diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md deleted file mode 100644 index 783d1c6..0000000 --- a/DEVELOPMENT.md +++ /dev/null @@ -1,39 +0,0 @@ -# Development Guide - -## Prerequisites - -Before running the application locally, ensure you have the following installed: - -1. **Go** (v1.23+ recommended) -2. **Node.js** (v16+ recommended) -3. **Wails CLI** - -### Installing Wails - -Since you already have Go installed, you can install the Wails CLI by running: - -```bash -go install github.com/wailsapp/wails/v2/cmd/wails@latest -``` - -Ensure that your `go/bin` directory is in your PATH. You can check if it's installed by running `wails version`. - -## Running the Application - -To run the application in development mode (with hot reloading for both frontend and backend): - -```bash -wails dev -``` - -This will compiles the application and open it in a window. It also starts a browser-based version at http://localhost:34115. - -## Building for Production - -To create a production build (Application.app): - -```bash -wails build -``` - -The output will be in the `build/bin` directory. diff --git a/app.go b/app.go index 64a65e3..86cddbf 100644 --- a/app.go +++ b/app.go @@ -12,23 +12,18 @@ import ( "time" ) -// App struct type App struct { ctx context.Context } -// NewApp creates a new App application struct func NewApp() *App { return &App{} } -// startup is called when the app starts. The context is saved -// so we can call the runtime methods func (a *App) startup(ctx context.Context) { a.ctx = ctx } -// SpotifyMetadataRequest represents the request structure for fetching Spotify metadata type SpotifyMetadataRequest struct { URL string `json:"url"` Batch bool `json:"batch"` @@ -36,7 +31,6 @@ type SpotifyMetadataRequest struct { Timeout float64 `json:"timeout"` } -// DownloadRequest represents the request structure for downloading tracks type DownloadRequest struct { ISRC string `json:"isrc"` Service string `json:"service"` @@ -46,36 +40,37 @@ type DownloadRequest struct { 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 + CoverURL string `json:"cover_url,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 - 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 + Position int `json:"position,omitempty"` + UseAlbumTrackNumber bool `json:"use_album_track_number,omitempty"` + SpotifyID string `json:"spotify_id,omitempty"` + EmbedLyrics bool `json:"embed_lyrics,omitempty"` + EmbedMaxQualityCover bool `json:"embed_max_quality_cover,omitempty"` + ServiceURL string `json:"service_url,omitempty"` + Duration int `json:"duration,omitempty"` + ItemID string `json:"item_id,omitempty"` + SpotifyTrackNumber int `json:"spotify_track_number,omitempty"` + SpotifyDiscNumber int `json:"spotify_disc_number,omitempty"` + SpotifyTotalTracks int `json:"spotify_total_tracks,omitempty"` + SpotifyTotalDiscs int `json:"spotify_total_discs,omitempty"` + Copyright string `json:"copyright,omitempty"` + Publisher string `json:"publisher,omitempty"` } -// DownloadResponse represents the response structure for download operations type DownloadResponse struct { Success bool `json:"success"` Message string `json:"message"` File string `json:"file,omitempty"` Error string `json:"error,omitempty"` AlreadyExists bool `json:"already_exists,omitempty"` - ItemID string `json:"item_id,omitempty"` // Queue item ID for tracking + ItemID string `json:"item_id,omitempty"` } -// GetStreamingURLs fetches all streaming URLs from song.link API func (a *App) GetStreamingURLs(spotifyTrackID string) (string, error) { if spotifyTrackID == "" { return "", fmt.Errorf("spotify track ID is required") @@ -96,7 +91,6 @@ func (a *App) GetStreamingURLs(spotifyTrackID string) (string, error) { return string(jsonData), nil } -// GetSpotifyMetadata fetches metadata from Spotify func (a *App) GetSpotifyMetadata(req SpotifyMetadataRequest) (string, error) { if req.URL == "" { return "", fmt.Errorf("URL parameter is required") @@ -125,13 +119,11 @@ func (a *App) GetSpotifyMetadata(req SpotifyMetadataRequest) (string, error) { return string(jsonData), nil } -// SpotifySearchRequest represents the request structure for searching Spotify type SpotifySearchRequest struct { Query string `json:"query"` Limit int `json:"limit"` } -// SearchSpotify searches for tracks, albums, artists, and playlists on Spotify func (a *App) SearchSpotify(req SpotifySearchRequest) (*backend.SearchResponse, error) { if req.Query == "" { return nil, fmt.Errorf("search query is required") @@ -147,15 +139,13 @@ func (a *App) SearchSpotify(req SpotifySearchRequest) (*backend.SearchResponse, return backend.SearchSpotify(ctx, req.Query, req.Limit) } -// SpotifySearchByTypeRequest represents the request for searching by specific type with offset type SpotifySearchByTypeRequest struct { Query string `json:"query"` - SearchType string `json:"search_type"` // track, album, artist, playlist + SearchType string `json:"search_type"` Limit int `json:"limit"` Offset int `json:"offset"` } -// SearchSpotifyByType searches for a specific type with offset support for pagination func (a *App) SearchSpotifyByType(req SpotifySearchByTypeRequest) ([]backend.SearchResult, error) { if req.Query == "" { return nil, fmt.Errorf("search query is required") @@ -175,13 +165,13 @@ func (a *App) SearchSpotifyByType(req SpotifySearchByTypeRequest) ([]backend.Sea return backend.SearchSpotifyByType(ctx, req.Query, req.SearchType, req.Limit, req.Offset) } -// DownloadTrack downloads a track by ISRC func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) { - if req.ISRC == "" { + + if req.Service == "qobuz" && req.ISRC == "" && req.SpotifyID == "" { return DownloadResponse{ Success: false, - Error: "ISRC is required", - }, fmt.Errorf("ISRC is required") + Error: "Spotify ID is required for Qobuz", + }, fmt.Errorf("spotify ID is required for Qobuz") } if req.Service == "" { @@ -191,7 +181,7 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) { if req.OutputDir == "" { req.OutputDir = "." } else { - // Only normalize path separators, don't sanitize user's existing folder names + req.OutputDir = backend.NormalizePath(req.OutputDir) } @@ -202,62 +192,89 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) { var err error var filename string - // Set default filename format if not provided if req.FilenameFormat == "" { req.FilenameFormat = "title-artist" } - // ItemID should always be provided by frontend (created via AddToDownloadQueue) - // If not provided, generate one for backwards compatibility itemID := req.ItemID if itemID == "" { - itemID = fmt.Sprintf("%s-%d", req.ISRC, time.Now().UnixNano()) - // Add to queue if no ItemID was provided (legacy support) - backend.AddToQueue(itemID, req.TrackName, req.ArtistName, req.AlbumName, req.ISRC) + + if req.SpotifyID != "" { + itemID = fmt.Sprintf("%s-%d", req.SpotifyID, time.Now().UnixNano()) + } else { + itemID = fmt.Sprintf("%s-%s-%d", req.TrackName, req.ArtistName, time.Now().UnixNano()) + } + + backend.AddToQueue(itemID, req.TrackName, req.ArtistName, req.AlbumName, req.SpotifyID) } - // Mark item as downloading immediately backend.SetDownloading(true) backend.StartDownloadItem(itemID) defer backend.SetDownloading(false) - // Early check: Check if file with same ISRC already exists - if existingFile, exists := backend.CheckISRCExists(req.OutputDir, req.ISRC); exists { - fmt.Printf("File with ISRC %s already exists: %s\n", req.ISRC, existingFile) - backend.SkipDownloadItem(itemID, existingFile) - return DownloadResponse{ - Success: true, - Message: "File with same ISRC already exists", - File: existingFile, - AlreadyExists: true, - ItemID: itemID, - }, nil + spotifyURL := "" + if req.SpotifyID != "" { + spotifyURL = fmt.Sprintf("https://open.spotify.com/track/%s", req.SpotifyID) + } + + if req.SpotifyID != "" && (req.Copyright == "" || req.Publisher == "" || req.SpotifyTotalDiscs == 0 || req.ReleaseDate == "" || req.SpotifyTotalTracks == 0 || req.SpotifyTrackNumber == 0) { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + trackURL := fmt.Sprintf("https://open.spotify.com/track/%s", req.SpotifyID) + trackData, err := backend.GetFilteredSpotifyData(ctx, trackURL, false, 0) + if err == nil { + + var trackResp struct { + Track struct { + Copyright string `json:"copyright"` + Publisher string `json:"publisher"` + TotalDiscs int `json:"total_discs"` + TotalTracks int `json:"total_tracks"` + TrackNumber int `json:"track_number"` + ReleaseDate string `json:"release_date"` + } `json:"track"` + } + if jsonData, jsonErr := json.Marshal(trackData); jsonErr == nil { + if json.Unmarshal(jsonData, &trackResp) == nil { + + if req.Copyright == "" && trackResp.Track.Copyright != "" { + req.Copyright = trackResp.Track.Copyright + } + if req.Publisher == "" && trackResp.Track.Publisher != "" { + req.Publisher = trackResp.Track.Publisher + } + if req.SpotifyTotalDiscs == 0 && trackResp.Track.TotalDiscs > 0 { + req.SpotifyTotalDiscs = trackResp.Track.TotalDiscs + } + if req.SpotifyTotalTracks == 0 && trackResp.Track.TotalTracks > 0 { + req.SpotifyTotalTracks = trackResp.Track.TotalTracks + } + if req.SpotifyTrackNumber == 0 && trackResp.Track.TrackNumber > 0 { + req.SpotifyTrackNumber = trackResp.Track.TrackNumber + } + if req.ReleaseDate == "" && trackResp.Track.ReleaseDate != "" { + req.ReleaseDate = trackResp.Track.ReleaseDate + } + } + } + } } - // Fallback: if we have track metadata, check if file already exists by filename if req.TrackName != "" && req.ArtistName != "" { expectedFilename := backend.BuildExpectedFilename(req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.FilenameFormat, req.TrackNumber, req.Position, req.SpotifyDiscNumber, req.UseAlbumTrackNumber) expectedPath := filepath.Join(req.OutputDir, expectedFilename) if fileInfo, err := os.Stat(expectedPath); err == nil && fileInfo.Size() > 100*1024 { - // Validate the file by checking if it has valid ISRC metadata - if fileISRC, readErr := backend.ReadISRCFromFile(expectedPath); readErr == nil && fileISRC != "" { - // File exists and has valid metadata - skip download - backend.SkipDownloadItem(itemID, expectedPath) - return DownloadResponse{ - Success: true, - Message: "File already exists", - File: expectedPath, - AlreadyExists: true, - ItemID: itemID, - }, nil - } else { - // File exists but has no valid ISRC metadata - it's corrupted, delete it - fmt.Printf("Removing corrupted file (no valid ISRC metadata): %s\n", expectedPath) - if removeErr := os.Remove(expectedPath); removeErr != nil { - fmt.Printf("Warning: Failed to remove corrupted file %s: %v\n", expectedPath, removeErr) - } - } + + backend.SkipDownloadItem(itemID, expectedPath) + return DownloadResponse{ + Success: true, + Message: "File already exists", + File: expectedPath, + AlreadyExists: true, + ItemID: itemID, + }, nil } } @@ -265,8 +282,8 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) { case "amazon": downloader := backend.NewAmazonDownloader() if req.ServiceURL != "" { - // Use provided URL directly - filename, err = downloader.DownloadByURL(req.ServiceURL, req.OutputDir, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.CoverURL, req.ISRC, req.SpotifyTrackNumber, req.SpotifyDiscNumber, req.SpotifyTotalTracks, req.EmbedMaxQualityCover) + + 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.SpotifyTrackNumber, req.SpotifyDiscNumber, req.SpotifyTotalTracks, req.EmbedMaxQualityCover, req.SpotifyTotalDiscs, req.Copyright, req.Publisher, spotifyURL) } else { if req.SpotifyID == "" { return DownloadResponse{ @@ -274,15 +291,15 @@ 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.AlbumArtist, req.ReleaseDate, req.CoverURL, req.ISRC, req.SpotifyTrackNumber, req.SpotifyDiscNumber, req.SpotifyTotalTracks, req.EmbedMaxQualityCover) + 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.SpotifyTrackNumber, req.SpotifyDiscNumber, req.SpotifyTotalTracks, req.EmbedMaxQualityCover, req.SpotifyTotalDiscs, req.Copyright, req.Publisher, spotifyURL) } case "tidal": if req.ApiURL == "" || req.ApiURL == "auto" { 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.AlbumArtist, req.ReleaseDate, req.UseAlbumTrackNumber, req.CoverURL, req.EmbedMaxQualityCover, req.SpotifyTrackNumber, req.SpotifyDiscNumber, req.SpotifyTotalTracks, req.ISRC) + + 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.SpotifyTotalDiscs, req.Copyright, req.Publisher, spotifyURL) } else { if req.SpotifyID == "" { return DownloadResponse{ @@ -290,14 +307,14 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) { Error: "Spotify ID is required for Tidal", }, fmt.Errorf("spotify ID is required for Tidal") } - // Use ISRC matching for search fallback - filename, err = downloader.DownloadWithFallbackAndISRC(req.SpotifyID, req.ISRC, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.TrackNumber, req.Position, req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, req.UseAlbumTrackNumber, req.Duration, req.CoverURL, req.EmbedMaxQualityCover, req.SpotifyTrackNumber, req.SpotifyDiscNumber, req.SpotifyTotalTracks) + + filename, err = downloader.Download(req.SpotifyID, 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.SpotifyTotalDiscs, req.Copyright, req.Publisher, spotifyURL) } } 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.AlbumArtist, req.ReleaseDate, req.UseAlbumTrackNumber, req.CoverURL, req.EmbedMaxQualityCover, req.SpotifyTrackNumber, req.SpotifyDiscNumber, req.SpotifyTotalTracks, req.ISRC) + + 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.SpotifyTotalDiscs, req.Copyright, req.Publisher, spotifyURL) } else { if req.SpotifyID == "" { return DownloadResponse{ @@ -305,19 +322,45 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) { Error: "Spotify ID is required for Tidal", }, 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.AlbumArtist, req.ReleaseDate, req.UseAlbumTrackNumber, req.Duration, req.CoverURL, req.EmbedMaxQualityCover, req.SpotifyTrackNumber, req.SpotifyDiscNumber, req.SpotifyTotalTracks) + + filename, err = downloader.Download(req.SpotifyID, 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.SpotifyTotalDiscs, req.Copyright, req.Publisher, spotifyURL) } } case "qobuz": downloader := backend.NewQobuzDownloader() - // 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) + + deezerISRC := req.ISRC + if deezerISRC == "" && req.SpotifyID != "" { + + songlinkClient := backend.NewSongLinkClient() + deezerURL, err := songlinkClient.GetDeezerURLFromSpotify(req.SpotifyID) + if err != nil { + return DownloadResponse{ + Success: false, + Error: fmt.Sprintf("Failed to get Deezer URL: %v", err), + }, err + } + deezerISRC, err = backend.GetDeezerISRC(deezerURL) + if err != nil { + return DownloadResponse{ + Success: false, + Error: fmt.Sprintf("Failed to get ISRC from Deezer: %v", err), + }, err + } + } + if deezerISRC == "" { + return DownloadResponse{ + Success: false, + Error: "ISRC is required for Qobuz (could not fetch from Deezer)", + }, fmt.Errorf("ISRC is required for Qobuz") + } + filename, err = downloader.DownloadByISRC(deezerISRC, 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, req.SpotifyTotalDiscs, req.Copyright, req.Publisher, spotifyURL) default: return DownloadResponse{ @@ -327,9 +370,9 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) { } if err != nil { - // Clean up any partial/corrupted file that was created during failed download + if filename != "" && !strings.HasPrefix(filename, "EXISTS:") { - // Check if file exists and delete it + if _, statErr := os.Stat(filename); statErr == nil { fmt.Printf("Removing corrupted/partial file after failed download: %s\n", filename) if removeErr := os.Remove(filename); removeErr != nil { @@ -338,8 +381,6 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) { } } - // Don't mark as failed in backend - let the frontend handle it - // Frontend will call MarkDownloadItemFailed after all services are tried return DownloadResponse{ Success: false, Error: fmt.Sprintf("Download failed: %v", err), @@ -347,14 +388,12 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) { }, err } - // Check if file already existed alreadyExists := false if strings.HasPrefix(filename, "EXISTS:") { alreadyExists = true 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") @@ -365,8 +404,7 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) { lyricsClient := backend.NewLyricsClient() - // Try all sources with fallbacks - lyricsResp, source, err := lyricsClient.FetchLyricsAllSources(spotifyID, trackName, artistName) + lyricsResp, source, err := lyricsClient.FetchLyricsAllSources(spotifyID, trackName, artistName, 0) if err != nil { fmt.Printf("All sources failed: %v\n", err) fmt.Printf("========== LYRICS FETCH END (FAILED) ==========\n\n") @@ -390,7 +428,6 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) { 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") @@ -411,12 +448,12 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) { message = "File already exists" backend.SkipDownloadItem(itemID, filename) } else { - // Get file size for completed download + if fileInfo, statErr := os.Stat(filename); statErr == nil { - finalSize := float64(fileInfo.Size()) / (1024 * 1024) // Convert to MB + finalSize := float64(fileInfo.Size()) / (1024 * 1024) backend.CompleteDownloadItem(itemID, filename, finalSize) } else { - // Fallback: mark as completed without size + backend.CompleteDownloadItem(itemID, filename, 0) } } @@ -430,7 +467,6 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) { }, nil } -// OpenFolder opens a folder in the file explorer func (a *App) OpenFolder(path string) error { if path == "" { return fmt.Errorf("path is required") @@ -444,67 +480,55 @@ func (a *App) OpenFolder(path string) error { return nil } -// SelectFolder opens a folder selection dialog and returns the selected path func (a *App) SelectFolder(defaultPath string) (string, error) { return backend.SelectFolderDialog(a.ctx, defaultPath) } -// SelectFile opens a file selection dialog and returns the selected file path func (a *App) SelectFile() (string, error) { return backend.SelectFileDialog(a.ctx) } -// GetDefaults returns the default configuration func (a *App) GetDefaults() map[string]string { return map[string]string{ "downloadPath": backend.GetDefaultMusicPath(), } } -// GetDownloadProgress returns current download progress func (a *App) GetDownloadProgress() backend.ProgressInfo { return backend.GetDownloadProgress() } -// GetDownloadQueue returns the complete download queue state func (a *App) GetDownloadQueue() backend.DownloadQueueInfo { return backend.GetDownloadQueue() } -// ClearCompletedDownloads clears completed, failed, and skipped items from the queue func (a *App) ClearCompletedDownloads() { backend.ClearDownloadQueue() } -// ClearAllDownloads clears the entire queue and resets session stats func (a *App) ClearAllDownloads() { backend.ClearAllDownloads() } -// AddToDownloadQueue adds a new item to the download queue and returns its ID func (a *App) AddToDownloadQueue(isrc, trackName, artistName, albumName string) string { itemID := fmt.Sprintf("%s-%d", isrc, time.Now().UnixNano()) backend.AddToQueue(itemID, trackName, artistName, albumName, isrc) return itemID } -// MarkDownloadItemFailed marks a download item as failed func (a *App) MarkDownloadItemFailed(itemID, errorMsg string) { backend.FailDownloadItem(itemID, errorMsg) } -// CancelAllQueuedItems marks all queued items as cancelled/skipped func (a *App) CancelAllQueuedItems() { backend.CancelAllQueuedItems() } -// Quit closes the application func (a *App) Quit() { - // You can add cleanup logic here if needed - panic("quit") // This will trigger Wails to close the app + + panic("quit") } -// AnalyzeTrack analyzes audio quality of a FLAC file func (a *App) AnalyzeTrack(filePath string) (string, error) { if filePath == "" { return "", fmt.Errorf("file path is required") @@ -523,7 +547,6 @@ func (a *App) AnalyzeTrack(filePath string) (string, error) { return string(jsonData), nil } -// AnalyzeMultipleTracks analyzes multiple FLAC files func (a *App) AnalyzeMultipleTracks(filePaths []string) (string, error) { if len(filePaths) == 0 { return "", fmt.Errorf("at least one file path is required") @@ -534,7 +557,7 @@ func (a *App) AnalyzeMultipleTracks(filePaths []string) (string, error) { for _, filePath := range filePaths { result, err := backend.AnalyzeTrack(filePath) if err != nil { - // Skip failed analyses + continue } results = append(results, result) @@ -548,7 +571,6 @@ func (a *App) AnalyzeMultipleTracks(filePaths []string) (string, error) { return string(jsonData), nil } -// LyricsDownloadRequest represents the request structure for downloading lyrics type LyricsDownloadRequest struct { SpotifyID string `json:"spotify_id"` TrackName string `json:"track_name"` @@ -564,7 +586,6 @@ type LyricsDownloadRequest struct { DiscNumber int `json:"disc_number"` } -// DownloadLyrics downloads lyrics for a single track func (a *App) DownloadLyrics(req LyricsDownloadRequest) (backend.LyricsDownloadResponse, error) { if req.SpotifyID == "" { return backend.LyricsDownloadResponse{ @@ -600,7 +621,6 @@ func (a *App) DownloadLyrics(req LyricsDownloadRequest) (backend.LyricsDownloadR return *resp, nil } -// CoverDownloadRequest represents the request structure for downloading cover art type CoverDownloadRequest struct { CoverURL string `json:"cover_url"` TrackName string `json:"track_name"` @@ -615,7 +635,6 @@ type CoverDownloadRequest struct { DiscNumber int `json:"disc_number"` } -// DownloadCover downloads cover art for a single track func (a *App) DownloadCover(req CoverDownloadRequest) (backend.CoverDownloadResponse, error) { if req.CoverURL == "" { return backend.CoverDownloadResponse{ @@ -650,7 +669,125 @@ func (a *App) DownloadCover(req CoverDownloadRequest) (backend.CoverDownloadResp return *resp, nil } -// CheckTrackAvailability checks the availability of a track on different streaming platforms +type HeaderDownloadRequest struct { + HeaderURL string `json:"header_url"` + ArtistName string `json:"artist_name"` + OutputDir string `json:"output_dir"` +} + +func (a *App) DownloadHeader(req HeaderDownloadRequest) (backend.HeaderDownloadResponse, error) { + if req.HeaderURL == "" { + return backend.HeaderDownloadResponse{ + Success: false, + Error: "Header URL is required", + }, fmt.Errorf("header URL is required") + } + + if req.ArtistName == "" { + return backend.HeaderDownloadResponse{ + Success: false, + Error: "Artist name is required", + }, fmt.Errorf("artist name is required") + } + + client := backend.NewCoverClient() + backendReq := backend.HeaderDownloadRequest{ + HeaderURL: req.HeaderURL, + ArtistName: req.ArtistName, + OutputDir: req.OutputDir, + } + + resp, err := client.DownloadHeader(backendReq) + if err != nil { + return backend.HeaderDownloadResponse{ + Success: false, + Error: err.Error(), + }, err + } + + return *resp, nil +} + +type GalleryImageDownloadRequest struct { + ImageURL string `json:"image_url"` + ArtistName string `json:"artist_name"` + ImageIndex int `json:"image_index"` + OutputDir string `json:"output_dir"` +} + +func (a *App) DownloadGalleryImage(req GalleryImageDownloadRequest) (backend.GalleryImageDownloadResponse, error) { + if req.ImageURL == "" { + return backend.GalleryImageDownloadResponse{ + Success: false, + Error: "Image URL is required", + }, fmt.Errorf("image URL is required") + } + + if req.ArtistName == "" { + return backend.GalleryImageDownloadResponse{ + Success: false, + Error: "Artist name is required", + }, fmt.Errorf("artist name is required") + } + + client := backend.NewCoverClient() + backendReq := backend.GalleryImageDownloadRequest{ + ImageURL: req.ImageURL, + ArtistName: req.ArtistName, + ImageIndex: req.ImageIndex, + OutputDir: req.OutputDir, + } + + resp, err := client.DownloadGalleryImage(backendReq) + if err != nil { + return backend.GalleryImageDownloadResponse{ + Success: false, + Error: err.Error(), + }, err + } + + return *resp, nil +} + +type AvatarDownloadRequest struct { + AvatarURL string `json:"avatar_url"` + ArtistName string `json:"artist_name"` + OutputDir string `json:"output_dir"` +} + +func (a *App) DownloadAvatar(req AvatarDownloadRequest) (backend.AvatarDownloadResponse, error) { + if req.AvatarURL == "" { + return backend.AvatarDownloadResponse{ + Success: false, + Error: "Avatar URL is required", + }, fmt.Errorf("avatar URL is required") + } + + if req.ArtistName == "" { + return backend.AvatarDownloadResponse{ + Success: false, + Error: "Artist name is required", + }, fmt.Errorf("artist name is required") + } + + client := backend.NewCoverClient() + backendReq := backend.AvatarDownloadRequest{ + AvatarURL: req.AvatarURL, + ArtistName: req.ArtistName, + OutputDir: req.OutputDir, + } + + resp, err := client.DownloadAvatar(backendReq) + if err != nil { + return backend.AvatarDownloadResponse{ + Success: false, + Error: err.Error(), + }, err + } + + return *resp, nil +} + func (a *App) CheckTrackAvailability(spotifyTrackID string, isrc string) (string, error) { if spotifyTrackID == "" { return "", fmt.Errorf("spotify track ID is required") @@ -670,32 +807,26 @@ 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) @@ -713,15 +844,13 @@ func (a *App) DownloadFFmpeg() DownloadFFmpegResponse { } } -// 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) + Codec string `json:"codec"` } -// ConvertAudio converts audio files using ffmpeg func (a *App) ConvertAudio(req ConvertAudioRequest) ([]backend.ConvertAudioResult, error) { backendReq := backend.ConvertAudioRequest{ InputFiles: req.InputFiles, @@ -732,7 +861,6 @@ func (a *App) ConvertAudio(req ConvertAudioRequest) ([]backend.ConvertAudioResul 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 { @@ -741,12 +869,10 @@ func (a *App) SelectAudioFiles() ([]string, error) { 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") @@ -754,7 +880,6 @@ func (a *App) ListDirectoryFiles(dirPath string) ([]backend.FileInfo, error) { 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") @@ -762,7 +887,6 @@ func (a *App) ListAudioFilesInDir(dirPath string) ([]backend.FileInfo, error) { 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") @@ -770,17 +894,14 @@ func (a *App) ReadFileMetadata(filePath string) (*backend.AudioMetadata, error) 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) } -// ReadTextFile reads a text file and returns its content func (a *App) ReadTextFile(filePath string) (string, error) { content, err := os.ReadFile(filePath) if err != nil { @@ -789,7 +910,6 @@ func (a *App) ReadTextFile(filePath string) (string, error) { return string(content), nil } -// RenameFileTo renames a file to a new name (keeping same directory) func (a *App) RenameFileTo(oldPath, newName string) error { dir := filepath.Dir(oldPath) ext := filepath.Ext(oldPath) @@ -797,13 +917,12 @@ func (a *App) RenameFileTo(oldPath, newName string) error { return os.Rename(oldPath, newPath) } -// ReadImageAsBase64 reads an image file and returns it as base64 data URL func (a *App) ReadImageAsBase64(filePath string) (string, error) { content, err := os.ReadFile(filePath) if err != nil { return "", err } - + ext := strings.ToLower(filepath.Ext(filePath)) var mimeType string switch ext { @@ -818,44 +937,115 @@ func (a *App) ReadImageAsBase64(filePath string) (string, error) { default: mimeType = "image/jpeg" } - + encoded := base64.StdEncoding.EncodeToString(content) return fmt.Sprintf("data:%s;base64,%s", mimeType, encoded), nil } -// 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"` + SpotifyID string `json:"spotify_id"` + TrackName string `json:"track_name"` + ArtistName string `json:"artist_name"` + AlbumName string `json:"album_name,omitempty"` + AlbumArtist string `json:"album_artist,omitempty"` + ReleaseDate string `json:"release_date,omitempty"` + TrackNumber int `json:"track_number,omitempty"` + DiscNumber int `json:"disc_number,omitempty"` + Position int `json:"position,omitempty"` + UseAlbumTrackNumber bool `json:"use_album_track_number,omitempty"` + FilenameFormat string `json:"filename_format,omitempty"` + IncludeTrackNumber bool `json:"include_track_number,omitempty"` + AudioFormat string `json:"audio_format,omitempty"` } -// 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)) +type CheckFileExistenceResult struct { + SpotifyID string `json:"spotify_id"` + Exists bool `json:"exists"` + FilePath string `json:"file_path,omitempty"` + TrackName string `json:"track_name,omitempty"` + ArtistName string `json:"artist_name,omitempty"` +} - for i, t := range tracks { - backendTracks[i] = struct { - ISRC string - TrackName string - ArtistName string - }{ - ISRC: t.ISRC, - TrackName: t.TrackName, - ArtistName: t.ArtistName, - } +func (a *App) CheckFilesExistence(outputDir string, tracks []CheckFileExistenceRequest) []CheckFileExistenceResult { + if len(tracks) == 0 { + return []CheckFileExistenceResult{} } - return backend.CheckFilesExistParallel(outputDir, backendTracks) + outputDir = backend.NormalizePath(outputDir) + + defaultFilenameFormat := "title-artist" + + type result struct { + index int + result CheckFileExistenceResult + } + + resultsChan := make(chan result, len(tracks)) + + for i, track := range tracks { + go func(idx int, t CheckFileExistenceRequest) { + res := CheckFileExistenceResult{ + SpotifyID: t.SpotifyID, + TrackName: t.TrackName, + ArtistName: t.ArtistName, + Exists: false, + } + + if t.TrackName == "" || t.ArtistName == "" { + resultsChan <- result{index: idx, result: res} + return + } + + filenameFormat := t.FilenameFormat + if filenameFormat == "" { + filenameFormat = defaultFilenameFormat + } + + trackNumber := t.Position + if t.UseAlbumTrackNumber && t.TrackNumber > 0 { + trackNumber = t.TrackNumber + } + + fileExt := ".flac" + if t.AudioFormat == "mp3" { + fileExt = ".mp3" + } + + expectedFilenameBase := backend.BuildExpectedFilename( + t.TrackName, + t.ArtistName, + t.AlbumName, + t.AlbumArtist, + t.ReleaseDate, + filenameFormat, + t.IncludeTrackNumber, + trackNumber, + t.DiscNumber, + t.UseAlbumTrackNumber, + ) + + expectedFilename := strings.TrimSuffix(expectedFilenameBase, ".flac") + fileExt + + expectedPath := filepath.Join(outputDir, expectedFilename) + + if fileInfo, err := os.Stat(expectedPath); err == nil && fileInfo.Size() > 100*1024 { + res.Exists = true + res.FilePath = expectedPath + } + + resultsChan <- result{index: idx, result: res} + }(i, track) + } + + results := make([]CheckFileExistenceResult, len(tracks)) + for i := 0; i < len(tracks); i++ { + r := <-resultsChan + results[r.index] = r.result + } + + return results } -// SkipDownloadItem marks a download item as skipped (file already exists) func (a *App) SkipDownloadItem(itemID, filePath string) { backend.SkipDownloadItem(itemID, filePath) } diff --git a/backend/amazon.go b/backend/amazon.go index 4c4e2d4..0799231 100644 --- a/backend/amazon.go +++ b/backend/amazon.go @@ -63,16 +63,14 @@ func (a *AmazonDownloader) getRandomUserAgent() string { } func (a *AmazonDownloader) GetAmazonURLFromSpotify(spotifyTrackID string) (string, error) { - // Rate limiting: max 10 requests per minute (song.link API limit) - // Reset counter every minute + now := time.Now() if now.Sub(a.apiCallResetTime) >= time.Minute { a.apiCallCount = 0 a.apiCallResetTime = now } - // If we've hit the limit, wait until the next minute - if a.apiCallCount >= 9 { // Use 9 to be safe (limit is 10) + if a.apiCallCount >= 9 { waitTime := time.Minute - now.Sub(a.apiCallResetTime) if waitTime > 0 { fmt.Printf("Rate limit reached, waiting %v...\n", waitTime.Round(time.Second)) @@ -82,10 +80,9 @@ func (a *AmazonDownloader) GetAmazonURLFromSpotify(spotifyTrackID string) (strin } } - // Add delay between requests (6 seconds = 10 requests per minute) if !a.lastAPICallTime.IsZero() { timeSinceLastCall := now.Sub(a.lastAPICallTime) - minDelay := 7 * time.Second // 7 seconds to be safe + minDelay := 7 * time.Second if timeSinceLastCall < minDelay { waitTime := minDelay - timeSinceLastCall fmt.Printf("Rate limiting: waiting %v...\n", waitTime.Round(time.Second)) @@ -93,7 +90,6 @@ func (a *AmazonDownloader) GetAmazonURLFromSpotify(spotifyTrackID string) (strin } } - // Decode base64 API URL spotifyBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9vcGVuLnNwb3RpZnkuY29tL3RyYWNrLw==") spotifyURL := fmt.Sprintf("%s%s", string(spotifyBase), spotifyTrackID) @@ -109,7 +105,6 @@ func (a *AmazonDownloader) GetAmazonURLFromSpotify(spotifyTrackID string) (strin fmt.Println("Getting Amazon URL...") - // Retry logic for rate limit errors maxRetries := 3 var resp *http.Response for i := 0; i < maxRetries; i++ { @@ -118,11 +113,10 @@ func (a *AmazonDownloader) GetAmazonURLFromSpotify(spotifyTrackID string) (strin return "", fmt.Errorf("failed to get Amazon URL: %w", err) } - // Update rate limit tracking a.lastAPICallTime = time.Now() a.apiCallCount++ - if resp.StatusCode == 429 { // Too Many Requests + if resp.StatusCode == 429 { resp.Body.Close() if i < maxRetries-1 { waitTime := 15 * time.Second @@ -142,7 +136,6 @@ 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) @@ -154,7 +147,7 @@ func (a *AmazonDownloader) GetAmazonURLFromSpotify(spotifyTrackID string) (strin var songLinkResp SongLinkResponse 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] + "..." @@ -169,7 +162,6 @@ func (a *AmazonDownloader) GetAmazonURLFromSpotify(spotifyTrackID string) (strin amazonURL := amazonLink.URL - // Convert album URL to track URL if needed if strings.Contains(amazonURL, "trackAsin=") { parts := strings.Split(amazonURL, "trackAsin=") if len(parts) > 1 { @@ -188,12 +180,11 @@ func (a *AmazonDownloader) DownloadFromService(amazonURL, outputDir string) (str for _, region := range a.regions { fmt.Printf("\nTrying region: %s...\n", region) - // Decode base64 service URL + serviceBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly8=") serviceDomain, _ := base64.StdEncoding.DecodeString("LmRvdWJsZWRvdWJsZS50b3A=") baseURL := fmt.Sprintf("%s%s%s", string(serviceBase), region, string(serviceDomain)) - // Step 1: Submit download request encodedURL := url.QueryEscape(amazonURL) submitURL := fmt.Sprintf("%s/dl?url=%s", baseURL, encodedURL) @@ -234,7 +225,6 @@ func (a *AmazonDownloader) DownloadFromService(amazonURL, outputDir string) (str downloadID := submitResp.ID fmt.Printf("Download ID: %s\n", downloadID) - // Step 2: Poll for completion statusURL := fmt.Sprintf("%s/dl/%s", baseURL, downloadID) fmt.Println("Waiting for download to complete...") @@ -276,7 +266,6 @@ func (a *AmazonDownloader) DownloadFromService(amazonURL, outputDir string) (str if status.Status == "done" { fmt.Println("\nDownload ready!") - // Build download URL fileURL := status.URL if strings.HasPrefix(fileURL, "./") { fileURL = fmt.Sprintf("%s/%s", baseURL, fileURL[2:]) @@ -289,7 +278,6 @@ func (a *AmazonDownloader) DownloadFromService(amazonURL, outputDir string) (str fmt.Printf("Downloading: %s - %s\n", artist, trackName) - // Download file downloadReq, err := http.NewRequest("GET", fileURL, nil) if err != nil { lastError = fmt.Errorf("failed to create download request: %w", err) @@ -310,7 +298,6 @@ func (a *AmazonDownloader) DownloadFromService(amazonURL, outputDir string) (str break } - // Generate filename fileName := fmt.Sprintf("%s - %s.flac", artist, trackName) for _, char := range `<>:"/\|?*` { fileName = strings.ReplaceAll(fileName, string(char), "") @@ -319,7 +306,6 @@ func (a *AmazonDownloader) DownloadFromService(amazonURL, outputDir string) (str filePath := filepath.Join(outputDir, fileName) - // Save file out, err := os.Create(filePath) if err != nil { lastError = fmt.Errorf("failed to create file: %w", err) @@ -328,7 +314,7 @@ func (a *AmazonDownloader) DownloadFromService(amazonURL, outputDir string) (str defer out.Close() fmt.Println("Downloading...") - // Use progress writer to track download + pw := NewProgressWriter(out) _, err = io.Copy(pw, fileResp.Body) if err != nil { @@ -336,7 +322,6 @@ func (a *AmazonDownloader) DownloadFromService(amazonURL, outputDir string) (str return "", fmt.Errorf("failed to write file: %w", err) } - // Print final size fmt.Printf("\rDownloaded: %.2f MB (Complete)\n", float64(pw.GetTotal())/(1024*1024)) fmt.Println("Download complete!") return filePath, nil @@ -349,7 +334,7 @@ func (a *AmazonDownloader) DownloadFromService(amazonURL, outputDir string) (str lastError = fmt.Errorf("processing failed: %s", errorMsg) break } else { - // Still processing + friendlyStatus := status.FriendlyStatus if friendlyStatus == "" { friendlyStatus = status.Status @@ -372,15 +357,14 @@ 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, spotifyAlbumArtist, spotifyReleaseDate, spotifyCoverURL, spotifyISRC string, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int, embedMaxQualityCover bool) (string, error) { - // Create output directory if needed +func (a *AmazonDownloader) DownloadByURL(amazonURL, outputDir, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, spotifyCoverURL string, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int, embedMaxQualityCover bool, spotifyTotalDiscs int, spotifyCopyright, spotifyPublisher, spotifyURL string) (string, error) { + if outputDir != "." { if err := os.MkdirAll(outputDir, 0755); err != nil { return "", fmt.Errorf("failed to create output directory: %w", err) } } - // Check if file with expected name already exists (Amazon doesn't provide ISRC before download) if spotifyTrackName != "" && spotifyArtistName != "" { expectedFilename := BuildExpectedFilename(spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, filenameFormat, includeTrackNumber, position, spotifyDiscNumber, false) expectedPath := filepath.Join(outputDir, expectedFilename) @@ -393,29 +377,24 @@ func (a *AmazonDownloader) DownloadByURL(amazonURL, outputDir, filenameFormat st fmt.Printf("Using Amazon URL: %s\n", amazonURL) - // Download from service filePath, err := a.DownloadFromService(amazonURL, outputDir) if err != nil { return "", err } - // Rename file based on Spotify metadata if spotifyTrackName != "" && spotifyArtistName != "" { safeArtist := sanitizeFilename(spotifyArtistName) safeTitle := sanitizeFilename(spotifyTrackName) safeAlbum := sanitizeFilename(spotifyAlbumName) safeAlbumArtist := sanitizeFilename(spotifyAlbumArtist) - // Extract year from release date year := "" if len(spotifyReleaseDate) >= 4 { year = spotifyReleaseDate[:4] } - // Build filename based on format settings var newFilename string - // Check if format is a template (contains {}) if strings.Contains(filenameFormat, "{") { newFilename = filenameFormat newFilename = strings.ReplaceAll(newFilename, "{title}", safeTitle) @@ -424,34 +403,31 @@ func (a *AmazonDownloader) DownloadByURL(amazonURL, outputDir, filenameFormat st newFilename = strings.ReplaceAll(newFilename, "{album_artist}", safeAlbumArtist) newFilename = strings.ReplaceAll(newFilename, "{year}", year) - // Handle disc number if spotifyDiscNumber > 0 { newFilename = strings.ReplaceAll(newFilename, "{disc}", fmt.Sprintf("%d", spotifyDiscNumber)) } else { newFilename = strings.ReplaceAll(newFilename, "{disc}", "") } - // Handle track number - if position is 0, remove {track} and surrounding separators if position > 0 { newFilename = strings.ReplaceAll(newFilename, "{track}", fmt.Sprintf("%02d", position)) } else { - // Remove {track} with common separators + newFilename = regexp.MustCompile(`\{track\}\.\s*`).ReplaceAllString(newFilename, "") newFilename = regexp.MustCompile(`\{track\}\s*-\s*`).ReplaceAllString(newFilename, "") newFilename = regexp.MustCompile(`\{track\}\s*`).ReplaceAllString(newFilename, "") } } else { - // Legacy format support + switch filenameFormat { case "artist-title": newFilename = fmt.Sprintf("%s - %s", safeArtist, safeTitle) case "title": newFilename = safeTitle - default: // "title-artist" + default: newFilename = fmt.Sprintf("%s - %s", safeTitle, safeArtist) } - // Add track number prefix if enabled (legacy behavior) if includeTrackNumber && position > 0 { newFilename = fmt.Sprintf("%02d. %s", position, newFilename) } @@ -460,7 +436,6 @@ func (a *AmazonDownloader) DownloadByURL(amazonURL, outputDir, filenameFormat st newFilename = newFilename + ".flac" newFilePath := filepath.Join(outputDir, newFilename) - // Rename file if err := os.Rename(filePath, newFilePath); err != nil { fmt.Printf("Warning: Failed to rename file: %v\n", err) } else { @@ -469,11 +444,10 @@ 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() @@ -486,27 +460,24 @@ func (a *AmazonDownloader) DownloadByURL(amazonURL, outputDir, filenameFormat st } } - // 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 + trackNumberToEmbed = 1 } - // Build metadata from Spotify metadata := Metadata{ Title: spotifyTrackName, Artist: spotifyArtistName, Album: spotifyAlbumName, AlbumArtist: spotifyAlbumArtist, - Date: spotifyReleaseDate, // Recorded date (full date YYYY-MM-DD) + Date: spotifyReleaseDate, TrackNumber: trackNumberToEmbed, - TotalTracks: spotifyTotalTracks, // Total tracks in album from Spotify - DiscNumber: spotifyDiscNumber, // Disc number from Spotify - ISRC: spotifyISRC, // Use ISRC from Spotify + TotalTracks: spotifyTotalTracks, + DiscNumber: spotifyDiscNumber, + TotalDiscs: spotifyTotalDiscs, + URL: spotifyURL, + Copyright: spotifyCopyright, + Publisher: spotifyPublisher, Description: "https://github.com/afkarxyz/SpotiFLAC", } @@ -521,12 +492,12 @@ func (a *AmazonDownloader) DownloadByURL(amazonURL, outputDir, filenameFormat st return filePath, nil } -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 +func (a *AmazonDownloader) DownloadBySpotifyID(spotifyTrackID, outputDir, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, spotifyCoverURL string, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int, embedMaxQualityCover bool, spotifyTotalDiscs int, spotifyCopyright, spotifyPublisher, spotifyURL string) (string, error) { + amazonURL, err := a.GetAmazonURLFromSpotify(spotifyTrackID) if err != nil { return "", err } - return a.DownloadByURL(amazonURL, outputDir, filenameFormat, includeTrackNumber, position, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, spotifyCoverURL, spotifyISRC, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks, embedMaxQualityCover) + return a.DownloadByURL(amazonURL, outputDir, filenameFormat, includeTrackNumber, position, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, spotifyCoverURL, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks, embedMaxQualityCover, spotifyTotalDiscs, spotifyCopyright, spotifyPublisher, spotifyURL) } diff --git a/backend/analysis.go b/backend/analysis.go index 0ca4214..9762c0a 100644 --- a/backend/analysis.go +++ b/backend/analysis.go @@ -9,7 +9,6 @@ import ( mewflac "github.com/mewkiz/flac" ) -// AnalysisResult contains the audio analysis data type AnalysisResult struct { FilePath string `json:"file_path"` FileSize int64 `json:"file_size"` @@ -25,19 +24,16 @@ type AnalysisResult struct { Spectrum *SpectrumData `json:"spectrum,omitempty"` } -// AnalyzeTrack performs audio analysis on a FLAC file func AnalyzeTrack(filepath string) (*AnalysisResult, error) { if !fileExists(filepath) { return nil, fmt.Errorf("file does not exist: %s", filepath) } - // 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 { return nil, fmt.Errorf("failed to parse FLAC file: %w", err) @@ -48,68 +44,55 @@ func AnalyzeTrack(filepath string) (*AnalysisResult, error) { FileSize: fileInfo.Size(), } - // Extract basic audio properties from STREAMINFO block if len(f.Meta) > 0 { streamInfo := f.Meta[0] if streamInfo.Type == flac.StreamInfo { - // Read STREAMINFO data + data := streamInfo.Data if len(data) >= 18 { - // Sample rate (bits 10-29 of bytes 10-13) + result.SampleRate = uint32(data[10])<<12 | uint32(data[11])<<4 | uint32(data[12])>>4 - // Channels (bits 30-32 of byte 12) result.Channels = ((data[12] >> 1) & 0x07) + 1 - // Bits per sample (bits 33-37 of bytes 12-13) result.BitsPerSample = ((data[12]&0x01)<<4 | data[13]>>4) + 1 - // Total samples (bits 38-73 of bytes 13-17) result.TotalSamples = uint64(data[13]&0x0F)<<32 | uint64(data[14])<<24 | uint64(data[15])<<16 | uint64(data[16])<<8 | uint64(data[17]) - // Calculate duration if result.SampleRate > 0 { result.Duration = float64(result.TotalSamples) / float64(result.SampleRate) } - // Read min/max frame size and block size for additional analysis - // Min block size (bytes 0-1) - // Max block size (bytes 2-3) - // These can give us hints about encoding quality } } } - // Analyze spectrum and calculate real audio metrics spectrum, err := AnalyzeSpectrum(filepath) if err != nil { - // Log error but continue + fmt.Printf("Warning: failed to analyze spectrum: %v\n", err) } else { result.Spectrum = spectrum - // Calculate dynamic range, peak, and RMS from decoded samples + calculateRealAudioMetrics(result, filepath) } - // Set bit depth result.BitDepth = fmt.Sprintf("%d-bit", result.BitsPerSample) return result, nil } -// calculateRealAudioMetrics calculates actual dynamic range, peak, and RMS from decoded audio func calculateRealAudioMetrics(result *AnalysisResult, filepath string) { - // Decode FLAC to get actual samples + samples, err := decodeFLACForMetrics(filepath) if err != nil { return } - // Calculate peak amplitude var peak float64 var sumSquares float64 @@ -124,20 +107,16 @@ func calculateRealAudioMetrics(result *AnalysisResult, filepath string) { sumSquares += sample * sample } - // Convert peak to dB (reference: 1.0 = 0 dBFS) peakDB := 20.0 * math.Log10(peak) result.PeakAmplitude = peakDB - // Calculate RMS (Root Mean Square) rms := math.Sqrt(sumSquares / float64(len(samples))) rmsDB := 20.0 * math.Log10(rms) result.RMSLevel = rmsDB - // Dynamic range is the difference between peak and RMS result.DynamicRange = peakDB - rmsDB } -// decodeFLACForMetrics decodes FLAC file and returns normalized samples for metric calculation func decodeFLACForMetrics(filepath string) ([]float64, error) { stream, err := mewflac.ParseFile(filepath) if err != nil { @@ -145,24 +124,20 @@ func decodeFLACForMetrics(filepath string) ([]float64, error) { } defer stream.Close() - // Limit samples to prevent memory issues (10 million samples = ~3.8 minutes at 44.1kHz) maxSamples := 10000000 samples := make([]float64, 0, maxSamples) - // Read all audio frames for { frame, err := stream.ParseNext() if err != nil { break } - // Get samples from first channel (mono or left channel) var channelSamples []int32 if len(frame.Subframes) > 0 { channelSamples = frame.Subframes[0].Samples } - // Normalize samples to -1.0 to 1.0 range maxVal := float64(int64(1) << (stream.Info.BitsPerSample - 1)) for _, sample := range channelSamples { if len(samples) >= maxSamples { diff --git a/backend/config.go b/backend/config.go index 5e2f793..51333d1 100644 --- a/backend/config.go +++ b/backend/config.go @@ -6,13 +6,12 @@ import ( ) func GetDefaultMusicPath() string { - // Get user's home directory + homeDir, err := os.UserHomeDir() if err != nil { - // Fallback to Public Music if can't get home dir + return "C:\\Users\\Public\\Music" } - // Return path to user's Music folder return filepath.Join(homeDir, "Music") } diff --git a/backend/cover.go b/backend/cover.go index 269b874..73e8bae 100644 --- a/backend/cover.go +++ b/backend/cover.go @@ -12,12 +12,10 @@ import ( ) const ( - // Spotify image size codes - spotifySize640 = "ab67616d0000b273" // 640x640 - spotifySizeMax = "ab67616d000082c1" // Max resolution + spotifySize640 = "ab67616d0000b273" + spotifySizeMax = "ab67616d000082c1" ) -// CoverDownloadRequest represents a request to download cover art type CoverDownloadRequest struct { CoverURL string `json:"cover_url"` TrackName string `json:"track_name"` @@ -32,7 +30,6 @@ type CoverDownloadRequest struct { DiscNumber int `json:"disc_number"` } -// CoverDownloadResponse represents the response from cover download type CoverDownloadResponse struct { Success bool `json:"success"` Message string `json:"message"` @@ -41,26 +38,36 @@ type CoverDownloadResponse struct { AlreadyExists bool `json:"already_exists,omitempty"` } -// CoverClient handles cover art downloading +type HeaderDownloadRequest struct { + HeaderURL string `json:"header_url"` + ArtistName string `json:"artist_name"` + OutputDir string `json:"output_dir"` +} + +type HeaderDownloadResponse struct { + Success bool `json:"success"` + Message string `json:"message"` + File string `json:"file,omitempty"` + Error string `json:"error,omitempty"` + AlreadyExists bool `json:"already_exists,omitempty"` +} + type CoverClient struct { httpClient *http.Client } -// NewCoverClient creates a new cover client func NewCoverClient() *CoverClient { return &CoverClient{ httpClient: &http.Client{Timeout: 30 * time.Second}, } } -// buildCoverFilename builds the cover filename based on settings (same as track filename) func buildCoverFilename(trackName, artistName, albumName, albumArtist, releaseDate, filenameFormat string, includeTrackNumber bool, position, discNumber int) string { safeTitle := sanitizeFilename(trackName) safeArtist := sanitizeFilename(artistName) safeAlbum := sanitizeFilename(albumName) safeAlbumArtist := sanitizeFilename(albumArtist) - // Extract year from release date (format: YYYY-MM-DD or YYYY) year := "" if len(releaseDate) >= 4 { year = releaseDate[:4] @@ -68,7 +75,6 @@ func buildCoverFilename(trackName, artistName, albumName, albumArtist, releaseDa var filename string - // Check if format is a template (contains {}) if strings.Contains(filenameFormat, "{") { filename = filenameFormat filename = strings.ReplaceAll(filename, "{title}", safeTitle) @@ -77,72 +83,58 @@ func buildCoverFilename(trackName, artistName, albumName, albumArtist, releaseDa filename = strings.ReplaceAll(filename, "{album_artist}", safeAlbumArtist) filename = strings.ReplaceAll(filename, "{year}", year) - // Handle disc number if discNumber > 0 { filename = strings.ReplaceAll(filename, "{disc}", fmt.Sprintf("%d", discNumber)) } else { filename = strings.ReplaceAll(filename, "{disc}", "") } - // Handle track number - if position is 0, remove {track} and surrounding separators if position > 0 { filename = strings.ReplaceAll(filename, "{track}", fmt.Sprintf("%02d", position)) } 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 filenameFormat { case "artist-title": filename = fmt.Sprintf("%s - %s", safeArtist, safeTitle) + case "title-artist": + filename = fmt.Sprintf("%s - %s", safeTitle, safeArtist) case "title": filename = safeTitle - default: // "title-artist" + default: filename = fmt.Sprintf("%s - %s", safeTitle, safeArtist) } - // Add track number prefix if enabled (legacy behavior) if includeTrackNumber && position > 0 { - filename = fmt.Sprintf("%02d. %s", position, filename) + filename = fmt.Sprintf("%02d - %s", position, filename) } } - return filename + ".jpg" + return filename + ".cover.jpg" } -// getMaxResolutionURL converts a Spotify cover URL to max resolution -// Falls back to original URL if max resolution is not available -func (c *CoverClient) getMaxResolutionURL(coverURL string) string { - // Try to convert to max resolution - if strings.Contains(coverURL, spotifySize640) { - maxURL := strings.Replace(coverURL, spotifySize640, spotifySizeMax, 1) - // Check if max resolution URL is available - resp, err := c.httpClient.Head(maxURL) - if err == nil && resp.StatusCode == http.StatusOK { - return maxURL - } +func (c *CoverClient) getMaxResolutionURL(imageURL string) string { + if strings.Contains(imageURL, spotifySize640) { + return strings.Replace(imageURL, spotifySize640, spotifySizeMax, 1) } - // Return original URL as fallback - return coverURL + return imageURL } -// 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) @@ -153,14 +145,12 @@ func (c *CoverClient) DownloadCoverToPath(coverURL, outputPath string, embedMaxQ 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) @@ -169,7 +159,6 @@ func (c *CoverClient) DownloadCoverToPath(coverURL, outputPath string, embedMaxQ return nil } -// DownloadCover downloads cover art for a single track func (c *CoverClient) DownloadCover(req CoverDownloadRequest) (*CoverDownloadResponse, error) { if req.CoverURL == "" { return &CoverDownloadResponse{ @@ -178,7 +167,6 @@ func (c *CoverClient) DownloadCover(req CoverDownloadRequest) (*CoverDownloadRes }, fmt.Errorf("cover URL is required") } - // Create output directory if it doesn't exist outputDir := req.OutputDir if outputDir == "" { outputDir = GetDefaultMusicPath() @@ -193,15 +181,13 @@ func (c *CoverClient) DownloadCover(req CoverDownloadRequest) (*CoverDownloadRes }, err } - // Generate filename using same format as track filenameFormat := req.FilenameFormat if filenameFormat == "" { - filenameFormat = "title-artist" // default + filenameFormat = "title-artist" } filename := buildCoverFilename(req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, filenameFormat, req.TrackNumber, req.Position, req.DiscNumber) filePath := filepath.Join(outputDir, filename) - // Check if file already exists if fileInfo, err := os.Stat(filePath); err == nil && fileInfo.Size() > 0 { return &CoverDownloadResponse{ Success: true, @@ -211,10 +197,8 @@ func (c *CoverClient) DownloadCover(req CoverDownloadRequest) (*CoverDownloadRes }, nil } - // Try to get max resolution URL, fallback to original downloadURL := c.getMaxResolutionURL(req.CoverURL) - // Download cover image resp, err := c.httpClient.Get(downloadURL) if err != nil { return &CoverDownloadResponse{ @@ -231,7 +215,6 @@ func (c *CoverClient) DownloadCover(req CoverDownloadRequest) (*CoverDownloadRes }, fmt.Errorf("HTTP %d", resp.StatusCode) } - // Create file file, err := os.Create(filePath) if err != nil { return &CoverDownloadResponse{ @@ -241,7 +224,6 @@ func (c *CoverClient) DownloadCover(req CoverDownloadRequest) (*CoverDownloadRes } defer file.Close() - // Write content to file _, err = io.Copy(file, resp.Body) if err != nil { return &CoverDownloadResponse{ @@ -256,3 +238,278 @@ func (c *CoverClient) DownloadCover(req CoverDownloadRequest) (*CoverDownloadRes File: filePath, }, nil } + +func (c *CoverClient) DownloadHeader(req HeaderDownloadRequest) (*HeaderDownloadResponse, error) { + if req.HeaderURL == "" { + return &HeaderDownloadResponse{ + Success: false, + Error: "Header URL is required", + }, fmt.Errorf("header URL is required") + } + + if req.ArtistName == "" { + return &HeaderDownloadResponse{ + Success: false, + Error: "Artist name is required", + }, fmt.Errorf("artist name is required") + } + + outputDir := req.OutputDir + if outputDir == "" { + outputDir = GetDefaultMusicPath() + } else { + outputDir = NormalizePath(outputDir) + } + + artistFolder := filepath.Join(outputDir, sanitizeFilename(req.ArtistName)) + if err := os.MkdirAll(artistFolder, 0755); err != nil { + return &HeaderDownloadResponse{ + Success: false, + Error: fmt.Sprintf("failed to create artist folder: %v", err), + }, err + } + + filename := sanitizeFilename(req.ArtistName) + "_Header.jpg" + filePath := filepath.Join(artistFolder, filename) + + if fileInfo, err := os.Stat(filePath); err == nil && fileInfo.Size() > 0 { + return &HeaderDownloadResponse{ + Success: true, + Message: "Header file already exists", + File: filePath, + AlreadyExists: true, + }, nil + } + + resp, err := c.httpClient.Get(req.HeaderURL) + if err != nil { + return &HeaderDownloadResponse{ + Success: false, + Error: fmt.Sprintf("failed to download header: %v", err), + }, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return &HeaderDownloadResponse{ + Success: false, + Error: fmt.Sprintf("failed to download header: HTTP %d", resp.StatusCode), + }, fmt.Errorf("HTTP %d", resp.StatusCode) + } + + file, err := os.Create(filePath) + if err != nil { + return &HeaderDownloadResponse{ + Success: false, + Error: fmt.Sprintf("failed to create file: %v", err), + }, err + } + defer file.Close() + + _, err = io.Copy(file, resp.Body) + if err != nil { + return &HeaderDownloadResponse{ + Success: false, + Error: fmt.Sprintf("failed to write header file: %v", err), + }, err + } + + return &HeaderDownloadResponse{ + Success: true, + Message: "Header downloaded successfully", + File: filePath, + }, nil +} + +type GalleryImageDownloadRequest struct { + ImageURL string `json:"image_url"` + ArtistName string `json:"artist_name"` + ImageIndex int `json:"image_index"` + OutputDir string `json:"output_dir"` +} + +type GalleryImageDownloadResponse struct { + Success bool `json:"success"` + Message string `json:"message"` + File string `json:"file,omitempty"` + Error string `json:"error,omitempty"` + AlreadyExists bool `json:"already_exists,omitempty"` +} + +func (c *CoverClient) DownloadGalleryImage(req GalleryImageDownloadRequest) (*GalleryImageDownloadResponse, error) { + if req.ImageURL == "" { + return &GalleryImageDownloadResponse{ + Success: false, + Error: "Image URL is required", + }, fmt.Errorf("image URL is required") + } + + if req.ArtistName == "" { + return &GalleryImageDownloadResponse{ + Success: false, + Error: "Artist name is required", + }, fmt.Errorf("artist name is required") + } + + outputDir := req.OutputDir + if outputDir == "" { + outputDir = GetDefaultMusicPath() + } else { + outputDir = NormalizePath(outputDir) + } + + artistFolder := filepath.Join(outputDir, sanitizeFilename(req.ArtistName)) + if err := os.MkdirAll(artistFolder, 0755); err != nil { + return &GalleryImageDownloadResponse{ + Success: false, + Error: fmt.Sprintf("failed to create artist folder: %v", err), + }, err + } + + filename := sanitizeFilename(req.ArtistName) + fmt.Sprintf("_Gallery_%d.jpg", req.ImageIndex+1) + filePath := filepath.Join(artistFolder, filename) + + if fileInfo, err := os.Stat(filePath); err == nil && fileInfo.Size() > 0 { + return &GalleryImageDownloadResponse{ + Success: true, + Message: "Gallery image file already exists", + File: filePath, + AlreadyExists: true, + }, nil + } + + resp, err := c.httpClient.Get(req.ImageURL) + if err != nil { + return &GalleryImageDownloadResponse{ + Success: false, + Error: fmt.Sprintf("failed to download gallery image: %v", err), + }, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return &GalleryImageDownloadResponse{ + Success: false, + Error: fmt.Sprintf("failed to download gallery image: HTTP %d", resp.StatusCode), + }, fmt.Errorf("HTTP %d", resp.StatusCode) + } + + file, err := os.Create(filePath) + if err != nil { + return &GalleryImageDownloadResponse{ + Success: false, + Error: fmt.Sprintf("failed to create file: %v", err), + }, err + } + defer file.Close() + + _, err = io.Copy(file, resp.Body) + if err != nil { + return &GalleryImageDownloadResponse{ + Success: false, + Error: fmt.Sprintf("failed to write gallery image file: %v", err), + }, err + } + + return &GalleryImageDownloadResponse{ + Success: true, + Message: "Gallery image downloaded successfully", + File: filePath, + }, nil +} + +type AvatarDownloadRequest struct { + AvatarURL string `json:"avatar_url"` + ArtistName string `json:"artist_name"` + OutputDir string `json:"output_dir"` +} + +type AvatarDownloadResponse struct { + Success bool `json:"success"` + Message string `json:"message"` + File string `json:"file,omitempty"` + Error string `json:"error,omitempty"` + AlreadyExists bool `json:"already_exists,omitempty"` +} + +func (c *CoverClient) DownloadAvatar(req AvatarDownloadRequest) (*AvatarDownloadResponse, error) { + if req.AvatarURL == "" { + return &AvatarDownloadResponse{ + Success: false, + Error: "Avatar URL is required", + }, fmt.Errorf("avatar URL is required") + } + + if req.ArtistName == "" { + return &AvatarDownloadResponse{ + Success: false, + Error: "Artist name is required", + }, fmt.Errorf("artist name is required") + } + + outputDir := req.OutputDir + if outputDir == "" { + outputDir = GetDefaultMusicPath() + } else { + outputDir = NormalizePath(outputDir) + } + + artistFolder := filepath.Join(outputDir, sanitizeFilename(req.ArtistName)) + if err := os.MkdirAll(artistFolder, 0755); err != nil { + return &AvatarDownloadResponse{ + Success: false, + Error: fmt.Sprintf("failed to create artist folder: %v", err), + }, err + } + + filename := sanitizeFilename(req.ArtistName) + "_Avatar.jpg" + filePath := filepath.Join(artistFolder, filename) + + if fileInfo, err := os.Stat(filePath); err == nil && fileInfo.Size() > 0 { + return &AvatarDownloadResponse{ + Success: true, + Message: "Avatar file already exists", + File: filePath, + AlreadyExists: true, + }, nil + } + + resp, err := c.httpClient.Get(req.AvatarURL) + if err != nil { + return &AvatarDownloadResponse{ + Success: false, + Error: fmt.Sprintf("failed to download avatar: %v", err), + }, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return &AvatarDownloadResponse{ + Success: false, + Error: fmt.Sprintf("failed to download avatar: HTTP %d", resp.StatusCode), + }, fmt.Errorf("HTTP %d", resp.StatusCode) + } + + file, err := os.Create(filePath) + if err != nil { + return &AvatarDownloadResponse{ + Success: false, + Error: fmt.Sprintf("failed to create file: %v", err), + }, err + } + defer file.Close() + + _, err = io.Copy(file, resp.Body) + if err != nil { + return &AvatarDownloadResponse{ + Success: false, + Error: fmt.Sprintf("failed to write avatar file: %v", err), + }, err + } + + return &AvatarDownloadResponse{ + Success: true, + Message: "Avatar downloaded successfully", + File: filePath, + }, nil +} diff --git a/backend/ffmpeg.go b/backend/ffmpeg.go index dc2d7ae..034c434 100644 --- a/backend/ffmpeg.go +++ b/backend/ffmpeg.go @@ -13,11 +13,11 @@ import ( "runtime" "strings" "sync" + "time" "github.com/ulikunitz/xz" ) -// decodeBase64 decodes a base64 encoded string func decodeBase64(encoded string) (string, error) { decoded, err := base64.StdEncoding.DecodeString(encoded) if err != nil { @@ -26,14 +26,12 @@ func decodeBase64(encoded string) (string, error) { return string(decoded), nil } -// ValidateExecutable checks if the path points to a valid, safe executable func ValidateExecutable(path string) error { cleanedPath := filepath.Clean(path) if cleanedPath == "" { return fmt.Errorf("empty path") } - // Ensure path is absolute if !filepath.IsAbs(cleanedPath) { return fmt.Errorf("path must be absolute: %s", path) } @@ -47,14 +45,12 @@ func ValidateExecutable(path string) error { return fmt.Errorf("path is a directory: %s", path) } - // Check executable permission on Unix if runtime.GOOS != "windows" { if info.Mode()&0111 == 0 { return fmt.Errorf("file is not executable: %s", path) } } - // Validate filename allowlist base := filepath.Base(cleanedPath) validNames := map[string]bool{ "ffmpeg": true, @@ -76,7 +72,6 @@ const ( ffprobeMacOSURL = "aHR0cHM6Ly9ldmVybWVldC5jeC9mZm1wZWcvZ2V0cmVsZWFzZS9mZnByb2JlL3ppcA==" ) -// GetFFmpegDir returns the directory where ffmpeg should be stored func GetFFmpegDir() (string, error) { homeDir, err := os.UserHomeDir() if err != nil { @@ -85,7 +80,6 @@ func GetFFmpegDir() (string, error) { 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 { @@ -100,7 +94,6 @@ func GetFFmpegPath() (string, error) { 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 { @@ -120,7 +113,6 @@ func GetFFprobePath() (string, error) { 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 { @@ -131,14 +123,12 @@ func IsFFprobeInstalled() (bool, error) { 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 { @@ -149,33 +139,35 @@ func IsFFmpegInstalled() (bool, error) { return false, nil } - // 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 { + + SetDownloadProgress(0) + SetDownloadSpeed(0) + SetDownloading(true) + defer SetDownloading(false) + 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 { @@ -188,14 +180,14 @@ func DownloadFFmpeg(progressCallback func(int)) error { 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 { @@ -205,7 +197,6 @@ func DownloadFFmpeg(progressCallback func(int)) error { return nil } - // For Windows/Linux: single archive contains both ffmpeg and ffprobe var encodedURL string switch runtime.GOOS { case "windows": @@ -216,7 +207,6 @@ func DownloadFFmpeg(progressCallback func(int)) error { 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) @@ -231,9 +221,8 @@ func DownloadFFmpeg(progressCallback func(int)) error { 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) @@ -241,7 +230,6 @@ func downloadAndExtract(url, destDir string, progressCallback func(int), progres 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) @@ -254,8 +242,16 @@ func downloadAndExtract(url, destDir string, progressCallback func(int), progres totalSize := resp.ContentLength var downloaded int64 + lastTime := time.Now() + var lastBytes int64 + + if totalSize > 0 { + totalSizeMB := float64(totalSize) / (1024 * 1024) + fmt.Printf("[FFmpeg] Total size: %.2f MB\n", totalSizeMB) + } else { + fmt.Printf("[FFmpeg] Downloading... (size unknown)\n") + } - // Create a progress reader buf := make([]byte, 32*1024) for { n, err := resp.Body.Read(buf) @@ -265,12 +261,46 @@ func downloadAndExtract(url, destDir string, progressCallback func(int), progres return fmt.Errorf("failed to write to temp file: %w", writeErr) } downloaded += int64(n) + + mbDownloaded := float64(downloaded) / (1024 * 1024) + now := time.Now() + timeDiff := now.Sub(lastTime).Seconds() + var speedMBps float64 + + if timeDiff > 0.1 { + bytesDiff := float64(downloaded - lastBytes) + speedMBps = (bytesDiff / (1024 * 1024)) / timeDiff + lastTime = now + lastBytes = downloaded + } + + SetDownloadProgress(mbDownloaded) + if speedMBps > 0 { + SetDownloadSpeed(speedMBps) + } + 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 totalSize > 0 { + percent := float64(downloaded) * 100 / float64(totalSize) + if speedMBps > 0 { + fmt.Printf("\r[FFmpeg] Downloading: %.2f MB / %.2f MB (%.1f%%) - %.2f MB/s", + mbDownloaded, float64(totalSize)/(1024*1024), percent, speedMBps) + } else { + fmt.Printf("\r[FFmpeg] Downloading: %.2f MB / %.2f MB (%.1f%%)", + mbDownloaded, float64(totalSize)/(1024*1024), percent) + } + } else { + if speedMBps > 0 { + fmt.Printf("\r[FFmpeg] Downloading: %.2f MB - %.2f MB/s", mbDownloaded, speedMBps) + } else { + fmt.Printf("\r[FFmpeg] Downloading: %.2f MB", mbDownloaded) + } + } } if err == io.EOF { break @@ -282,16 +312,20 @@ func downloadAndExtract(url, destDir string, progressCallback func(int), progres tmpFile.Close() - fmt.Printf("[FFmpeg] Download complete, extracting...\n") + if totalSize > 0 { + fmt.Printf("\r[FFmpeg] Download complete: %.2f MB / %.2f MB (100%%) \n", + float64(downloaded)/(1024*1024), float64(totalSize)/(1024*1024)) + } else { + fmt.Printf("\r[FFmpeg] Download complete: %.2f MB \n", float64(downloaded)/(1024*1024)) + } + fmt.Printf("[FFmpeg] 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 { @@ -323,7 +357,7 @@ func extractZip(zipPath, destDir string) error { destPath = filepath.Join(destDir, ffprobeName) foundFFprobe = true } else { - // Skip ffplay and other files + continue } @@ -351,7 +385,6 @@ func extractZip(zipPath, destDir string) error { 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") } @@ -366,7 +399,6 @@ func extractZip(zipPath, destDir string) error { 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 { @@ -409,7 +441,7 @@ func extractTarXz(tarXzPath, destDir string) error { destPath = filepath.Join(destDir, ffprobeName) foundFFprobe = true } else { - // Skip ffplay and other files + continue } @@ -430,7 +462,6 @@ func extractTarXz(tarXzPath, destDir string) error { 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") } @@ -445,15 +476,13 @@ func extractTarXz(tarXzPath, destDir string) error { 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" + OutputFormat string `json:"output_format"` + Bitrate string `json:"bitrate"` + Codec string `json:"codec"` } -// ConvertAudioResult represents the result of a single file conversion type ConvertAudioResult struct { InputFile string `json:"input_file"` OutputFile string `json:"output_file"` @@ -461,7 +490,6 @@ type ConvertAudioResult struct { 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 { @@ -481,7 +509,6 @@ func ConvertAudio(req ConvertAudioRequest) ([]ConvertAudioResult, error) { 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) { @@ -491,16 +518,13 @@ func ConvertAudio(req ConvertAudioRequest) ([]ConvertAudioResult, error) { 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 @@ -510,11 +534,9 @@ func ConvertAudio(req ConvertAudioRequest) ([]ConvertAudioResult, error) { 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 @@ -526,9 +548,14 @@ func ConvertAudio(req ConvertAudioRequest) ([]ConvertAudioResult, error) { result.OutputFile = outputFile - // Extract cover art and lyrics from input file before conversion var coverArtPath string var lyrics string + var inputMetadata Metadata + + inputMetadata, err = ExtractFullMetadataFromFile(inputFile) + if err != nil { + fmt.Printf("[FFmpeg] Warning: Failed to extract metadata from %s: %v\n", inputFile, err) + } coverArtPath, _ = ExtractCoverArt(inputFile) lyrics, err = ExtractLyrics(inputFile) @@ -540,49 +567,42 @@ func ConvertAudio(req ConvertAudioRequest) ([]ConvertAudioResult, error) { fmt.Printf("[FFmpeg] No lyrics found in %s\n", inputFile) } - // Build ffmpeg command + inputMetadata.Lyrics = lyrics + args := []string{ "-i", inputFile, - "-y", // Overwrite output + "-y", } - // 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", "0:a", + "-id3v2_version", "3", ) - // 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 + codec = "aac" } 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 + "-map", "0:a", ) } 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", "0:a", ) } - // 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) @@ -590,7 +610,7 @@ func ConvertAudio(req ConvertAudioRequest) ([]ConvertAudioResult, error) { 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 { @@ -599,21 +619,17 @@ func ConvertAudio(req ConvertAudioRequest) ([]ConvertAudioResult, error) { 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 err := EmbedMetadataToConvertedFile(outputFile, inputMetadata, coverArtPath); err != nil { + fmt.Printf("[FFmpeg] Warning: Failed to embed metadata: %v\n", err) + } else { + fmt.Printf("[FFmpeg] Metadata embedded successfully\n") } if lyrics != "" { @@ -624,6 +640,10 @@ func ConvertAudio(req ConvertAudioRequest) ([]ConvertAudioResult, error) { } } + if coverArtPath != "" { + os.Remove(coverArtPath) + } + result.Success = true fmt.Printf("[FFmpeg] Successfully converted: %s\n", outputFile) @@ -637,7 +657,6 @@ func ConvertAudio(req ConvertAudioRequest) ([]ConvertAudioResult, error) { return results, nil } -// GetAudioInfo returns information about an audio file type AudioFileInfo struct { Path string `json:"path"` Filename string `json:"filename"` @@ -645,7 +664,6 @@ type AudioFileInfo struct { 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 { diff --git a/backend/ffmpeg_unix.go b/backend/ffmpeg_unix.go index 20cdff5..73b262f 100644 --- a/backend/ffmpeg_unix.go +++ b/backend/ffmpeg_unix.go @@ -7,8 +7,6 @@ import ( "os/exec" ) -// setHideWindow is a no-op on non-Windows platforms func setHideWindow(cmd *exec.Cmd) { - // No-op on Unix-like systems -} +} diff --git a/backend/ffmpeg_windows.go b/backend/ffmpeg_windows.go index 12d4c66..c08640e 100644 --- a/backend/ffmpeg_windows.go +++ b/backend/ffmpeg_windows.go @@ -8,10 +8,8 @@ import ( "syscall" ) -// setHideWindow sets HideWindow attribute for Windows processes func setHideWindow(cmd *exec.Cmd) { cmd.SysProcAttr = &syscall.SysProcAttr{ HideWindow: true, } } - diff --git a/backend/file_dialog.go b/backend/file_dialog.go index c315bdb..b47e1de 100644 --- a/backend/file_dialog.go +++ b/backend/file_dialog.go @@ -6,7 +6,6 @@ import ( "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", @@ -39,7 +38,6 @@ func SelectMultipleFiles(ctx context.Context) ([]string, error) { 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", @@ -49,4 +47,3 @@ func SelectOutputDirectory(ctx context.Context) (string, error) { } return dir, nil } - diff --git a/backend/filemanager.go b/backend/filemanager.go index 7756473..f656554 100644 --- a/backend/filemanager.go +++ b/backend/filemanager.go @@ -14,7 +14,6 @@ import ( "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"` @@ -23,7 +22,6 @@ type FileInfo struct { Children []FileInfo `json:"children,omitempty"` } -// AudioMetadata represents metadata read from an audio file type AudioMetadata struct { Title string `json:"title"` Artist string `json:"artist"` @@ -34,7 +32,6 @@ type AudioMetadata struct { 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"` @@ -44,7 +41,6 @@ type RenamePreview struct { 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"` @@ -52,7 +48,6 @@ type RenameResult struct { 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 { @@ -73,7 +68,6 @@ func ListDirectory(dirPath string) ([]FileInfo, error) { Size: info.Size(), } - // If it's a directory, recursively list its contents if entry.IsDir() { children, err := ListDirectory(fileInfo.Path) if err == nil { @@ -87,13 +81,12 @@ func ListDirectory(dirPath string) ([]FileInfo, error) { 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 + return nil } if info.IsDir() { @@ -120,7 +113,6 @@ func ListAudioFiles(dirPath string) ([]FileInfo, error) { 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") @@ -140,7 +132,6 @@ func ReadAudioMetadata(filePath string) (*AudioMetadata, error) { } } -// readFlacMetadata reads metadata from a FLAC file func readFlacMetadata(filePath string) (*AudioMetadata, error) { f, err := flac.ParseFile(filePath) if err != nil { @@ -192,7 +183,6 @@ func readFlacMetadata(filePath string) (*AudioMetadata, error) { 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 { @@ -207,14 +197,12 @@ func readMp3Metadata(filePath string) (*AudioMetadata, error) { 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] @@ -224,7 +212,6 @@ func readMp3Metadata(filePath string) (*AudioMetadata, error) { } } - // 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] @@ -237,7 +224,6 @@ func readMp3Metadata(filePath string) (*AudioMetadata, error) { return metadata, nil } -// readMetadataWithFFprobe reads metadata from any audio file using ffprobe func readMetadataWithFFprobe(filePath string) (*AudioMetadata, error) { ffprobePath, err := GetFFprobePath() if err != nil { @@ -248,7 +234,6 @@ func readMetadataWithFFprobe(filePath string) (*AudioMetadata, error) { return nil, fmt.Errorf("invalid ffprobe executable: %w", err) } - // Use ffprobe to get metadata in JSON format (both format and stream tags) cmd := exec.Command(ffprobePath, "-v", "quiet", "-print_format", "json", @@ -257,7 +242,6 @@ func readMetadataWithFFprobe(filePath string) (*AudioMetadata, error) { filePath, ) - // Hide console window on Windows setHideWindow(cmd) output, err := cmd.Output() @@ -265,7 +249,6 @@ func readMetadataWithFFprobe(filePath string) (*AudioMetadata, error) { return nil, err } - // Parse JSON output var result struct { Format struct { Tags map[string]string `json:"tags"` @@ -281,22 +264,18 @@ func readMetadataWithFFprobe(filePath string) (*AudioMetadata, error) { 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": @@ -308,7 +287,7 @@ func readMetadataWithFFprobe(filePath string) (*AudioMetadata, error) { 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 @@ -328,7 +307,6 @@ func readMetadataWithFFprobe(filePath string) (*AudioMetadata, error) { 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 { @@ -337,7 +315,6 @@ func readM4aMetadata(filePath string) (*AudioMetadata, error) { 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 "" @@ -345,38 +322,32 @@ func GenerateFilename(metadata *AudioMetadata, format string, ext string) string result := format - // Extract year (first 4 characters only) year := metadata.Year if len(year) >= 4 { year = year[:4] } - // 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(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 == "" { @@ -386,9 +357,8 @@ func GenerateFilename(metadata *AudioMetadata, format string, ext string) string 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 { @@ -397,7 +367,6 @@ func sanitizeFilenameForRename(name string) string { return strings.TrimSpace(result) } -// PreviewRename generates a preview of rename operations func PreviewRename(files []string, format string) []RenamePreview { var previews []RenamePreview @@ -434,7 +403,6 @@ func PreviewRename(files []string, format string) []RenamePreview { 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 { @@ -446,7 +414,6 @@ func GetFileSizes(files []string) map[string]int64 { return result } -// RenameFiles renames files based on their metadata func RenameFiles(files []string, format string) []RenameResult { var results []RenameResult @@ -476,7 +443,6 @@ func RenameFiles(files []string, format string) []RenameResult { 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" @@ -486,7 +452,6 @@ func RenameFiles(files []string, format string) []RenameResult { } } - // Rename the file if err := os.Rename(filePath, newPath); err != nil { result.Error = err.Error() result.Success = false diff --git a/backend/filename.go b/backend/filename.go index 19de449..c9c6cb1 100644 --- a/backend/filename.go +++ b/backend/filename.go @@ -9,15 +9,13 @@ import ( "unicode/utf8" ) -// BuildExpectedFilename builds the expected filename based on track metadata and settings func BuildExpectedFilename(trackName, artistName, albumName, albumArtist, releaseDate, filenameFormat string, includeTrackNumber bool, position, discNumber int, useAlbumTrackNumber bool) string { - // Sanitize track name and artist name + safeTitle := sanitizeFilename(trackName) safeArtist := sanitizeFilename(artistName) safeAlbum := sanitizeFilename(albumName) safeAlbumArtist := sanitizeFilename(albumArtist) - // Extract year from release date (format: YYYY-MM-DD or YYYY) year := "" if len(releaseDate) >= 4 { year = releaseDate[:4] @@ -25,7 +23,6 @@ func BuildExpectedFilename(trackName, artistName, albumName, albumArtist, releas var filename string - // Check if format is a template (contains {}) if strings.Contains(filenameFormat, "{") { filename = filenameFormat filename = strings.ReplaceAll(filename, "{title}", safeTitle) @@ -34,34 +31,31 @@ func BuildExpectedFilename(trackName, artistName, albumName, albumArtist, releas filename = strings.ReplaceAll(filename, "{album_artist}", safeAlbumArtist) filename = strings.ReplaceAll(filename, "{year}", year) - // Handle disc number if discNumber > 0 { filename = strings.ReplaceAll(filename, "{disc}", fmt.Sprintf("%d", discNumber)) } else { filename = strings.ReplaceAll(filename, "{disc}", "") } - // Handle track number - if position is 0, remove {track} and surrounding separators if position > 0 { filename = strings.ReplaceAll(filename, "{track}", fmt.Sprintf("%02d", position)) } else { - // Remove {track} with common separators like ". " or " - " or ". " + 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 filenameFormat { case "artist-title": filename = fmt.Sprintf("%s - %s", safeArtist, safeTitle) case "title": filename = safeTitle - default: // "title-artist" + default: filename = fmt.Sprintf("%s - %s", safeTitle, safeArtist) } - // Add track number prefix if enabled (legacy behavior) if includeTrackNumber && position > 0 { filename = fmt.Sprintf("%02d. %s", position, filename) } @@ -70,109 +64,81 @@ func BuildExpectedFilename(trackName, artistName, albumName, albumArtist, releas return filename + ".flac" } -// sanitizeFilename removes invalid characters from filename func sanitizeFilename(name string) string { - // 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)) - // Detect separator sep := string(filepath.Separator) - // Split path into components parts := strings.Split(normalizedPath, sep) sanitizedParts := make([]string, 0, len(parts)) for i, part := range parts { - // Keep drive letter intact on Windows (e.g., "C:") + if i == 0 && len(part) == 2 && part[1] == ':' { sanitizedParts = append(sanitizedParts, part) 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 != "" { sanitizedParts = append(sanitizedParts, sanitized) @@ -182,8 +148,7 @@ func SanitizeFolderPath(folderPath string) string { return strings.Join(sanitizedParts, sep) } -// sanitizeFolderName removes invalid characters from a single folder name func sanitizeFolderName(name string) string { - // Use the same sanitization as filename + return sanitizeFilename(name) } diff --git a/backend/folder.go b/backend/folder.go index 0527f76..77f1ac0 100644 --- a/backend/folder.go +++ b/backend/folder.go @@ -14,7 +14,7 @@ func OpenFolderInExplorer(path string) error { switch runtime.GOOS { case "windows": cmd = exec.Command("explorer", path) - case "darwin": // macOS + case "darwin": cmd = exec.Command("open", path) case "linux": cmd = exec.Command("xdg-open", path) @@ -26,7 +26,7 @@ func OpenFolderInExplorer(path string) error { } func SelectFolderDialog(ctx context.Context, defaultPath string) (string, error) { - // If defaultPath is empty, use default music path + if defaultPath == "" { defaultPath = GetDefaultMusicPath() } @@ -41,7 +41,6 @@ func SelectFolderDialog(ctx context.Context, defaultPath string) (string, error) return "", err } - // If user cancelled, selectedPath will be empty if selectedPath == "" { return "", nil } @@ -69,7 +68,6 @@ func SelectFileDialog(ctx context.Context) (string, error) { return "", err } - // If user cancelled, selectedFile will be empty if selectedFile == "" { return "", nil } diff --git a/backend/lyrics.go b/backend/lyrics.go index 5a7e95a..db44f1f 100644 --- a/backend/lyrics.go +++ b/backend/lyrics.go @@ -14,7 +14,6 @@ import ( "time" ) -// LRCLibResponse represents the LRCLIB API response type LRCLibResponse struct { ID int `json:"id"` Name string `json:"name"` @@ -27,21 +26,18 @@ type LRCLibResponse struct { SyncedLyrics string `json:"syncedLyrics"` } -// LyricsLine represents a single line of lyrics type LyricsLine struct { StartTimeMs string `json:"startTimeMs"` Words string `json:"words"` EndTimeMs string `json:"endTimeMs"` } -// LyricsResponse represents the API response type LyricsResponse struct { Error bool `json:"error"` SyncType string `json:"syncType"` Lines []LyricsLine `json:"lines"` } -// LyricsDownloadRequest represents a request to download lyrics type LyricsDownloadRequest struct { SpotifyID string `json:"spotify_id"` TrackName string `json:"track_name"` @@ -57,7 +53,6 @@ type LyricsDownloadRequest struct { DiscNumber int `json:"disc_number"` } -// LyricsDownloadResponse represents the response from lyrics download type LyricsDownloadResponse struct { Success bool `json:"success"` Message string `json:"message"` @@ -66,27 +61,28 @@ type LyricsDownloadResponse struct { AlreadyExists bool `json:"already_exists,omitempty"` } -// LyricsClient handles lyrics fetching type LyricsClient struct { httpClient *http.Client } -// NewLyricsClient creates a new lyrics client func NewLyricsClient() *LyricsClient { return &LyricsClient{ httpClient: &http.Client{Timeout: 15 * time.Second}, } } -// FetchLyricsWithMetadata fetches lyrics using track name and artist from LRCLIB -func (c *LyricsClient) FetchLyricsWithMetadata(trackName, artistName string) (*LyricsResponse, error) { - // Try LRCLIB API +func (c *LyricsClient) FetchLyricsWithMetadata(trackName, artistName string, duration int) (*LyricsResponse, error) { + apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9scmNsaWIubmV0L2FwaS9nZXQ/YXJ0aXN0X25hbWU9") apiURL := fmt.Sprintf("%s%s&track_name=%s", string(apiBase), url.QueryEscape(artistName), url.QueryEscape(trackName)) + if duration > 0 { + apiURL = fmt.Sprintf("%s&duration=%d", apiURL, duration) + } + resp, err := c.httpClient.Get(apiURL) if err != nil { return nil, fmt.Errorf("failed to fetch from LRCLIB: %v", err) @@ -107,11 +103,9 @@ func (c *LyricsClient) FetchLyricsWithMetadata(trackName, artistName string) (*L return nil, fmt.Errorf("failed to parse LRCLIB response: %v", err) } - // 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, @@ -119,7 +113,6 @@ func (c *LyricsClient) convertLRCLibToLyricsResponse(lrcLib *LRCLibResponse) *Ly Lines: []LyricsLine{}, } - // Prefer synced lyrics, fall back to plain lyricsText := lrcLib.SyncedLyrics if lyricsText == "" { lyricsText = lrcLib.PlainLyrics @@ -131,7 +124,6 @@ func (c *LyricsClient) convertLRCLibToLyricsResponse(lrcLib *LRCLibResponse) *Ly return resp } - // Parse synced lyrics format [mm:ss.xx] text lines := strings.Split(lyricsText, "\n") for _, line := range lines { line = strings.TrimSpace(line) @@ -139,14 +131,12 @@ func (c *LyricsClient) convertLRCLibToLyricsResponse(lrcLib *LRCLibResponse) *Ly 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), @@ -156,9 +146,8 @@ func (c *LyricsClient) convertLRCLibToLyricsResponse(lrcLib *LRCLibResponse) *Ly } } - // Plain lyrics line (no timestamp) resp.Lines = append(resp.Lines, LyricsLine{ - StartTimeMs: "0", + StartTimeMs: "", Words: line, }) } @@ -166,10 +155,9 @@ func (c *LyricsClient) convertLRCLibToLyricsResponse(lrcLib *LRCLibResponse) *Ly return resp } -// lrcTimestampToMs converts LRC timestamp [mm:ss.xx] to milliseconds func lrcTimestampToMs(timestamp string) int64 { var minutes, seconds, centiseconds int64 - // Try parsing mm:ss.xx format + n, _ := fmt.Sscanf(timestamp, "%d:%d.%d", &minutes, &seconds, ¢iseconds) if n >= 2 { return minutes*60*1000 + seconds*1000 + centiseconds*10 @@ -177,7 +165,6 @@ func lrcTimestampToMs(timestamp string) int64 { 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=") @@ -207,7 +194,6 @@ func (c *LyricsClient) FetchLyricsFromLRCLibSearch(trackName, artistName string) 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 != "" { @@ -226,41 +212,37 @@ func (c *LyricsClient) FetchLyricsFromLRCLibSearch(trackName, artistName string) 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) +func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName string, duration int) (*LyricsResponse, string, error) { + + resp, err := c.FetchLyricsWithMetadata(trackName, artistName, duration) 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) + resp, err = c.FetchLyricsWithMetadata(simplifiedTrack, artistName, duration) if err == nil && resp != nil && !resp.Error && len(resp.Lines) > 0 { return resp, "LRCLIB (simplified)", nil } @@ -274,31 +256,31 @@ func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName st return nil, "", fmt.Errorf("lyrics not found in any source") } -// ConvertToLRC converts lyrics response to LRC format func (c *LyricsClient) ConvertToLRC(lyrics *LyricsResponse, trackName, artistName string) string { var sb strings.Builder - // Add metadata sb.WriteString(fmt.Sprintf("[ti:%s]\n", trackName)) sb.WriteString(fmt.Sprintf("[ar:%s]\n", artistName)) sb.WriteString("[by:SpotiFlac]\n") sb.WriteString("\n") - // Add lyrics lines for _, line := range lyrics.Lines { if line.Words == "" { continue } - // Convert milliseconds to LRC timestamp format [mm:ss.xx] - timestamp := msToLRCTimestamp(line.StartTimeMs) - sb.WriteString(fmt.Sprintf("%s%s\n", timestamp, line.Words)) + if line.StartTimeMs == "" { + sb.WriteString(fmt.Sprintf("%s\n", line.Words)) + } else { + + timestamp := msToLRCTimestamp(line.StartTimeMs) + sb.WriteString(fmt.Sprintf("%s%s\n", timestamp, line.Words)) + } } return sb.String() } -// msToLRCTimestamp converts milliseconds string to LRC timestamp format [mm:ss.xx] func msToLRCTimestamp(msStr string) string { var ms int64 fmt.Sscanf(msStr, "%d", &ms) @@ -311,14 +293,12 @@ func msToLRCTimestamp(msStr string) string { return fmt.Sprintf("[%02d:%02d.%02d]", minutes, seconds, centiseconds) } -// buildLyricsFilename builds the lyrics filename based on settings (same as track filename) func buildLyricsFilename(trackName, artistName, albumName, albumArtist, releaseDate, filenameFormat string, includeTrackNumber bool, position, discNumber int) string { safeTitle := sanitizeFilename(trackName) safeArtist := sanitizeFilename(artistName) safeAlbum := sanitizeFilename(albumName) safeAlbumArtist := sanitizeFilename(albumArtist) - // Extract year from release date (format: YYYY-MM-DD or YYYY) year := "" if len(releaseDate) >= 4 { year = releaseDate[:4] @@ -326,7 +306,6 @@ func buildLyricsFilename(trackName, artistName, albumName, albumArtist, releaseD var filename string - // Check if format is a template (contains {}) if strings.Contains(filenameFormat, "{") { filename = filenameFormat filename = strings.ReplaceAll(filename, "{title}", safeTitle) @@ -335,34 +314,31 @@ func buildLyricsFilename(trackName, artistName, albumName, albumArtist, releaseD filename = strings.ReplaceAll(filename, "{album_artist}", safeAlbumArtist) filename = strings.ReplaceAll(filename, "{year}", year) - // Handle disc number if discNumber > 0 { filename = strings.ReplaceAll(filename, "{disc}", fmt.Sprintf("%d", discNumber)) } else { filename = strings.ReplaceAll(filename, "{disc}", "") } - // Handle track number - if position is 0, remove {track} and surrounding separators if position > 0 { filename = strings.ReplaceAll(filename, "{track}", fmt.Sprintf("%02d", position)) } 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 filenameFormat { case "artist-title": filename = fmt.Sprintf("%s - %s", safeArtist, safeTitle) case "title": filename = safeTitle - default: // "title-artist" + default: filename = fmt.Sprintf("%s - %s", safeTitle, safeArtist) } - // Add track number prefix if enabled (legacy behavior) if includeTrackNumber && position > 0 { filename = fmt.Sprintf("%02d. %s", position, filename) } @@ -371,7 +347,47 @@ func buildLyricsFilename(trackName, artistName, albumName, albumArtist, releaseD return filename + ".lrc" } -// DownloadLyrics downloads lyrics for a single track +func findAudioFileForLyrics(dir, trackName, artistName string) string { + + safeTitle := sanitizeFilename(trackName) + safeArtist := sanitizeFilename(artistName) + + audioExts := []string{".flac", ".mp3", ".m4a", ".FLAC", ".MP3", ".M4A"} + + patterns := []string{ + fmt.Sprintf("%s - %s", safeTitle, safeArtist), + fmt.Sprintf("%s - %s", safeArtist, safeTitle), + safeTitle, + } + + entries, err := os.ReadDir(dir) + if err != nil { + return "" + } + + for _, entry := range entries { + if entry.IsDir() { + continue + } + + filename := entry.Name() + baseName := strings.TrimSuffix(filename, filepath.Ext(filename)) + + for _, pattern := range patterns { + if strings.HasPrefix(baseName, pattern) || strings.Contains(baseName, pattern) { + ext := strings.ToLower(filepath.Ext(filename)) + for _, audioExt := range audioExts { + if ext == strings.ToLower(audioExt) { + return filepath.Join(dir, filename) + } + } + } + } + } + + return "" +} + func (c *LyricsClient) DownloadLyrics(req LyricsDownloadRequest) (*LyricsDownloadResponse, error) { if req.SpotifyID == "" { return &LyricsDownloadResponse{ @@ -380,7 +396,6 @@ func (c *LyricsClient) DownloadLyrics(req LyricsDownloadRequest) (*LyricsDownloa }, fmt.Errorf("spotify ID is required") } - // Create output directory if it doesn't exist outputDir := req.OutputDir if outputDir == "" { outputDir = GetDefaultMusicPath() @@ -395,15 +410,13 @@ func (c *LyricsClient) DownloadLyrics(req LyricsDownloadRequest) (*LyricsDownloa }, err } - // Generate filename using same format as track filenameFormat := req.FilenameFormat if filenameFormat == "" { - filenameFormat = "title-artist" // default + filenameFormat = "title-artist" } filename := buildLyricsFilename(req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, filenameFormat, req.TrackNumber, req.Position, req.DiscNumber) filePath := filepath.Join(outputDir, filename) - // Check if file already exists if fileInfo, err := os.Stat(filePath); err == nil && fileInfo.Size() > 0 { return &LyricsDownloadResponse{ Success: true, @@ -413,8 +426,17 @@ func (c *LyricsClient) DownloadLyrics(req LyricsDownloadRequest) (*LyricsDownloa }, nil } - // Fetch lyrics from LRCLIB - lyrics, _, err := c.FetchLyricsAllSources(req.SpotifyID, req.TrackName, req.ArtistName) + audioDuration := 0 + audioFile := findAudioFileForLyrics(outputDir, req.TrackName, req.ArtistName) + if audioFile != "" { + duration, err := GetAudioDuration(audioFile) + if err == nil && duration > 0 { + audioDuration = int(duration) + fmt.Printf("[DownloadLyrics] Found audio file, duration: %d seconds\n", audioDuration) + } + } + + lyrics, _, err := c.FetchLyricsAllSources(req.SpotifyID, req.TrackName, req.ArtistName, audioDuration) if err != nil { return &LyricsDownloadResponse{ Success: false, @@ -422,10 +444,8 @@ func (c *LyricsClient) DownloadLyrics(req LyricsDownloadRequest) (*LyricsDownloa }, err } - // Convert to LRC format lrcContent := c.ConvertToLRC(lyrics, req.TrackName, req.ArtistName) - // Write LRC file if err := os.WriteFile(filePath, []byte(lrcContent), 0644); err != nil { return &LyricsDownloadResponse{ Success: false, diff --git a/backend/metadata.go b/backend/metadata.go index d057f48..a5cb8e1 100644 --- a/backend/metadata.go +++ b/backend/metadata.go @@ -1,13 +1,13 @@ package backend import ( + "encoding/json" "fmt" "os" "os/exec" pathfilepath "path/filepath" "strconv" "strings" - "sync" id3v2 "github.com/bogem/id3v2/v2" "github.com/go-flac/flacpicture" @@ -20,12 +20,15 @@ type Metadata struct { Artist string Album string AlbumArtist string - Date string // Recorded date (full date YYYY-MM-DD) - ReleaseDate string // Release date (full date) - kept for compatibility + Date string + ReleaseDate string TrackNumber int - TotalTracks int // Total tracks in album + TotalTracks int DiscNumber int - ISRC string + TotalDiscs int + URL string + Copyright string + Publisher string Lyrics string Description string } @@ -70,15 +73,21 @@ func EmbedMetadata(filepath string, metadata Metadata, coverPath string) error { if metadata.DiscNumber > 0 { _ = cmt.Add("DISCNUMBER", strconv.Itoa(metadata.DiscNumber)) } - if metadata.ISRC != "" { - _ = cmt.Add(flacvorbis.FIELD_ISRC, metadata.ISRC) + if metadata.TotalDiscs > 0 { + _ = cmt.Add("TOTALDISCS", strconv.Itoa(metadata.TotalDiscs)) + } + if metadata.Copyright != "" { + _ = cmt.Add("COPYRIGHT", metadata.Copyright) + } + if metadata.Publisher != "" { + _ = cmt.Add("PUBLISHER", metadata.Publisher) } 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 + _ = cmt.Add("LYRICS", metadata.Lyrics) } cmtBlock := cmt.Marshal() @@ -135,20 +144,17 @@ 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 @@ -171,10 +177,8 @@ func EmbedLyricsOnly(filepath string, lyrics string) error { } } - // 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) @@ -187,7 +191,6 @@ func EmbedLyricsOnly(filepath string, lyrics string) error { } } - // Add lyrics _ = cmt.Add("LYRICS", lyrics) cmtBlock := cmt.Marshal() @@ -204,82 +207,6 @@ func EmbedLyricsOnly(filepath string, lyrics string) error { return nil } -// ReadISRCFromFile reads ISRC metadata from a FLAC file -func ReadISRCFromFile(filepath string) (string, error) { - if !fileExists(filepath) { - return "", fmt.Errorf("file does not exist") - } - - f, err := flac.ParseFile(filepath) - if err != nil { - return "", fmt.Errorf("failed to parse FLAC file: %w", err) - } - - // Find VorbisComment block - for _, block := range f.Meta { - if block.Type == flac.VorbisComment { - cmt, err := flacvorbis.ParseFromMetaDataBlock(*block) - if err != nil { - continue - } - - // Get ISRC field - isrcValues, err := cmt.Get(flacvorbis.FIELD_ISRC) - if err == nil && len(isrcValues) > 0 { - return isrcValues[0], nil - } - } - } - - return "", nil // No ISRC found -} - -// CheckISRCExists checks if a file with the given ISRC already exists in the directory -func CheckISRCExists(outputDir string, targetISRC string) (string, bool) { - if targetISRC == "" { - return "", false - } - - // Read all .flac files in directory - entries, err := os.ReadDir(outputDir) - if err != nil { - return "", false - } - - for _, entry := range entries { - if entry.IsDir() { - continue - } - - // Check only .flac files - filename := entry.Name() - if len(filename) < 5 || filename[len(filename)-5:] != ".flac" { - continue - } - - filepath := fmt.Sprintf("%s/%s", outputDir, filename) - - // Read ISRC from file (this will fail for corrupted files) - isrc, err := ReadISRCFromFile(filepath) - if err != nil { - // File is corrupted or unreadable, delete it - fmt.Printf("Removing corrupted/unreadable file: %s (error: %v)\n", filepath, err) - if removeErr := os.Remove(filepath); removeErr != nil { - fmt.Printf("Warning: Failed to remove corrupted file %s: %v\n", filepath, removeErr) - } - continue - } - - // Compare ISRC (case-insensitive) - if isrc != "" && strings.EqualFold(isrc, targetISRC) { - return filepath, true - } - } - - 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)) @@ -293,7 +220,6 @@ func ExtractCoverArt(filePath string) (string, error) { } } -// 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 { @@ -311,7 +237,6 @@ func extractCoverFromMp3(filePath string) (string, error) { 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) @@ -326,7 +251,6 @@ func extractCoverFromMp3(filePath string) (string, error) { 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)) @@ -343,7 +267,6 @@ func extractCoverFromM4AOrFlac(filePath string) (string, error) { continue } - // Create temporary file tmpFile, err := os.CreateTemp("", "cover-*.jpg") if err != nil { return "", fmt.Errorf("failed to create temp file: %w", err) @@ -361,12 +284,9 @@ func extractCoverFromM4AOrFlac(filePath string) (string, error) { 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)) @@ -376,14 +296,13 @@ func ExtractLyrics(filePath string) (string, error) { 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 { @@ -412,7 +331,6 @@ func extractLyricsFromMp3(filePath string) (string, error) { return uslt.Lyrics, nil } -// extractLyricsFromFlac extracts lyrics from FLAC file func extractLyricsFromFlac(filePath string) (string, error) { f, err := flac.ParseFile(filePath) if err != nil { @@ -426,7 +344,6 @@ func extractLyricsFromFlac(filePath string) (string, error) { continue } - // Search through comments for lyrics for _, comment := range cmt.Comments { parts := strings.SplitN(comment, "=", 2) if len(parts) == 2 { @@ -445,7 +362,6 @@ func extractLyricsFromFlac(filePath string) (string, error) { return "", nil } -// EmbedCoverArtOnly embeds cover art into an audio file func EmbedCoverArtOnly(filePath string, coverPath string) error { if coverPath == "" || !fileExists(coverPath) { return nil @@ -457,16 +373,13 @@ func EmbedCoverArtOnly(filePath string, coverPath string) error { 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 { @@ -474,16 +387,13 @@ func embedCoverToMp3(filePath string, coverPath string) error { } 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", @@ -500,27 +410,30 @@ func embedCoverToMp3(filePath string, coverPath string) error { return nil } -// EmbedLyricsOnlyMP3 adds lyrics to an MP3 file using ID3v2 USLT frame func EmbedLyricsOnlyMP3(filepath string, lyrics string) error { if lyrics == "" { return nil } + validatedLyrics, err := validateLyricsDuration(lyrics, filepath) + if err != nil { + fmt.Printf("[EmbedLyricsOnlyMP3] Warning: Failed to validate lyrics duration: %v, using original lyrics\n", err) + validatedLyrics = lyrics + } + lyrics = validatedLyrics + 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 + Encoding: id3v2.EncodingUTF8, Language: "eng", - ContentDescriptor: "", // Empty descriptor for better compatibility + ContentDescriptor: "", Lyrics: lyrics, } tag.AddUnsynchronisedLyricsFrame(usltFrame) @@ -532,10 +445,15 @@ func EmbedLyricsOnlyMP3(filepath string, lyrics string) error { 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 + + validatedLyrics, err := validateLyricsDuration(lyrics, filepath) + if err != nil { + fmt.Printf("[embedLyricsToM4A] Warning: Failed to validate lyrics duration: %v, using original lyrics\n", err) + validatedLyrics = lyrics + } + lyrics = validatedLyrics + ffmpegPath, err := GetFFmpegPath() if err != nil { return fmt.Errorf("ffmpeg not found: %w", err) @@ -545,18 +463,14 @@ func embedLyricsToM4A(filepath string, lyrics string) error { return fmt.Errorf("invalid ffmpeg executable: %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", @@ -564,12 +478,11 @@ func embedLyricsToM4A(filepath string, lyrics string) error { "-metadata", "lyrics-eng="+lyrics, "-metadata", "lyrics="+lyrics, "-codec", "copy", - "-f", "ipod", // Explicitly specify M4A/iPod format - "-y", // Overwrite + "-f", "ipod", + "-y", tmpOutputFile, ) - // Hide console window on Windows setHideWindow(cmd) output, err := cmd.CombinedOutput() @@ -578,7 +491,6 @@ func embedLyricsToM4A(filepath string, lyrics string) error { 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) } @@ -587,7 +499,6 @@ func embedLyricsToM4A(filepath string, lyrics string) error { return nil } -// EmbedLyricsOnlyUniversal embeds lyrics to MP3, FLAC, or M4A file func EmbedLyricsOnlyUniversal(filepath string, lyrics string) error { if lyrics == "" { return nil @@ -606,85 +517,451 @@ func EmbedLyricsOnlyUniversal(filepath string, lyrics string) error { } } -// 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"` -} +func GetAudioDuration(filepath string) (float64, error) { + ext := strings.ToLower(pathfilepath.Ext(filepath)) -// 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) + if ext == ".flac" { + duration, err := getFlacDuration(filepath) + if err == nil && duration > 0 { + return duration, nil + } } - wg.Wait() - return results + return getDurationWithFFprobe(filepath) } -// buildISRCIndex scans a directory and builds a map of ISRC -> file path -func buildISRCIndex(outputDir string) map[string]string { - index := make(map[string]string) +func getFlacDuration(filepath string) (float64, error) { + f, err := flac.ParseFile(filepath) + if err != nil { + return 0, err + } - // 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 + if len(f.Meta) > 0 { + streamInfo := f.Meta[0] + if streamInfo.Type == flac.StreamInfo { + data := streamInfo.Data + if len(data) >= 18 { + + sampleRate := uint32(data[10])<<12 | uint32(data[11])<<4 | uint32(data[12])>>4 + + totalSamples := uint64(data[13]&0x0F)<<32 | + uint64(data[14])<<24 | + uint64(data[15])<<16 | + uint64(data[16])<<8 | + uint64(data[17]) + + if sampleRate > 0 { + return float64(totalSamples) / float64(sampleRate), 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 + return 0, fmt.Errorf("could not extract duration from FLAC file") +} + +func getDurationWithFFprobe(filepath string) (float64, error) { + ffprobePath, err := GetFFprobePath() + if err != nil { + return 0, err + } + + if err := ValidateExecutable(ffprobePath); err != nil { + return 0, fmt.Errorf("invalid ffprobe executable: %w", err) + } + + cmd := exec.Command(ffprobePath, + "-v", "quiet", + "-print_format", "json", + "-show_format", + filepath, + ) + + setHideWindow(cmd) + + output, err := cmd.Output() + if err != nil { + return 0, err + } + + var result struct { + Format struct { + Duration string `json:"duration"` + } `json:"format"` + } + + if err := json.Unmarshal(output, &result); err != nil { + return 0, err + } + + if result.Format.Duration == "" { + return 0, fmt.Errorf("duration not found in ffprobe output") + } + + duration, err := strconv.ParseFloat(result.Format.Duration, 64) + if err != nil { + return 0, err + } + + return duration, nil +} + +func validateLyricsDuration(lyrics string, filepath string) (string, error) { + + duration, err := GetAudioDuration(filepath) + if err != nil { + + fmt.Printf("[ValidateLyrics] Warning: Could not get audio duration: %v, skipping validation\n", err) + return lyrics, nil + } + + if duration <= 0 { + + fmt.Printf("[ValidateLyrics] Warning: Invalid duration (%f seconds), skipping validation\n", duration) + return lyrics, nil + } + + durationMs := int64(duration * 1000) + + lines := strings.Split(lyrics, "\n") + var validLines []string + + for _, line := range lines { + trimmedLine := strings.TrimSpace(line) + if trimmedLine == "" { + validLines = append(validLines, line) + continue + } + + if strings.HasPrefix(trimmedLine, "[") { + + if strings.Index(trimmedLine, ":") > 0 { + + validLines = append(validLines, line) + continue + } + + closeBracket := strings.Index(trimmedLine, "]") + if closeBracket > 0 { + timestampStr := trimmedLine[1:closeBracket] + + ms := parseLRCTimestamp(timestampStr) + if ms >= 0 && ms <= durationMs { + + validLines = append(validLines, line) + } else { + + fmt.Printf("[ValidateLyrics] Filtered out line with timestamp %s (exceeds duration %d ms): %s\n", timestampStr, durationMs, trimmedLine) + } + } else { + + validLines = append(validLines, line) + } + } else { + + validLines = append(validLines, line) + } + } + + return strings.Join(validLines, "\n"), nil +} + +func parseLRCTimestamp(timestamp string) int64 { + var minutes, seconds, centiseconds int64 + n, _ := fmt.Sscanf(timestamp, "%d:%d.%d", &minutes, &seconds, ¢iseconds) + if n >= 2 { + return minutes*60*1000 + seconds*1000 + centiseconds*10 + } + return -1 +} + +func ExtractFullMetadataFromFile(filePath string) (Metadata, error) { + var metadata Metadata + + ffprobePath, err := GetFFprobePath() + if err != nil { + return metadata, err + } + + if err := ValidateExecutable(ffprobePath); err != nil { + return metadata, fmt.Errorf("invalid ffprobe executable: %w", err) + } + + cmd := exec.Command(ffprobePath, + "-v", "quiet", + "-print_format", "json", + "-show_format", + "-show_streams", + filePath, + ) + + setHideWindow(cmd) + + output, err := cmd.Output() + if err != nil { + return metadata, err + } + + 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 metadata, err + } + + allTags := make(map[string]string) + + for _, stream := range result.Streams { + for key, value := range stream.Tags { + allTags[strings.ToLower(key)] = value + } + } + + for key, value := range result.Format.Tags { + allTags[strings.ToLower(key)] = value + } + + 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 "date", "year": + if metadata.Date == "" || len(value) > len(metadata.Date) { + metadata.Date = value + } + case "track": + + parts := strings.Split(value, "/") + if len(parts) > 0 { + if num, err := strconv.Atoi(parts[0]); err == nil { + metadata.TrackNumber = num + } + } + if len(parts) > 1 { + if num, err := strconv.Atoi(parts[1]); err == nil { + metadata.TotalTracks = num + } + } + case "disc": + + parts := strings.Split(value, "/") + if len(parts) > 0 { + if num, err := strconv.Atoi(parts[0]); err == nil { + metadata.DiscNumber = num + } + } + if len(parts) > 1 { + if num, err := strconv.Atoi(parts[1]); err == nil { + metadata.TotalDiscs = num + } + } + case "copyright", "tcop": + metadata.Copyright = value + case "publisher", "tpub", "label": + metadata.Publisher = value + case "url": + metadata.URL = value + case "description", "comment": + if metadata.Description == "" { + metadata.Description = value + } + } + } + + return metadata, nil +} + +func EmbedMetadataToConvertedFile(filePath string, metadata Metadata, coverPath string) error { + ext := strings.ToLower(pathfilepath.Ext(filePath)) + + switch ext { + case ".flac": + + return EmbedMetadata(filePath, metadata, coverPath) + case ".mp3": + return embedMetadataToMP3(filePath, metadata, coverPath) + case ".m4a": + return embedMetadataToM4A(filePath, metadata, coverPath) + default: + return fmt.Errorf("unsupported file format: %s", ext) + } +} + +func embedMetadataToMP3(filePath string, metadata Metadata, 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() + + tag.DeleteFrames("TXXX") + + if metadata.Title != "" { + tag.SetTitle(metadata.Title) + } + if metadata.Artist != "" { + tag.SetArtist(metadata.Artist) + } + if metadata.Album != "" { + tag.SetAlbum(metadata.Album) + } + if metadata.Date != "" { + year := metadata.Date + if len(year) >= 4 { + year = year[:4] + } + tag.SetYear(year) + } + + if metadata.AlbumArtist != "" { + tag.DeleteFrames("TPE2") + tag.AddTextFrame("TPE2", id3v2.EncodingUTF8, metadata.AlbumArtist) + } + + if metadata.TrackNumber > 0 { + tag.DeleteFrames(tag.CommonID("Track number/Position in set")) + trackStr := strconv.Itoa(metadata.TrackNumber) + if metadata.TotalTracks > 0 { + trackStr = fmt.Sprintf("%d/%d", metadata.TrackNumber, metadata.TotalTracks) + } + tag.AddTextFrame(tag.CommonID("Track number/Position in set"), id3v2.EncodingUTF8, trackStr) + } + + if metadata.DiscNumber > 0 { + tag.DeleteFrames(tag.CommonID("Part of a set")) + discStr := strconv.Itoa(metadata.DiscNumber) + if metadata.TotalDiscs > 0 { + discStr = fmt.Sprintf("%d/%d", metadata.DiscNumber, metadata.TotalDiscs) + } + tag.AddTextFrame(tag.CommonID("Part of a set"), id3v2.EncodingUTF8, discStr) + } + + if metadata.Copyright != "" { + tag.DeleteFrames("TCOP") + tag.AddTextFrame("TCOP", id3v2.EncodingUTF8, metadata.Copyright) + } + + if metadata.Publisher != "" { + tag.DeleteFrames("TPUB") + tag.AddTextFrame("TPUB", id3v2.EncodingUTF8, metadata.Publisher) + } + + if coverPath != "" && fileExists(coverPath) { + + tag.DeleteFrames(tag.CommonID("Attached picture")) + + artwork, err := os.ReadFile(coverPath) + if err == nil { + pic := id3v2.PictureFrame{ + Encoding: id3v2.EncodingUTF8, + MimeType: "image/jpeg", + PictureType: id3v2.PTFrontCover, + Description: "Cover", + Picture: artwork, + } + tag.AddAttachedPicture(pic) + } else { + fmt.Printf("[EmbedMetadataToMP3] Warning: Failed to read cover art file: %v\n", err) + } + } + + if err := tag.Save(); err != nil { + return fmt.Errorf("failed to save MP3 tags: %w", err) + } + + return nil +} + +func embedMetadataToM4A(filePath string, metadata Metadata, coverPath string) error { + ffmpegPath, err := GetFFmpegPath() + if err != nil { + return fmt.Errorf("ffmpeg not found: %w", err) + } + + if err := ValidateExecutable(ffmpegPath); err != nil { + return fmt.Errorf("invalid ffmpeg executable: %w", err) + } + + args := []string{ + "-i", filePath, + "-y", + } + + if coverPath != "" && fileExists(coverPath) { + args = append(args, "-i", coverPath) + args = append(args, "-map", "0:a", "-map", "1", "-c:a", "copy", "-c:v", "copy", "-disposition:v:0", "attached_pic") + } else { + args = append(args, "-map", "0", "-codec", "copy") + } + + if metadata.Title != "" { + args = append(args, "-metadata", "title="+metadata.Title) + } + if metadata.Artist != "" { + args = append(args, "-metadata", "artist="+metadata.Artist) + } + if metadata.Album != "" { + args = append(args, "-metadata", "album="+metadata.Album) + } + if metadata.AlbumArtist != "" { + args = append(args, "-metadata", "album_artist="+metadata.AlbumArtist) + } + if metadata.Date != "" { + args = append(args, "-metadata", "date="+metadata.Date) + } + if metadata.TrackNumber > 0 { + trackStr := strconv.Itoa(metadata.TrackNumber) + if metadata.TotalTracks > 0 { + trackStr = fmt.Sprintf("%d/%d", metadata.TrackNumber, metadata.TotalTracks) + } + args = append(args, "-metadata", "track="+trackStr) + } + if metadata.DiscNumber > 0 { + discStr := strconv.Itoa(metadata.DiscNumber) + if metadata.TotalDiscs > 0 { + discStr = fmt.Sprintf("%d/%d", metadata.DiscNumber, metadata.TotalDiscs) + } + args = append(args, "-metadata", "disk="+discStr) + } + if metadata.Copyright != "" { + args = append(args, "-metadata", "copyright="+metadata.Copyright) + } + if metadata.Publisher != "" { + args = append(args, "-metadata", "publisher="+metadata.Publisher) + } + + tmpOutputFile := strings.TrimSuffix(filePath, pathfilepath.Ext(filePath)) + ".tmp" + pathfilepath.Ext(filePath) + defer func() { + if _, err := os.Stat(tmpOutputFile); err == nil { + os.Remove(tmpOutputFile) + } + }() + + args = append(args, "-f", "ipod", tmpOutputFile) + + cmd := exec.Command(ffmpegPath, args...) + setHideWindow(cmd) + + output, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("ffmpeg failed to embed metadata: %s - %w", string(output), err) + } + + if err := os.Rename(tmpOutputFile, filePath); err != nil { + return fmt.Errorf("failed to replace original file: %w", err) + } + + return nil } diff --git a/backend/progress.go b/backend/progress.go index abebc85..356f95b 100644 --- a/backend/progress.go +++ b/backend/progress.go @@ -7,7 +7,6 @@ import ( "time" ) -// DownloadStatus represents the status of a download item type DownloadStatus string const ( @@ -18,7 +17,6 @@ const ( StatusSkipped DownloadStatus = "skipped" ) -// DownloadItem represents a single item in the download queue type DownloadItem struct { ID string `json:"id"` TrackName string `json:"track_name"` @@ -26,16 +24,15 @@ type DownloadItem struct { AlbumName string `json:"album_name"` ISRC string `json:"isrc"` Status DownloadStatus `json:"status"` - Progress float64 `json:"progress"` // MB downloaded - TotalSize float64 `json:"total_size"` // MB total (if known) - Speed float64 `json:"speed"` // MB/s - StartTime int64 `json:"start_time"` // Unix timestamp - EndTime int64 `json:"end_time"` // Unix timestamp - ErrorMessage string `json:"error_message"` // If failed - FilePath string `json:"file_path"` // Final file path + Progress float64 `json:"progress"` + TotalSize float64 `json:"total_size"` + Speed float64 `json:"speed"` + StartTime int64 `json:"start_time"` + EndTime int64 `json:"end_time"` + ErrorMessage string `json:"error_message"` + FilePath string `json:"file_path"` } -// Global progress tracker var ( currentProgress float64 currentProgressLock sync.RWMutex @@ -44,7 +41,6 @@ var ( currentSpeed float64 speedLock sync.RWMutex - // Download queue tracking downloadQueue []DownloadItem downloadQueueLock sync.RWMutex currentItemID string @@ -55,27 +51,24 @@ var ( sessionStartLock sync.RWMutex ) -// ProgressInfo represents download progress information type ProgressInfo struct { IsDownloading bool `json:"is_downloading"` MBDownloaded float64 `json:"mb_downloaded"` SpeedMBps float64 `json:"speed_mbps"` } -// DownloadQueueInfo represents the complete download queue state type DownloadQueueInfo struct { IsDownloading bool `json:"is_downloading"` Queue []DownloadItem `json:"queue"` - CurrentSpeed float64 `json:"current_speed"` // MB/s - TotalDownloaded float64 `json:"total_downloaded"` // MB this session - SessionStartTime int64 `json:"session_start_time"` // Unix timestamp + CurrentSpeed float64 `json:"current_speed"` + TotalDownloaded float64 `json:"total_downloaded"` + SessionStartTime int64 `json:"session_start_time"` QueuedCount int `json:"queued_count"` CompletedCount int `json:"completed_count"` FailedCount int `json:"failed_count"` SkippedCount int `json:"skipped_count"` } -// GetDownloadProgress returns current download progress func GetDownloadProgress() ProgressInfo { downloadingLock.RLock() downloading := isDownloading @@ -96,34 +89,30 @@ func GetDownloadProgress() ProgressInfo { } } -// SetDownloadSpeed updates the current download speed func SetDownloadSpeed(mbps float64) { speedLock.Lock() currentSpeed = mbps speedLock.Unlock() } -// SetDownloadProgress updates the current download progress func SetDownloadProgress(mbDownloaded float64) { currentProgressLock.Lock() currentProgress = mbDownloaded currentProgressLock.Unlock() } -// SetDownloading sets the downloading state func SetDownloading(downloading bool) { downloadingLock.Lock() isDownloading = downloading downloadingLock.Unlock() if !downloading { - // Reset progress when download completes + SetDownloadProgress(0) SetDownloadSpeed(0) } } -// ProgressWriter wraps an io.Writer and reports download progress type ProgressWriter struct { writer io.Writer total int64 @@ -131,7 +120,7 @@ type ProgressWriter struct { startTime int64 lastTime int64 lastBytes int64 - itemID string // Track which download item this belongs to + itemID string } func NewProgressWriter(writer io.Writer) *ProgressWriter { @@ -147,7 +136,6 @@ func NewProgressWriter(writer io.Writer) *ProgressWriter { } } -// NewProgressWriterWithID creates a progress writer with an item ID for queue tracking func NewProgressWriterWithID(writer io.Writer, itemID string) *ProgressWriter { pw := NewProgressWriter(writer) pw.itemID = itemID @@ -162,13 +150,11 @@ func (pw *ProgressWriter) Write(p []byte) (int, error) { n, err := pw.writer.Write(p) pw.total += int64(n) - // Report progress every 256KB for smoother updates if pw.total-pw.lastPrinted >= 256*1024 { mbDownloaded := float64(pw.total) / (1024 * 1024) - // Calculate speed (MB/s) now := getCurrentTimeMillis() - timeDiff := float64(now-pw.lastTime) / 1000.0 // seconds + timeDiff := float64(now-pw.lastTime) / 1000.0 bytesDiff := float64(pw.total - pw.lastBytes) var speedMBps float64 @@ -180,10 +166,8 @@ func (pw *ProgressWriter) Write(p []byte) (int, error) { fmt.Printf("\rDownloaded: %.2f MB", mbDownloaded) } - // Update global progress SetDownloadProgress(mbDownloaded) - // Update individual item progress if we have an item ID if pw.itemID != "" { UpdateItemProgress(pw.itemID, mbDownloaded, speedMBps) } @@ -200,9 +184,6 @@ func (pw *ProgressWriter) GetTotal() int64 { return pw.total } -// Queue management functions - -// AddToQueue adds a new item to the download queue func AddToQueue(id, trackName, artistName, albumName, isrc string) { downloadQueueLock.Lock() defer downloadQueueLock.Unlock() @@ -223,7 +204,6 @@ func AddToQueue(id, trackName, artistName, albumName, isrc string) { downloadQueue = append(downloadQueue, item) - // Initialize session start time if this is the first item sessionStartLock.Lock() if sessionStartTime == 0 { sessionStartTime = time.Now().Unix() @@ -231,7 +211,6 @@ func AddToQueue(id, trackName, artistName, albumName, isrc string) { sessionStartLock.Unlock() } -// StartDownloadItem marks an item as currently downloading func StartDownloadItem(id string) { downloadQueueLock.Lock() defer downloadQueueLock.Unlock() @@ -250,7 +229,6 @@ func StartDownloadItem(id string) { currentItemLock.Unlock() } -// UpdateItemProgress updates the progress of the current download item func UpdateItemProgress(id string, progress, speed float64) { downloadQueueLock.Lock() defer downloadQueueLock.Unlock() @@ -264,14 +242,12 @@ func UpdateItemProgress(id string, progress, speed float64) { } } -// GetCurrentItemID returns the ID of the currently downloading item func GetCurrentItemID() string { currentItemLock.RLock() defer currentItemLock.RUnlock() return currentItemID } -// CompleteDownloadItem marks an item as completed func CompleteDownloadItem(id, filePath string, finalSize float64) { downloadQueueLock.Lock() defer downloadQueueLock.Unlock() @@ -284,7 +260,6 @@ func CompleteDownloadItem(id, filePath string, finalSize float64) { downloadQueue[i].Progress = finalSize downloadQueue[i].TotalSize = finalSize - // Add to total downloaded totalDownloadedLock.Lock() totalDownloaded += finalSize totalDownloadedLock.Unlock() @@ -293,7 +268,6 @@ func CompleteDownloadItem(id, filePath string, finalSize float64) { } } -// FailDownloadItem marks an item as failed func FailDownloadItem(id, errorMsg string) { downloadQueueLock.Lock() defer downloadQueueLock.Unlock() @@ -308,7 +282,6 @@ func FailDownloadItem(id, errorMsg string) { } } -// SkipDownloadItem marks an item as skipped (already exists) func SkipDownloadItem(id, filePath string) { downloadQueueLock.Lock() defer downloadQueueLock.Unlock() @@ -323,9 +296,8 @@ func SkipDownloadItem(id, filePath string) { } } -// GetDownloadQueue returns the complete download queue state func GetDownloadQueue() DownloadQueueInfo { - // Auto-reset session if all downloads are complete + ResetSessionIfComplete() downloadQueueLock.RLock() @@ -347,7 +319,6 @@ func GetDownloadQueue() DownloadQueueInfo { sessionStart := sessionStartTime sessionStartLock.RUnlock() - // Count statuses var queued, completed, failed, skipped int for _, item := range downloadQueue { switch item.Status { @@ -362,7 +333,6 @@ func GetDownloadQueue() DownloadQueueInfo { } } - // Create a copy of the queue queueCopy := make([]DownloadItem, len(downloadQueue)) copy(queueCopy, downloadQueue) @@ -379,12 +349,10 @@ func GetDownloadQueue() DownloadQueueInfo { } } -// ClearDownloadQueue clears all completed, failed, and skipped items from the queue func ClearDownloadQueue() { downloadQueueLock.Lock() defer downloadQueueLock.Unlock() - // Keep only queued and downloading items newQueue := make([]DownloadItem, 0) for _, item := range downloadQueue { if item.Status == StatusQueued || item.Status == StatusDownloading { @@ -394,7 +362,6 @@ func ClearDownloadQueue() { downloadQueue = newQueue } -// ClearAllDownloads clears the entire queue and resets session stats func ClearAllDownloads() { downloadQueueLock.Lock() downloadQueue = []DownloadItem{} @@ -412,13 +379,10 @@ func ClearAllDownloads() { currentItemID = "" currentItemLock.Unlock() - // Reset current progress and speed SetDownloadProgress(0) SetDownloadSpeed(0) } -// CancelAllQueuedItems marks all queued items as skipped (cancelled) -// This is called when user stops a download or when batch download completes func CancelAllQueuedItems() { downloadQueueLock.Lock() defer downloadQueueLock.Unlock() @@ -432,8 +396,6 @@ func CancelAllQueuedItems() { } } -// ResetSessionIfComplete resets session stats if no active or queued downloads -// Note: Does NOT clear the queue - items remain visible for history func ResetSessionIfComplete() { downloadQueueLock.RLock() hasActiveOrQueued := false @@ -445,8 +407,6 @@ func ResetSessionIfComplete() { } downloadQueueLock.RUnlock() - // If no active or queued items, reset session stats - // But keep the queue items for history visibility if !hasActiveOrQueued { sessionStartLock.Lock() sessionStartTime = 0 diff --git a/backend/qobuz.go b/backend/qobuz.go index 0729db0..f64a23b 100644 --- a/backend/qobuz.go +++ b/backend/qobuz.go @@ -78,7 +78,7 @@ func NewQobuzDownloader() *QobuzDownloader { } func (q *QobuzDownloader) SearchByISRC(isrc string) (*QobuzTrack, error) { - // Decode base64 API URL + apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly93d3cucW9idXouY29tL2FwaS5qc29uLzAuMi90cmFjay9zZWFyY2g/cXVlcnk9") url := fmt.Sprintf("%s%s&limit=1&app_id=%s", string(apiBase), isrc, q.appID) @@ -93,7 +93,7 @@ func (q *QobuzDownloader) SearchByISRC(isrc string) (*QobuzTrack, error) { } var searchResp QobuzSearchResponse - // 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) @@ -104,7 +104,7 @@ func (q *QobuzDownloader) SearchByISRC(isrc string) (*QobuzTrack, error) { } 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] + "..." @@ -120,20 +120,17 @@ 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 := quality // Use the provided quality parameter + + qualityCode := quality if qualityCode == "" { - qualityCode = "6" // Default to FLAC 16-bit if not specified + qualityCode = "6" } 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) @@ -154,7 +151,6 @@ func (q *QobuzDownloader) GetDownloadURL(trackID int64, quality string) (string, resp.Body.Close() } - // Fallback to secondary API fmt.Println("Primary API failed, trying fallback...") fallbackBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9kYWJtdXNpYy54eXovYXBpL3N0cmVhbT90cmFja0lkPQ==") fallbackURL := fmt.Sprintf("%s%d&quality=%s", string(fallbackBase), trackID, qualityCode) @@ -184,7 +180,7 @@ func (q *QobuzDownloader) GetDownloadURL(trackID int64, quality string) (string, var streamResp QobuzStreamResponse if err := json.Unmarshal(body, &streamResp); err != nil { - // Truncate body for error message (max 200 chars) + bodyStr := string(body) if len(bodyStr) > 200 { bodyStr = bodyStr[:200] + "..." @@ -202,10 +198,9 @@ func (q *QobuzDownloader) GetDownloadURL(trackID int64, quality string) (string, func (q *QobuzDownloader) DownloadFile(url, filepath string) error { fmt.Println("Starting file download...") - // 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 + Timeout: 5 * time.Minute, } resp, err := downloadClient.Get(url) @@ -226,14 +221,13 @@ func (q *QobuzDownloader) DownloadFile(url, filepath string) error { 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 } @@ -266,19 +260,16 @@ func (q *QobuzDownloader) DownloadCoverArt(coverURL, filepath string) error { func buildQobuzFilename(title, artist, album, albumArtist, releaseDate string, trackNumber, discNumber 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 } - // Extract year from release date (format: YYYY-MM-DD or YYYY) year := "" if len(releaseDate) >= 4 { year = releaseDate[:4] } - // Check if format is a template (contains {}) if strings.Contains(format, "{") { filename = format filename = strings.ReplaceAll(filename, "{title}", title) @@ -287,34 +278,31 @@ func buildQobuzFilename(title, artist, album, albumArtist, releaseDate string, t filename = strings.ReplaceAll(filename, "{album_artist}", albumArtist) filename = strings.ReplaceAll(filename, "{year}", year) - // Handle disc number if discNumber > 0 { filename = strings.ReplaceAll(filename, "{disc}", fmt.Sprintf("%d", discNumber)) } else { filename = strings.ReplaceAll(filename, "{disc}", "") } - // 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" + default: 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) } @@ -323,22 +311,20 @@ func buildQobuzFilename(title, artist, album, albumArtist, releaseDate string, t return filename + ".flac" } -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) +func (q *QobuzDownloader) DownloadByISRC(deezerISRC, outputDir, quality, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate string, useAlbumTrackNumber bool, spotifyCoverURL string, embedMaxQualityCover bool, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int, spotifyTotalDiscs int, spotifyCopyright, spotifyPublisher, spotifyURL string) (string, error) { + fmt.Printf("Fetching track info for ISRC: %s\n", deezerISRC) - // Create output directory if it doesn't exist if outputDir != "." { if err := os.MkdirAll(outputDir, 0755); err != nil { return "", fmt.Errorf("failed to create output directory: %w", err) } } - track, err := q.SearchByISRC(isrc) + track, err := q.SearchByISRC(deezerISRC) if err != nil { return "", err } - // All metadata from Spotify - no fallback to Qobuz artists := spotifyArtistName trackTitle := spotifyTrackName albumTitle := spotifyAlbumName @@ -362,7 +348,6 @@ func (q *QobuzDownloader) DownloadByISRC(isrc, outputDir, quality, filenameForma return "", fmt.Errorf("received empty download URL") } - // Show partial URL for security urlPreview := downloadURL if len(downloadURL) > 60 { urlPreview = downloadURL[:60] + "..." @@ -374,13 +359,6 @@ func (q *QobuzDownloader) DownloadByISRC(isrc, outputDir, quality, filenameForma safeAlbum := sanitizeFilename(albumTitle) safeAlbumArtist := sanitizeFilename(spotifyAlbumArtist) - // 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 (use Spotify track number) filename := buildQobuzFilename(safeTitle, safeArtist, safeAlbum, safeAlbumArtist, spotifyReleaseDate, spotifyTrackNumber, spotifyDiscNumber, filenameFormat, includeTrackNumber, position, useAlbumTrackNumber) filepath := filepath.Join(outputDir, filename) @@ -397,7 +375,7 @@ func (q *QobuzDownloader) DownloadByISRC(isrc, outputDir, quality, filenameForma fmt.Printf("Downloaded: %s\n", filepath) coverPath := "" - // Use Spotify cover URL (with max resolution if enabled) - all metadata from Spotify + if spotifyCoverURL != "" { coverPath = filepath + ".cover.jpg" coverClient := NewCoverClient() @@ -412,23 +390,24 @@ func (q *QobuzDownloader) DownloadByISRC(isrc, outputDir, quality, filenameForma fmt.Println("Embedding metadata and cover art...") - // Determine track number to embed - ALL from Spotify trackNumberToEmbed := spotifyTrackNumber - if position > 0 && !useAlbumTrackNumber { - trackNumberToEmbed = position // Use playlist position + if trackNumberToEmbed == 0 { + trackNumberToEmbed = 1 } - // ALL metadata from Spotify metadata := Metadata{ Title: trackTitle, Artist: artists, Album: albumTitle, AlbumArtist: spotifyAlbumArtist, - Date: spotifyReleaseDate, // Recorded date (full date YYYY-MM-DD) + Date: spotifyReleaseDate, TrackNumber: trackNumberToEmbed, - TotalTracks: spotifyTotalTracks, // Total tracks in album from Spotify - DiscNumber: spotifyDiscNumber, // Disc number from Spotify - ISRC: isrc, // ISRC from Spotify (passed as parameter) + TotalTracks: spotifyTotalTracks, + DiscNumber: spotifyDiscNumber, + TotalDiscs: spotifyTotalDiscs, + URL: spotifyURL, + Copyright: spotifyCopyright, + Publisher: spotifyPublisher, Description: "https://github.com/afkarxyz/SpotiFLAC", } diff --git a/backend/romaji.go b/backend/romaji.go index a41b2c8..54f404e 100644 --- a/backend/romaji.go +++ b/backend/romaji.go @@ -5,7 +5,6 @@ import ( "unicode" ) -// Hiragana to Romaji mapping var hiraganaToRomaji = map[rune]string{ 'あ': "a", 'い': "i", 'う': "u", 'え': "e", 'お': "o", 'か': "ka", 'き': "ki", 'く': "ku", 'け': "ke", 'こ': "ko", @@ -17,20 +16,19 @@ var hiraganaToRomaji = map[rune]string{ 'や': "ya", 'ゆ': "yu", 'よ': "yo", 'ら': "ra", 'り': "ri", 'る': "ru", 'れ': "re", 'ろ': "ro", 'わ': "wa", 'を': "wo", 'ん': "n", - // Dakuten (voiced) + 'が': "ga", 'ぎ': "gi", 'ぐ': "gu", 'げ': "ge", 'ご': "go", 'ざ': "za", 'じ': "ji", 'ず': "zu", 'ぜ': "ze", 'ぞ': "zo", 'だ': "da", 'ぢ': "ji", 'づ': "zu", 'で': "de", 'ど': "do", 'ば': "ba", 'び': "bi", 'ぶ': "bu", 'べ': "be", 'ぼ': "bo", - // Handakuten (semi-voiced) + 'ぱ': "pa", 'ぴ': "pi", 'ぷ': "pu", 'ぺ': "pe", 'ぽ': "po", - // Small characters + 'ゃ': "ya", 'ゅ': "yu", 'ょ': "yo", - 'っ': "", // Double consonant marker + 'っ': "", 'ぁ': "a", 'ぃ': "i", 'ぅ': "u", 'ぇ': "e", 'ぉ': "o", } -// Katakana to Romaji mapping var katakanaToRomaji = map[rune]string{ 'ア': "a", 'イ': "i", 'ウ': "u", 'エ': "e", 'オ': "o", 'カ': "ka", 'キ': "ki", 'ク': "ku", 'ケ': "ke", 'コ': "ko", @@ -42,23 +40,22 @@ var katakanaToRomaji = map[rune]string{ 'ヤ': "ya", 'ユ': "yu", 'ヨ': "yo", 'ラ': "ra", 'リ': "ri", 'ル': "ru", 'レ': "re", 'ロ': "ro", 'ワ': "wa", 'ヲ': "wo", 'ン': "n", - // Dakuten (voiced) + 'ガ': "ga", 'ギ': "gi", 'グ': "gu", 'ゲ': "ge", 'ゴ': "go", 'ザ': "za", 'ジ': "ji", 'ズ': "zu", 'ゼ': "ze", 'ゾ': "zo", 'ダ': "da", 'ヂ': "ji", 'ヅ': "zu", 'デ': "de", 'ド': "do", 'バ': "ba", 'ビ': "bi", 'ブ': "bu", 'ベ': "be", 'ボ': "bo", - // Handakuten (semi-voiced) + 'パ': "pa", 'ピ': "pi", 'プ': "pu", 'ペ': "pe", 'ポ': "po", - // Small characters + 'ャ': "ya", 'ュ': "yu", 'ョ': "yo", - 'ッ': "", // Double consonant marker + 'ッ': "", 'ァ': "a", 'ィ': "i", 'ゥ': "u", 'ェ': "e", 'ォ': "o", - // Extended katakana - 'ー': "", // Long vowel mark + + 'ー': "", 'ヴ': "vu", } -// Combination mappings for きゃ, しゃ, etc. var combinationHiragana = map[string]string{ "きゃ": "kya", "きゅ": "kyu", "きょ": "kyo", "しゃ": "sha", "しゅ": "shu", "しょ": "sho", @@ -85,13 +82,12 @@ var combinationKatakana = map[string]string{ "ジャ": "ja", "ジュ": "ju", "ジョ": "jo", "ビャ": "bya", "ビュ": "byu", "ビョ": "byo", "ピャ": "pya", "ピュ": "pyu", "ピョ": "pyo", - // Extended combinations + "ティ": "ti", "ディ": "di", "トゥ": "tu", "ドゥ": "du", "ファ": "fa", "フィ": "fi", "フェ": "fe", "フォ": "fo", "ウィ": "wi", "ウェ": "we", "ウォ": "wo", } -// ContainsJapanese checks if a string contains Japanese characters func ContainsJapanese(s string) bool { for _, r := range s { if isHiragana(r) || isKatakana(r) || isKanji(r) { @@ -110,12 +106,10 @@ func isKatakana(r rune) bool { } func isKanji(r rune) bool { - return (r >= 0x4E00 && r <= 0x9FFF) || // CJK Unified Ideographs - (r >= 0x3400 && r <= 0x4DBF) // CJK Unified Ideographs Extension A + return (r >= 0x4E00 && r <= 0x9FFF) || + (r >= 0x3400 && r <= 0x4DBF) } -// JapaneseToRomaji converts Japanese text (hiragana/katakana) to romaji -// Note: Kanji cannot be converted without a dictionary, so they are kept as-is func JapaneseToRomaji(text string) string { if !ContainsJapanese(text) { return text @@ -126,7 +120,7 @@ func JapaneseToRomaji(text string) string { i := 0 for i < len(runes) { - // Check for っ/ッ (double consonant) + if i < len(runes)-1 && (runes[i] == 'っ' || runes[i] == 'ッ') { nextRomaji := "" if romaji, ok := hiraganaToRomaji[runes[i+1]]; ok { @@ -135,13 +129,12 @@ func JapaneseToRomaji(text string) string { nextRomaji = romaji } if len(nextRomaji) > 0 { - result.WriteByte(nextRomaji[0]) // Double the first consonant + result.WriteByte(nextRomaji[0]) } i++ continue } - // Check for two-character combinations if i < len(runes)-1 { combo := string(runes[i : i+2]) if romaji, ok := combinationHiragana[combo]; ok { @@ -156,17 +149,16 @@ func JapaneseToRomaji(text string) string { } } - // Single character conversion r := runes[i] if romaji, ok := hiraganaToRomaji[r]; ok { result.WriteString(romaji) } else if romaji, ok := katakanaToRomaji[r]; ok { result.WriteString(romaji) } else if isKanji(r) { - // Keep kanji as-is (would need dictionary for proper conversion) + result.WriteRune(r) } else { - // Keep other characters (punctuation, spaces, etc.) + result.WriteRune(r) } i++ @@ -175,21 +167,17 @@ func JapaneseToRomaji(text string) string { return result.String() } -// BuildSearchQuery creates a search query from track name and artist -// Converts Japanese to romaji if present func BuildSearchQuery(trackName, artistName string) string { - // Convert Japanese to romaji + trackRomaji := JapaneseToRomaji(trackName) artistRomaji := JapaneseToRomaji(artistName) - // Clean up the query - remove special characters that might interfere with search trackClean := cleanSearchQuery(trackRomaji) artistClean := cleanSearchQuery(artistRomaji) return strings.TrimSpace(artistClean + " " + trackClean) } -// cleanSearchQuery removes special characters that might interfere with search func cleanSearchQuery(s string) string { var result strings.Builder for _, r := range s { @@ -202,21 +190,19 @@ func cleanSearchQuery(s string) string { return strings.TrimSpace(result.String()) } -// cleanToASCII removes all non-ASCII characters and keeps only letters, numbers, spaces -// This is useful for creating search queries that work better with Tidal's search func cleanToASCII(s string) string { var result strings.Builder for _, r := range s { - // Keep only ASCII letters, numbers, spaces, and basic punctuation + if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == ' ' || r == '-' || r == '\'' { result.WriteRune(r) } else if r == ',' || r == '.' { - // Convert punctuation to space + result.WriteRune(' ') } } - // Clean up multiple spaces + cleaned := strings.Join(strings.Fields(result.String()), " ") return strings.TrimSpace(cleaned) } diff --git a/backend/songlink.go b/backend/songlink.go index f9cdcb6..8ac1d37 100644 --- a/backend/songlink.go +++ b/backend/songlink.go @@ -7,6 +7,7 @@ import ( "io" "net/http" "net/url" + "strings" "time" ) @@ -22,7 +23,6 @@ type SongLinkURLs struct { AmazonURL string `json:"amazon_url"` } -// TrackAvailability represents the availability of a track on different platforms type TrackAvailability struct { SpotifyID string `json:"spotify_id"` Tidal bool `json:"tidal"` @@ -43,14 +43,13 @@ func NewSongLinkClient() *SongLinkClient { } func (s *SongLinkClient) GetAllURLsFromSpotify(spotifyTrackID string) (*SongLinkURLs, error) { - // Rate limiting: max 10 requests per minute (song.link API limit) + now := time.Now() if now.Sub(s.apiCallResetTime) >= time.Minute { s.apiCallCount = 0 s.apiCallResetTime = now } - // If we've hit the limit, wait until the next minute if s.apiCallCount >= 9 { waitTime := time.Minute - now.Sub(s.apiCallResetTime) if waitTime > 0 { @@ -61,7 +60,6 @@ func (s *SongLinkClient) GetAllURLsFromSpotify(spotifyTrackID string) (*SongLink } } - // Add delay between requests (7 seconds to be safe) if !s.lastAPICallTime.IsZero() { timeSinceLastCall := now.Sub(s.lastAPICallTime) minDelay := 7 * time.Second @@ -72,7 +70,6 @@ func (s *SongLinkClient) GetAllURLsFromSpotify(spotifyTrackID string) (*SongLink } } - // Decode base64 API URL spotifyBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9vcGVuLnNwb3RpZnkuY29tL3RyYWNrLw==") spotifyURL := fmt.Sprintf("%s%s", string(spotifyBase), spotifyTrackID) @@ -86,7 +83,6 @@ func (s *SongLinkClient) GetAllURLsFromSpotify(spotifyTrackID string) (*SongLink fmt.Println("Getting streaming URLs from song.link...") - // Retry logic for rate limit errors maxRetries := 3 var resp *http.Response for i := 0; i < maxRetries; i++ { @@ -95,7 +91,6 @@ func (s *SongLinkClient) GetAllURLsFromSpotify(spotifyTrackID string) (*SongLink return nil, fmt.Errorf("failed to get URLs: %w", err) } - // Update rate limit tracking s.lastAPICallTime = time.Now() s.apiCallCount++ @@ -124,7 +119,7 @@ func (s *SongLinkClient) GetAllURLsFromSpotify(spotifyTrackID string) (*SongLink URL string `json:"url"` } `json:"linksByPlatform"` } - // 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) @@ -135,7 +130,7 @@ func (s *SongLinkClient) GetAllURLsFromSpotify(spotifyTrackID string) (*SongLink } 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] + "..." @@ -145,23 +140,20 @@ func (s *SongLinkClient) GetAllURLsFromSpotify(spotifyTrackID string) (*SongLink urls := &SongLinkURLs{} - // Extract Tidal URL if tidalLink, ok := songLinkResp.LinksByPlatform["tidal"]; ok && tidalLink.URL != "" { urls.TidalURL = tidalLink.URL fmt.Printf("✓ Tidal URL found\n") } - // Extract Amazon URL if amazonLink, ok := songLinkResp.LinksByPlatform["amazonMusic"]; ok && amazonLink.URL != "" { amazonURL := amazonLink.URL - // Convert album URL to track URL if needed + if len(amazonURL) > 0 { urls.AmazonURL = amazonURL fmt.Printf("✓ Amazon URL found\n") } } - // Check if at least one URL was found if urls.TidalURL == "" && urls.AmazonURL == "" { return nil, fmt.Errorf("no streaming URLs found") } @@ -169,16 +161,14 @@ func (s *SongLinkClient) GetAllURLsFromSpotify(spotifyTrackID string) (*SongLink return urls, nil } -// CheckTrackAvailability checks the availability of a track on different platforms func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc string) (*TrackAvailability, error) { - // Rate limiting: max 10 requests per minute (song.link API limit) + now := time.Now() if now.Sub(s.apiCallResetTime) >= time.Minute { s.apiCallCount = 0 s.apiCallResetTime = now } - // If we've hit the limit, wait until the next minute if s.apiCallCount >= 9 { waitTime := time.Minute - now.Sub(s.apiCallResetTime) if waitTime > 0 { @@ -189,7 +179,6 @@ func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc stri } } - // Add delay between requests (7 seconds to be safe) if !s.lastAPICallTime.IsZero() { timeSinceLastCall := now.Sub(s.lastAPICallTime) minDelay := 7 * time.Second @@ -200,7 +189,6 @@ func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc stri } } - // Decode base64 API URL spotifyBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9vcGVuLnNwb3RpZnkuY29tL3RyYWNrLw==") spotifyURL := fmt.Sprintf("%s%s", string(spotifyBase), spotifyTrackID) @@ -214,7 +202,6 @@ func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc stri fmt.Printf("Checking availability for track: %s\n", spotifyTrackID) - // Retry logic for rate limit errors maxRetries := 3 var resp *http.Response for i := 0; i < maxRetries; i++ { @@ -223,7 +210,6 @@ func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc stri return nil, fmt.Errorf("failed to check availability: %w", err) } - // Update rate limit tracking s.lastAPICallTime = time.Now() s.apiCallCount++ @@ -252,7 +238,7 @@ func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc stri URL string `json:"url"` } `json:"linksByPlatform"` } - // 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) @@ -263,7 +249,7 @@ func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc stri } 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] + "..." @@ -275,33 +261,33 @@ func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc stri SpotifyID: spotifyTrackID, } - // Check Tidal if tidalLink, ok := songLinkResp.LinksByPlatform["tidal"]; ok && tidalLink.URL != "" { availability.Tidal = true availability.TidalURL = tidalLink.URL } - // Check Amazon if amazonLink, ok := songLinkResp.LinksByPlatform["amazonMusic"]; ok && amazonLink.URL != "" { availability.Amazon = true availability.AmazonURL = amazonLink.URL } - // Check Qobuz using ISRC (song.link doesn't support Qobuz) - if isrc != "" { - qobuzAvailable := checkQobuzAvailability(isrc) - availability.Qobuz = qobuzAvailable + if deezerLink, ok := songLinkResp.LinksByPlatform["deezer"]; ok && deezerLink.URL != "" { + deezerURL := deezerLink.URL + + deezerISRC, err := GetDeezerISRC(deezerURL) + if err == nil && deezerISRC != "" { + qobuzAvailable := checkQobuzAvailability(deezerISRC) + availability.Qobuz = qobuzAvailable + } } return availability, nil } -// checkQobuzAvailability checks if a track is available on Qobuz using ISRC func checkQobuzAvailability(isrc string) bool { client := &http.Client{Timeout: 10 * time.Second} appID := "798273057" - // Decode base64 API URL apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly93d3cucW9idXouY29tL2FwaS5qc29uLzAuMi90cmFjay9zZWFyY2g/cXVlcnk9") searchURL := fmt.Sprintf("%s%s&limit=1&app_id=%s", string(apiBase), isrc, appID) @@ -326,3 +312,139 @@ func checkQobuzAvailability(isrc string) bool { return searchResp.Tracks.Total > 0 } + +func (s *SongLinkClient) GetDeezerURLFromSpotify(spotifyTrackID string) (string, error) { + + now := time.Now() + if now.Sub(s.apiCallResetTime) >= time.Minute { + s.apiCallCount = 0 + s.apiCallResetTime = now + } + + if s.apiCallCount >= 9 { + waitTime := time.Minute - now.Sub(s.apiCallResetTime) + if waitTime > 0 { + fmt.Printf("Rate limit reached, waiting %v...\n", waitTime.Round(time.Second)) + time.Sleep(waitTime) + s.apiCallCount = 0 + s.apiCallResetTime = time.Now() + } + } + + if !s.lastAPICallTime.IsZero() { + timeSinceLastCall := now.Sub(s.lastAPICallTime) + minDelay := 7 * time.Second + if timeSinceLastCall < minDelay { + waitTime := minDelay - timeSinceLastCall + fmt.Printf("Rate limiting: waiting %v...\n", waitTime.Round(time.Second)) + time.Sleep(waitTime) + } + } + + spotifyBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9vcGVuLnNwb3RpZnkuY29tL3RyYWNrLw==") + spotifyURL := fmt.Sprintf("%s%s", string(spotifyBase), spotifyTrackID) + + apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9hcGkuc29uZy5saW5rL3YxLWFscGhhLjEvbGlua3M/dXJsPQ==") + apiURL := fmt.Sprintf("%s%s", string(apiBase), url.QueryEscape(spotifyURL)) + + req, err := http.NewRequest("GET", apiURL, nil) + if err != nil { + return "", fmt.Errorf("failed to create request: %w", err) + } + + fmt.Println("Getting Deezer URL from song.link...") + + maxRetries := 3 + var resp *http.Response + for i := 0; i < maxRetries; i++ { + resp, err = s.client.Do(req) + if err != nil { + return "", fmt.Errorf("failed to get Deezer URL: %w", err) + } + + s.lastAPICallTime = time.Now() + s.apiCallCount++ + + if resp.StatusCode == 429 { + resp.Body.Close() + if i < maxRetries-1 { + waitTime := 15 * time.Second + fmt.Printf("Rate limited by API, waiting %v before retry...\n", waitTime) + time.Sleep(waitTime) + continue + } + return "", fmt.Errorf("API rate limit exceeded after %d retries", maxRetries) + } + + if resp.StatusCode != 200 { + resp.Body.Close() + return "", fmt.Errorf("API returned status %d", resp.StatusCode) + } + + break + } + defer resp.Body.Close() + + var songLinkResp struct { + LinksByPlatform map[string]struct { + URL string `json:"url"` + } `json:"linksByPlatform"` + } + if err := json.NewDecoder(resp.Body).Decode(&songLinkResp); err != nil { + return "", 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 GetDeezerISRC(deezerURL string) (string, error) { + + var trackID string + if strings.Contains(deezerURL, "/track/") { + parts := strings.Split(deezerURL, "/track/") + if len(parts) > 1 { + trackID = strings.Split(parts[1], "?")[0] + trackID = strings.TrimSpace(trackID) + } + } + + if trackID == "" { + return "", fmt.Errorf("could not extract track ID from Deezer URL: %s", deezerURL) + } + + apiURL := fmt.Sprintf("https://api.deezer.com/track/%s", trackID) + + client := &http.Client{Timeout: 10 * time.Second} + resp, err := client.Get(apiURL) + if err != nil { + return "", fmt.Errorf("failed to call Deezer API: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + return "", fmt.Errorf("Deezer API returned status %d", resp.StatusCode) + } + + var deezerTrack struct { + ID int64 `json:"id"` + ISRC string `json:"isrc"` + Title string `json:"title"` + } + if err := json.NewDecoder(resp.Body).Decode(&deezerTrack); err != nil { + return "", fmt.Errorf("failed to decode Deezer API response: %w", err) + } + + if deezerTrack.ISRC == "" { + return "", fmt.Errorf("ISRC not found in Deezer API response for track %s", trackID) + } + + fmt.Printf("Found ISRC from Deezer: %s (track: %s)\n", deezerTrack.ISRC, deezerTrack.Title) + return deezerTrack.ISRC, nil +} diff --git a/backend/spectrum.go b/backend/spectrum.go index e6b6412..fe7b053 100644 --- a/backend/spectrum.go +++ b/backend/spectrum.go @@ -8,7 +8,6 @@ import ( "github.com/mewkiz/flac" ) -// SpectrumData contains frequency spectrum information type SpectrumData struct { TimeSlices []TimeSlice `json:"time_slices"` SampleRate int `json:"sample_rate"` @@ -17,15 +16,13 @@ type SpectrumData struct { MaxFreq float64 `json:"max_freq"` } -// TimeSlice represents spectrum data at a point in time type TimeSlice struct { Time float64 `json:"time"` Magnitudes []float64 `json:"magnitudes"` } -// AnalyzeSpectrum decodes FLAC file and performs FFT analysis func AnalyzeSpectrum(filepath string) (*SpectrumData, error) { - // Open FLAC file + stream, err := flac.ParseFile(filepath) if err != nil { return nil, fmt.Errorf("failed to parse FLAC: %w", err) @@ -36,7 +33,6 @@ func AnalyzeSpectrum(filepath string) (*SpectrumData, error) { sampleRate := int(info.SampleRate) channels := int(info.NChannels) - // Read audio samples samples, err := readSamples(stream, channels) if err != nil { return nil, fmt.Errorf("failed to read samples: %w", err) @@ -46,28 +42,23 @@ func AnalyzeSpectrum(filepath string) (*SpectrumData, error) { return nil, fmt.Errorf("no audio samples found") } - // Calculate spectrum return calculateSpectrum(samples, sampleRate), nil } -// readSamples reads and decodes audio samples from FLAC stream func readSamples(stream *flac.Stream, channels int) ([]float64, error) { var allSamples []float64 - maxSamples := 10 * 1024 * 1024 // Limit to ~10 million samples to avoid memory issues + maxSamples := 10 * 1024 * 1024 - // Decode frames for { frame, err := stream.ParseNext() if err != nil { - // End of stream + break } - // Convert samples to float64 and mix channels to mono for i := 0; i < frame.Subframes[0].NSamples; i++ { var sample float64 - // Mix all channels to mono by averaging for ch := 0; ch < channels; ch++ { sample += float64(frame.Subframes[ch].Samples[i]) } @@ -75,7 +66,6 @@ func readSamples(stream *flac.Stream, channels int) ([]float64, error) { allSamples = append(allSamples, sample) - // Limit sample count if len(allSamples) >= maxSamples { return allSamples, nil } @@ -85,7 +75,6 @@ func readSamples(stream *flac.Stream, channels int) ([]float64, error) { return allSamples, nil } -// calculateSpectrum performs FFT analysis on audio samples func calculateSpectrum(samples []float64, sampleRate int) *SpectrumData { fftSize := 8192 numTimeSlices := 300 @@ -140,7 +129,6 @@ func calculateSpectrum(samples []float64, sampleRate int) *SpectrumData { } } -// applyHannWindow applies Hann window to reduce spectral leakage func applyHannWindow(samples []float64) []float64 { n := len(samples) windowed := make([]float64, n) @@ -153,7 +141,6 @@ func applyHannWindow(samples []float64) []float64 { return windowed } -// fft performs Fast Fourier Transform using Cooley-Tukey algorithm func fft(samples []float64) []complex128 { n := len(samples) @@ -165,7 +152,6 @@ func fft(samples []float64) []complex128 { return fftRecursive(x) } -// fftRecursive performs recursive FFT func fftRecursive(x []complex128) []complex128 { n := len(x) diff --git a/backend/spotify_metadata.go b/backend/spotify_metadata.go index 10cad9d..2a0da23 100644 --- a/backend/spotify_metadata.go +++ b/backend/spotify_metadata.go @@ -7,67 +7,32 @@ import ( "errors" "fmt" "io" - "math/rand" "net/http" "net/url" "strconv" "strings" - "sync" "time" ) const ( - spotifyTokenURL = "https://accounts.spotify.com/api/token" - playlistBaseURL = "https://api.spotify.com/v1/playlists/%s" - albumBaseURL = "https://api.spotify.com/v1/albums/%s" - trackBaseURL = "https://api.spotify.com/v1/tracks/%s" - artistBaseURL = "https://api.spotify.com/v1/artists/%s" - artistAlbumsBaseURL = "https://api.spotify.com/v1/artists/%s/albums" + apiBaseURL = "https://afkarxyz.web.id" + apiKey = "NDAwNDAxNDAzNDA0NTAwNTAyNTAz" ) var ( errInvalidSpotifyURL = errors.New("invalid or unsupported Spotify URL") ) -// SpotifyMetadataClient mirrors the behaviour of Doc/getMetadata.py and interacts with Spotify's web API. type SpotifyMetadataClient struct { - httpClient *http.Client - clientID string - clientSecret string - cachedToken string - tokenExpiresAt time.Time - rng *rand.Rand - rngMu sync.Mutex - userAgent string + httpClient *http.Client } -// NewSpotifyMetadataClient creates a ready-to-use client with Official Spotify API credentials. func NewSpotifyMetadataClient() *SpotifyMetadataClient { - src := rand.NewSource(time.Now().UnixNano()) - - // Decode client ID from base64 - clientID := "" - if decoded, err := base64.StdEncoding.DecodeString("NWY1NzNjOTYyMDQ5NGJhZTg3ODkwYzBmMDhhNjAyOTM="); err == nil { - clientID = string(decoded) + return &SpotifyMetadataClient{ + httpClient: &http.Client{Timeout: 30 * time.Second}, } - - // Decode client secret from base64 - clientSecret := "" - if decoded, err := base64.StdEncoding.DecodeString("MjEyNDc2ZDliMGYzNDcyZWFhNzYyZDkwYjE5YjBiYTg="); err == nil { - clientSecret = string(decoded) - } - - c := &SpotifyMetadataClient{ - httpClient: &http.Client{Timeout: 15 * time.Second}, - clientID: clientID, - clientSecret: clientSecret, - rng: rand.New(src), - } - c.userAgent = c.randomUserAgent() - return c } -// TrackMetadata mirrors the filtered track payload returned by the Python script. type TrackMetadata struct { SpotifyID string `json:"spotify_id,omitempty"` Artists string `json:"artists"` @@ -80,18 +45,20 @@ type TrackMetadata struct { TrackNumber int `json:"track_number"` TotalTracks int `json:"total_tracks,omitempty"` DiscNumber int `json:"disc_number,omitempty"` + TotalDiscs int `json:"total_discs,omitempty"` ExternalURL string `json:"external_urls"` ISRC string `json:"isrc"` + Copyright string `json:"copyright,omitempty"` + Publisher string `json:"publisher,omitempty"` + Plays string `json:"plays,omitempty"` } -// ArtistSimple holds basic artist info for clickable artists type ArtistSimple struct { ID string `json:"id"` Name string `json:"name"` ExternalURL string `json:"external_urls"` } -// AlbumTrackMetadata holds per-track info for album / playlist formatting. type AlbumTrackMetadata struct { SpotifyID string `json:"spotify_id,omitempty"` Artists string `json:"artists"` @@ -104,6 +71,7 @@ type AlbumTrackMetadata struct { TrackNumber int `json:"track_number"` TotalTracks int `json:"total_tracks,omitempty"` DiscNumber int `json:"disc_number,omitempty"` + TotalDiscs int `json:"total_discs,omitempty"` ExternalURL string `json:"external_urls"` ISRC string `json:"isrc"` AlbumType string `json:"album_type,omitempty"` @@ -112,6 +80,8 @@ type AlbumTrackMetadata struct { ArtistID string `json:"artist_id,omitempty"` ArtistURL string `json:"artist_url,omitempty"` ArtistsData []ArtistSimple `json:"artists_data,omitempty"` + Plays string `json:"plays,omitempty"` + Status string `json:"status,omitempty"` } type TrackResponse struct { @@ -146,7 +116,9 @@ type PlaylistInfoMetadata struct { Name string `json:"name"` Images string `json:"images"` } `json:"owner"` - Batch string `json:"batch,omitempty"` + Cover string `json:"cover,omitempty"` + Description string `json:"description,omitempty"` + Batch string `json:"batch,omitempty"` } type PlaylistResponsePayload struct { @@ -159,9 +131,15 @@ type ArtistInfoMetadata struct { Followers int `json:"followers"` Genres []string `json:"genres"` Images string `json:"images"` + Header string `json:"header,omitempty"` + Gallery []string `json:"gallery,omitempty"` ExternalURL string `json:"external_urls"` DiscographyType string `json:"discography_type"` TotalAlbums int `json:"total_albums"` + Biography string `json:"biography,omitempty"` + Verified bool `json:"verified,omitempty"` + Listeners int `json:"listeners,omitempty"` + Rank int `json:"rank,omitempty"` Batch string `json:"batch,omitempty"` } @@ -199,146 +177,173 @@ type spotifyURI struct { DiscographyGroup string } -type accessTokenResponse struct { - AccessToken string `json:"access_token"` - ExpiresIn interface{} `json:"expires_in"` // Can be number or string - TokenType string `json:"token_type"` -} - -type image struct { - URL string `json:"url"` -} - -type externalURL struct { - Spotify string `json:"spotify"` -} - -type externalID struct { - ISRC string `json:"isrc"` -} - -type artist struct { - ID string `json:"id"` - Name string `json:"name"` -} - -type albumSimplified struct { - ID string `json:"id"` - Name string `json:"name"` - AlbumType string `json:"album_type"` - ReleaseDate string `json:"release_date"` - TotalTracks int `json:"total_tracks"` - Images []image `json:"images"` - ExternalURL externalURL `json:"external_urls"` - Artists []artist `json:"artists"` -} - -type trackSimplified struct { - ID string `json:"id"` - 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"` -} - -type trackFull struct { - ID string `json:"id"` - 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"` - Artists []artist `json:"artists"` -} - -type playlistTrackItem struct { - Track *trackFull `json:"track"` -} - -type playlistResponse struct { - Name string `json:"name"` - Images []image `json:"images"` - Owner struct { - DisplayName string `json:"display_name"` - } `json:"owner"` - Followers struct { - Total int `json:"total"` - } `json:"followers"` - Tracks struct { - Items []playlistTrackItem `json:"items"` - Next string `json:"next"` - Total int `json:"total"` - } `json:"tracks"` -} - -type albumResponse struct { - Name string `json:"name"` - ReleaseDate string `json:"release_date"` - TotalTracks int `json:"total_tracks"` - Images []image `json:"images"` - Artists []artist `json:"artists"` - Tracks struct { - Items []trackSimplified `json:"items"` - Next string `json:"next"` - } `json:"tracks"` -} - -type artistResponse struct { +type apiTrackResponse struct { + ID string `json:"id"` Name string `json:"name"` - Followers struct { + Artists string `json:"artists"` + Duration string `json:"duration"` + Track int `json:"track"` + Disc int `json:"disc"` + Discs int `json:"discs"` + Copyright string `json:"copyright"` + Label string `json:"label"` + Plays string `json:"plays"` + Album struct { + ID string `json:"id"` + Name string `json:"name"` + Released string `json:"released"` + Year int `json:"year"` + Tracks int `json:"tracks"` + Artists string `json:"artists"` + Label string `json:"label"` + } `json:"album"` + Cover struct { + Small string `json:"small"` + Medium string `json:"medium"` + Large string `json:"large"` + } `json:"cover"` +} + +type apiAlbumResponse struct { + ID string `json:"id"` + Name string `json:"name"` + Artists string `json:"artists"` + Cover string `json:"cover"` + ReleaseDate string `json:"releaseDate"` + Count int `json:"count"` + Tracks []struct { + ID string `json:"id"` + Name string `json:"name"` + Artists string `json:"artists"` + ArtistIds []string `json:"artistIds"` + Duration string `json:"duration"` + Plays string `json:"plays"` + } `json:"tracks"` +} + +type apiPlaylistResponse struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Owner struct { + Name string `json:"name"` + Avatar string `json:"avatar"` + } `json:"owner"` + Cover string `json:"cover"` + Count int `json:"count"` + Followers int `json:"followers"` + Tracks []struct { + ID string `json:"id"` + Cover string `json:"cover"` + Title string `json:"title"` + Artist string `json:"artist"` + ArtistIds []string `json:"artistIds"` + Plays string `json:"plays"` + Status string `json:"status"` + Album string `json:"album"` + AlbumID string `json:"albumId"` + Duration string `json:"duration"` + } `json:"tracks"` +} + +type apiArtistResponse struct { + ID string `json:"id"` + Name string `json:"name"` + Profile struct { + Biography string `json:"biography"` + Name string `json:"name"` + Verified bool `json:"verified"` + } `json:"profile"` + Avatar string `json:"avatar"` + Header string `json:"header"` + Stats struct { + Followers int `json:"followers"` + Listeners int `json:"listeners"` + Rank int `json:"rank"` + } `json:"stats"` + Gallery []string `json:"gallery"` + Discography struct { + All []struct { + ID string `json:"id"` + Name string `json:"name"` + Cover string `json:"cover"` + Date string `json:"date"` + Year int `json:"year"` + } `json:"all"` Total int `json:"total"` - } `json:"followers"` - Genres []string `json:"genres"` - Images []image `json:"images"` - ExternalURL externalURL `json:"external_urls"` - Popularity int `json:"popularity"` + } `json:"discography"` } -type playlistRaw struct { - Data playlistResponse - BatchEnabled bool - BatchCount int +type apiSearchResponse struct { + Results struct { + Tracks []struct { + ID string `json:"id"` + Name string `json:"name"` + Artists string `json:"artists"` + Album string `json:"album"` + Duration string `json:"duration"` + Cover string `json:"cover"` + } `json:"tracks"` + Albums []struct { + ID string `json:"id"` + Name string `json:"name"` + Artists string `json:"artists"` + Cover string `json:"cover"` + Year int `json:"year"` + } `json:"albums"` + Artists []struct { + ID string `json:"id"` + Name string `json:"name"` + Cover string `json:"cover"` + } `json:"artists"` + Playlists []struct { + ID string `json:"id"` + Name string `json:"name"` + Cover string `json:"cover"` + Owner string `json:"owner"` + } `json:"playlists"` + } `json:"results"` + TotalResults struct { + Tracks int `json:"tracks"` + Albums int `json:"albums"` + Artists int `json:"artists"` + Playlists int `json:"playlists"` + } `json:"totalResults"` } -type albumRaw struct { - Data albumResponse - Token string - BatchEnabled bool - BatchCount int +type SearchResult struct { + ID string `json:"id"` + Name string `json:"name"` + Type string `json:"type"` + Artists string `json:"artists,omitempty"` + AlbumName string `json:"album_name,omitempty"` + Images string `json:"images"` + ReleaseDate string `json:"release_date,omitempty"` + ExternalURL string `json:"external_urls"` + Duration int `json:"duration_ms,omitempty"` + TotalTracks int `json:"total_tracks,omitempty"` + Owner string `json:"owner,omitempty"` } -type discographyRaw struct { - Artist artistResponse - Albums []albumSimplified - Token string - Discography string - BatchEnabled bool - BatchCount int +type SearchResponse struct { + Tracks []SearchResult `json:"tracks"` + Albums []SearchResult `json:"albums"` + Artists []SearchResult `json:"artists"` + Playlists []SearchResult `json:"playlists"` } -// GetFilteredSpotifyData is a convenience wrapper that mirrors the Python module's entry point. func GetFilteredSpotifyData(ctx context.Context, spotifyURL string, batch bool, delay time.Duration) (interface{}, error) { client := NewSpotifyMetadataClient() return client.GetFilteredData(ctx, spotifyURL, batch, delay) } -// GetFilteredData fetches, normalises, and formats Spotify payloads for the given URL. func (c *SpotifyMetadataClient) GetFilteredData(ctx context.Context, spotifyURL string, batch bool, delay time.Duration) (interface{}, error) { parsed, err := parseSpotifyURI(spotifyURL) if err != nil { return nil, err } - token, err := c.getAccessToken(ctx) - if err != nil { - return nil, err - } - - raw, err := c.getRawSpotifyData(ctx, parsed, token, batch, delay) + raw, err := c.getRawSpotifyData(ctx, parsed, batch, delay) if err != nil { return nil, err } @@ -346,20 +351,20 @@ func (c *SpotifyMetadataClient) GetFilteredData(ctx context.Context, spotifyURL return c.processSpotifyData(ctx, raw) } -func (c *SpotifyMetadataClient) getRawSpotifyData(ctx context.Context, parsed spotifyURI, token string, batch bool, delay time.Duration) (interface{}, error) { +func (c *SpotifyMetadataClient) getRawSpotifyData(ctx context.Context, parsed spotifyURI, batch bool, delay time.Duration) (interface{}, error) { switch parsed.Type { case "playlist": - return c.fetchPlaylist(ctx, parsed.ID, token, batch, delay) + return c.fetchPlaylist(ctx, parsed.ID) case "album": - return c.fetchAlbum(ctx, parsed.ID, token, batch, delay) + return c.fetchAlbum(ctx, parsed.ID) case "track": - return c.fetchTrack(ctx, parsed.ID, token) + return c.fetchTrack(ctx, parsed.ID) case "artist_discography": - return c.fetchArtistDiscography(ctx, parsed, token, batch, delay) + return c.fetchArtistDiscography(ctx, parsed) case "artist": - // Automatically fetch discography for artist URLs to get full data (albums + tracks) + discographyParsed := spotifyURI{Type: "artist_discography", ID: parsed.ID, DiscographyGroup: "all"} - return c.fetchArtistDiscography(ctx, discographyParsed, token, batch, delay) + return c.fetchArtistDiscography(ctx, discographyParsed) default: return nil, fmt.Errorf("unsupported Spotify type: %s", parsed.Type) } @@ -367,221 +372,151 @@ func (c *SpotifyMetadataClient) getRawSpotifyData(ctx context.Context, parsed sp func (c *SpotifyMetadataClient) processSpotifyData(ctx context.Context, raw interface{}) (interface{}, error) { switch payload := raw.(type) { - case *playlistRaw: + case *apiPlaylistResponse: return c.formatPlaylistData(payload), nil - case *albumRaw: - return c.formatAlbumData(ctx, payload) - case *trackFull: - trackPayload := formatTrackData(payload) - return trackPayload, nil - case *discographyRaw: + case *apiAlbumResponse: + return c.formatAlbumData(payload) + case *apiTrackResponse: + return c.formatTrackData(payload), nil + case *apiArtistResponse: return c.formatArtistDiscographyData(ctx, payload) - case *artistResponse: - formatted := formatArtistData(payload) - return formatted, nil default: return nil, errors.New("unknown raw payload type") } } -func (c *SpotifyMetadataClient) fetchPlaylist(ctx context.Context, playlistID, token string, batch bool, delay time.Duration) (*playlistRaw, error) { - var data playlistResponse - if err := c.getJSON(ctx, fmt.Sprintf(playlistBaseURL, playlistID), token, &data); err != nil { - return nil, err - } - - tracksURL := fmt.Sprintf("https://api.spotify.com/v1/playlists/%s/tracks?limit=100", playlistID) - var items []playlistTrackItem - batchDelay := time.Duration(0) - if batch { - batchDelay = delay - } - batches, err := fetchPaging(ctx, c, tracksURL, token, batchDelay, &items) - if err != nil { - return nil, err - } - if len(items) > 0 { - data.Tracks.Items = items - } - - return &playlistRaw{ - Data: data, - BatchEnabled: batch, - BatchCount: batches, - }, nil -} - -func (c *SpotifyMetadataClient) fetchAlbum(ctx context.Context, albumID, token string, batch bool, delay time.Duration) (*albumRaw, error) { - var data albumResponse - if err := c.getJSON(ctx, fmt.Sprintf(albumBaseURL, albumID), token, &data); err != nil { - return nil, err - } - - tracksURL := fmt.Sprintf("%s/tracks?limit=50", fmt.Sprintf(albumBaseURL, albumID)) - var items []trackSimplified - batchDelay := time.Duration(0) - if batch { - batchDelay = delay - } - batches, err := fetchPaging(ctx, c, tracksURL, token, batchDelay, &items) - if err != nil { - return nil, err - } - if len(items) > 0 { - data.Tracks.Items = items - } - - return &albumRaw{ - Data: data, - Token: token, - BatchEnabled: batch, - BatchCount: batches, - }, nil -} - -func (c *SpotifyMetadataClient) fetchTrack(ctx context.Context, trackID, token string) (*trackFull, error) { - var data trackFull - if err := c.getJSON(ctx, fmt.Sprintf(trackBaseURL, trackID), token, &data); err != nil { +func (c *SpotifyMetadataClient) fetchTrack(ctx context.Context, trackID string) (*apiTrackResponse, error) { + url := fmt.Sprintf("%s/track/%s", apiBaseURL, trackID) + var data apiTrackResponse + if err := c.getJSON(ctx, url, &data); err != nil { return nil, err } return &data, nil } -func (c *SpotifyMetadataClient) fetchArtistDiscography(ctx context.Context, parsed spotifyURI, token string, batch bool, delay time.Duration) (*discographyRaw, error) { - var artistData artistResponse - if err := c.getJSON(ctx, fmt.Sprintf(artistBaseURL, parsed.ID), token, &artistData); err != nil { +func (c *SpotifyMetadataClient) fetchAlbum(ctx context.Context, albumID string) (*apiAlbumResponse, error) { + url := fmt.Sprintf("%s/album/%s", apiBaseURL, albumID) + var data apiAlbumResponse + if err := c.getJSON(ctx, url, &data); err != nil { return nil, err } - - includeGroups := parsed.DiscographyGroup - if includeGroups == "" || includeGroups == "all" { - includeGroups = "album,single,compilation" - } - - albumsURL := fmt.Sprintf("%s?include_groups=%s&limit=50", fmt.Sprintf(artistAlbumsBaseURL, parsed.ID), includeGroups) - var albums []albumSimplified - batchDelay := time.Duration(0) - if batch { - batchDelay = delay - } - batches, err := fetchPaging(ctx, c, albumsURL, token, batchDelay, &albums) - if err != nil { - return nil, err - } - - return &discographyRaw{ - Artist: artistData, - Albums: albums, - Token: token, - Discography: parsed.DiscographyGroup, - BatchEnabled: batch, - BatchCount: batches, - }, nil + return &data, nil } -func (c *SpotifyMetadataClient) fetchArtist(ctx context.Context, artistID, token string) (*artistResponse, error) { - var artistData artistResponse - if err := c.getJSON(ctx, fmt.Sprintf(artistBaseURL, artistID), token, &artistData); err != nil { +func (c *SpotifyMetadataClient) fetchPlaylist(ctx context.Context, playlistID string) (*apiPlaylistResponse, error) { + url := fmt.Sprintf("%s/playlist/%s", apiBaseURL, playlistID) + var data apiPlaylistResponse + if err := c.getJSON(ctx, url, &data); err != nil { return nil, err } - return &artistData, nil + return &data, nil } -func (c *SpotifyMetadataClient) formatPlaylistData(raw *playlistRaw) PlaylistResponsePayload { - var info PlaylistInfoMetadata - info.Tracks.Total = raw.Data.Tracks.Total - info.Followers.Total = raw.Data.Followers.Total - info.Owner.DisplayName = raw.Data.Owner.DisplayName - info.Owner.Name = raw.Data.Name - info.Owner.Images = firstImageURL(raw.Data.Images) - if raw.BatchEnabled { - info.Batch = strconv.Itoa(maxInt(1, raw.BatchCount)) +func (c *SpotifyMetadataClient) fetchArtistDiscography(ctx context.Context, parsed spotifyURI) (*apiArtistResponse, error) { + url := fmt.Sprintf("%s/artist/%s", apiBaseURL, parsed.ID) + var data apiArtistResponse + if err := c.getJSON(ctx, url, &data); err != nil { + return nil, err + } + return &data, nil +} + +func (c *SpotifyMetadataClient) formatTrackData(raw *apiTrackResponse) TrackResponse { + durationMS := parseDuration(raw.Duration) + + externalURL := fmt.Sprintf("https://open.spotify.com/track/%s", raw.ID) + + coverURL := raw.Cover.Medium + if coverURL == "" { + coverURL = raw.Cover.Large + } + if coverURL == "" { + coverURL = raw.Cover.Small } - tracks := make([]AlbumTrackMetadata, 0, len(raw.Data.Tracks.Items)) - for _, item := range raw.Data.Tracks.Items { - if item.Track == nil { - continue - } - var artistID, artistURL string - if len(item.Track.Artists) > 0 { - artistID = item.Track.Artists[0].ID - artistURL = fmt.Sprintf("https://open.spotify.com/artist/%s", item.Track.Artists[0].ID) - } - artistsData := make([]ArtistSimple, 0, len(item.Track.Artists)) - for _, a := range item.Track.Artists { - artistsData = append(artistsData, ArtistSimple{ - ID: a.ID, - Name: a.Name, - ExternalURL: fmt.Sprintf("https://open.spotify.com/artist/%s", a.ID), - }) - } - tracks = append(tracks, AlbumTrackMetadata{ - 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, - AlbumID: item.Track.Album.ID, - AlbumURL: item.Track.Album.ExternalURL.Spotify, - ArtistID: artistID, - ArtistURL: artistURL, - ArtistsData: artistsData, - }) + releaseDate := raw.Album.Released + if releaseDate == "" && raw.Album.Year > 0 { + releaseDate = fmt.Sprintf("%d", raw.Album.Year) + } + trackMetadata := TrackMetadata{ + SpotifyID: raw.ID, + Artists: raw.Artists, + Name: raw.Name, + AlbumName: raw.Album.Name, + AlbumArtist: raw.Album.Artists, + DurationMS: durationMS, + Images: coverURL, + ReleaseDate: releaseDate, + TrackNumber: raw.Track, + TotalTracks: raw.Album.Tracks, + DiscNumber: raw.Disc, + TotalDiscs: raw.Discs, + ExternalURL: externalURL, + ISRC: raw.ID, + Copyright: raw.Copyright, + Publisher: raw.Album.Label, + Plays: raw.Plays, } - return PlaylistResponsePayload{ - PlaylistInfo: info, - TrackList: tracks, + return TrackResponse{ + Track: trackMetadata, } } -func (c *SpotifyMetadataClient) formatAlbumData(ctx context.Context, raw *albumRaw) (*AlbumResponsePayload, error) { - albumImage := firstImageURL(raw.Data.Images) +func (c *SpotifyMetadataClient) formatAlbumData(raw *apiAlbumResponse) (*AlbumResponsePayload, error) { var artistID, artistURL string - if len(raw.Data.Artists) > 0 { - artistID = raw.Data.Artists[0].ID - artistURL = fmt.Sprintf("https://open.spotify.com/artist/%s", raw.Data.Artists[0].ID) - } + info := AlbumInfoMetadata{ - TotalTracks: raw.Data.TotalTracks, - Name: raw.Data.Name, - ReleaseDate: raw.Data.ReleaseDate, - Artists: joinArtists(raw.Data.Artists), - Images: albumImage, + TotalTracks: raw.Count, + Name: raw.Name, + ReleaseDate: raw.ReleaseDate, + Artists: raw.Artists, + Images: raw.Cover, ArtistID: artistID, ArtistURL: artistURL, } - if raw.BatchEnabled { - info.Batch = strconv.Itoa(maxInt(1, raw.BatchCount)) - } - tracks := make([]AlbumTrackMetadata, 0, len(raw.Data.Tracks.Items)) - cache := make(map[string]string) - for _, item := range raw.Data.Tracks.Items { - isrc := c.fetchTrackISRC(ctx, item.ID, raw.Token, cache) + tracks := make([]AlbumTrackMetadata, 0, len(raw.Tracks)) + for idx, item := range raw.Tracks { + durationMS := parseDuration(item.Duration) + trackNumber := idx + 1 + + var artistID, artistURL string + if len(item.ArtistIds) > 0 { + artistID = item.ArtistIds[0] + artistURL = fmt.Sprintf("https://open.spotify.com/artist/%s", artistID) + } + + artistsData := make([]ArtistSimple, 0, len(item.ArtistIds)) + for _, id := range item.ArtistIds { + artistsData = append(artistsData, ArtistSimple{ + ID: id, + Name: "", + ExternalURL: fmt.Sprintf("https://open.spotify.com/artist/%s", id), + }) + } + tracks = append(tracks, AlbumTrackMetadata{ SpotifyID: item.ID, - Artists: joinArtists(item.Artists), + Artists: 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, + AlbumName: raw.Name, + AlbumArtist: raw.Artists, + DurationMS: durationMS, + Images: raw.Cover, + ReleaseDate: raw.ReleaseDate, + TrackNumber: trackNumber, + TotalTracks: raw.Count, + DiscNumber: 1, + TotalDiscs: 0, + ExternalURL: fmt.Sprintf("https://open.spotify.com/track/%s", item.ID), + ISRC: item.ID, + AlbumID: raw.ID, + AlbumURL: fmt.Sprintf("https://open.spotify.com/album/%s", raw.ID), + ArtistID: artistID, + ArtistURL: artistURL, + ArtistsData: artistsData, + Plays: item.Plays, }) } @@ -591,84 +526,159 @@ func (c *SpotifyMetadataClient) formatAlbumData(ctx context.Context, raw *albumR }, nil } -func (c *SpotifyMetadataClient) formatArtistDiscographyData(ctx context.Context, raw *discographyRaw) (*ArtistDiscographyPayload, error) { - artistImage := firstImageURL(raw.Artist.Images) - discType := raw.Discography - if discType == "" { - discType = "all" +func (c *SpotifyMetadataClient) formatPlaylistData(raw *apiPlaylistResponse) PlaylistResponsePayload { + var info PlaylistInfoMetadata + info.Tracks.Total = raw.Count + info.Followers.Total = raw.Followers + info.Owner.DisplayName = raw.Owner.Name + info.Owner.Name = raw.Name + info.Owner.Images = raw.Owner.Avatar + info.Cover = raw.Cover + info.Description = raw.Description + + tracks := make([]AlbumTrackMetadata, 0, len(raw.Tracks)) + for _, item := range raw.Tracks { + durationMS := parseDuration(item.Duration) + + var artistID, artistURL string + if len(item.ArtistIds) > 0 { + artistID = item.ArtistIds[0] + artistURL = fmt.Sprintf("https://open.spotify.com/artist/%s", artistID) + } + + artistsData := make([]ArtistSimple, 0, len(item.ArtistIds)) + for _, id := range item.ArtistIds { + artistsData = append(artistsData, ArtistSimple{ + ID: id, + Name: "", + ExternalURL: fmt.Sprintf("https://open.spotify.com/artist/%s", id), + }) + } + + tracks = append(tracks, AlbumTrackMetadata{ + SpotifyID: item.ID, + Artists: item.Artist, + Name: item.Title, + AlbumName: item.Album, + AlbumArtist: item.Artist, + DurationMS: durationMS, + Images: item.Cover, + ReleaseDate: "", + TrackNumber: 0, + TotalTracks: 0, + DiscNumber: 1, + TotalDiscs: 0, + ExternalURL: fmt.Sprintf("https://open.spotify.com/track/%s", item.ID), + ISRC: item.ID, + AlbumID: item.AlbumID, + AlbumURL: fmt.Sprintf("https://open.spotify.com/album/%s", item.AlbumID), + ArtistID: artistID, + ArtistURL: artistURL, + ArtistsData: artistsData, + Plays: item.Plays, + Status: item.Status, + }) } + return PlaylistResponsePayload{ + PlaylistInfo: info, + TrackList: tracks, + } +} + +func (c *SpotifyMetadataClient) formatArtistDiscographyData(ctx context.Context, raw *apiArtistResponse) (*ArtistDiscographyPayload, error) { + discType := "all" + info := ArtistInfoMetadata{ - Name: raw.Artist.Name, - Followers: raw.Artist.Followers.Total, - Genres: raw.Artist.Genres, - Images: artistImage, - ExternalURL: raw.Artist.ExternalURL.Spotify, + Name: raw.Name, + Followers: raw.Stats.Followers, + Genres: []string{}, + Images: raw.Avatar, + Header: raw.Header, + Gallery: raw.Gallery, + ExternalURL: fmt.Sprintf("https://open.spotify.com/artist/%s", raw.ID), DiscographyType: discType, - TotalAlbums: len(raw.Albums), - } - if raw.BatchEnabled { - info.Batch = strconv.Itoa(maxInt(1, raw.BatchCount)) + TotalAlbums: raw.Discography.Total, + Biography: raw.Profile.Biography, + Verified: raw.Profile.Verified, + Listeners: raw.Stats.Listeners, + Rank: raw.Stats.Rank, } - albumList := make([]DiscographyAlbumMetadata, 0, len(raw.Albums)) + albumList := make([]DiscographyAlbumMetadata, 0, len(raw.Discography.All)) allTracks := make([]AlbumTrackMetadata, 0) - isrcCache := make(map[string]string) - for _, alb := range raw.Albums { - albumImage := firstImageURL(alb.Images) + for _, alb := range raw.Discography.All { + + select { + case <-ctx.Done(): + + return &ArtistDiscographyPayload{ + ArtistInfo: info, + AlbumList: albumList, + TrackList: allTracks, + }, ctx.Err() + default: + + } + albumList = append(albumList, DiscographyAlbumMetadata{ ID: alb.ID, Name: alb.Name, - AlbumType: alb.AlbumType, - ReleaseDate: alb.ReleaseDate, - TotalTracks: alb.TotalTracks, - Artists: joinArtists(alb.Artists), - Images: albumImage, - ExternalURL: alb.ExternalURL.Spotify, + AlbumType: "album", + ReleaseDate: alb.Date, + TotalTracks: 0, + Artists: raw.Name, + Images: alb.Cover, + ExternalURL: fmt.Sprintf("https://open.spotify.com/album/%s", alb.ID), }) - tracks, err := c.collectAlbumTracks(ctx, alb.ID, raw.Token) + albumData, err := c.fetchAlbum(ctx, alb.ID) if err != nil { fmt.Printf("Error getting tracks for album %s: %v\n", alb.Name, err) continue } - for _, tr := range tracks { - isrc := c.fetchTrackISRC(ctx, tr.ID, raw.Token, isrcCache) + for idx, tr := range albumData.Tracks { + durationMS := parseDuration(tr.Duration) + trackNumber := idx + 1 + var artistID, artistURL string - if len(tr.Artists) > 0 { - artistID = tr.Artists[0].ID - artistURL = fmt.Sprintf("https://open.spotify.com/artist/%s", tr.Artists[0].ID) + if len(tr.ArtistIds) > 0 { + artistID = tr.ArtistIds[0] + artistURL = fmt.Sprintf("https://open.spotify.com/artist/%s", artistID) } - artistsData := make([]ArtistSimple, 0, len(tr.Artists)) - for _, a := range tr.Artists { + + artistsData := make([]ArtistSimple, 0, len(tr.ArtistIds)) + for _, id := range tr.ArtistIds { artistsData = append(artistsData, ArtistSimple{ - ID: a.ID, - Name: a.Name, - ExternalURL: fmt.Sprintf("https://open.spotify.com/artist/%s", a.ID), + ID: id, + Name: "", + ExternalURL: fmt.Sprintf("https://open.spotify.com/artist/%s", id), }) } + allTracks = append(allTracks, AlbumTrackMetadata{ SpotifyID: tr.ID, - Artists: joinArtists(tr.Artists), + Artists: 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, + AlbumName: albumData.Name, + AlbumArtist: albumData.Artists, + AlbumType: "album", + DurationMS: durationMS, + Images: albumData.Cover, + ReleaseDate: albumData.ReleaseDate, + TrackNumber: trackNumber, + TotalTracks: albumData.Count, + DiscNumber: 1, + ExternalURL: fmt.Sprintf("https://open.spotify.com/track/%s", tr.ID), + ISRC: tr.ID, AlbumID: alb.ID, - AlbumURL: alb.ExternalURL.Spotify, + AlbumURL: fmt.Sprintf("https://open.spotify.com/album/%s", alb.ID), ArtistID: artistID, ArtistURL: artistURL, ArtistsData: artistsData, + Plays: tr.Plays, }) } } @@ -680,242 +690,56 @@ func (c *SpotifyMetadataClient) formatArtistDiscographyData(ctx context.Context, }, nil } -func formatArtistData(raw *artistResponse) ArtistResponsePayload { - if raw == nil { - return ArtistResponsePayload{} - } - payload := ArtistResponsePayload{} - payload.Artist.Name = raw.Name - payload.Artist.Followers = raw.Followers.Total - payload.Artist.Genres = raw.Genres - payload.Artist.Images = firstImageURL(raw.Images) - payload.Artist.ExternalURL = raw.ExternalURL.Spotify - payload.Artist.Popularity = raw.Popularity - return payload -} - -func formatTrackData(raw *trackFull) TrackResponse { - if raw == nil { - return 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, - }, - } -} - -func (c *SpotifyMetadataClient) collectAlbumTracks(ctx context.Context, albumID, token string) ([]trackSimplified, error) { - url := fmt.Sprintf("%s/tracks?limit=50", fmt.Sprintf(albumBaseURL, albumID)) - var tracks []trackSimplified - _, err := fetchPaging(ctx, c, url, token, 0, &tracks) +func (c *SpotifyMetadataClient) getJSON(ctx context.Context, endpoint string, dst interface{}) error { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) if err != nil { - return nil, err - } - return tracks, nil -} - -func (c *SpotifyMetadataClient) fetchTrackISRC(ctx context.Context, trackID, token string, cache map[string]string) string { - if trackID == "" || token == "" { - return "" - } - if isrc, ok := cache[trackID]; ok { - return isrc + return err } - var data struct { - ExternalID externalID `json:"external_ids"` - } - if err := c.getJSON(ctx, fmt.Sprintf(trackBaseURL, trackID), token, &data); err != nil { - return "" - } - cache[trackID] = data.ExternalID.ISRC - return cache[trackID] -} - -func fetchPaging[T any](ctx context.Context, client *SpotifyMetadataClient, nextURL, token string, delay time.Duration, dest *[]T) (int, error) { - batches := 0 - for nextURL != "" { - select { - case <-ctx.Done(): - return batches, ctx.Err() - default: - } - - var page struct { - Items []T `json:"items"` - Next string `json:"next"` - } - if err := client.getJSON(ctx, nextURL, token, &page); err != nil { - return batches, err - } - - *dest = append(*dest, page.Items...) - nextURL = stripLocaleParam(page.Next) - batches++ - - if nextURL != "" && delay > 0 { - if err := sleepWithContext(ctx, delay); err != nil { - return batches, err - } - } - } - return batches, nil -} - -func (c *SpotifyMetadataClient) getJSON(ctx context.Context, endpoint, token string, dst interface{}) error { - for { - req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) - if err != nil { - return err - } - headers := c.baseHeaders() - for key, values := range headers { - for _, v := range values { - req.Header.Add(key, v) - } - } - if token != "" { - req.Header.Set("Authorization", "Bearer "+token) - } - - resp, err := c.httpClient.Do(req) - if err != nil { - return err - } - body, err := io.ReadAll(resp.Body) - resp.Body.Close() - if err != nil { - return err - } - - if resp.StatusCode == http.StatusTooManyRequests { - if err := sleepWithContext(ctx, parseRetryAfter(resp.Header.Get("Retry-After"))); err != nil { - return err - } - continue - } - - if resp.StatusCode != http.StatusOK { - return fmt.Errorf("spotify API returned status %d for %s", resp.StatusCode, endpoint) - } - - return json.Unmarshal(body, dst) - } -} - -func (c *SpotifyMetadataClient) baseHeaders() http.Header { - h := http.Header{} - h.Set("User-Agent", c.userAgent) - h.Set("Accept", "application/json") - h.Set("Accept-Language", "en-US,en;q=0.9") - h.Set("sec-ch-ua-platform", "\"Windows\"") - h.Set("sec-fetch-dest", "empty") - h.Set("sec-fetch-mode", "cors") - h.Set("sec-fetch-site", "same-origin") - h.Set("Referer", "https://open.spotify.com/") - h.Set("Origin", "https://open.spotify.com") - return h -} - -func (c *SpotifyMetadataClient) randomUserAgent() string { - c.rngMu.Lock() - defer c.rngMu.Unlock() - - macMajor := c.randRange(11, 15) - macMinor := c.randRange(4, 9) - webkitMajor := c.randRange(530, 537) - webkitMinor := c.randRange(30, 37) - chromeMajor := c.randRange(80, 105) - chromeBuild := c.randRange(3000, 4500) - chromePatch := c.randRange(60, 125) - safariMajor := c.randRange(530, 537) - safariMinor := c.randRange(30, 36) - - return fmt.Sprintf( - "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_%d_%d) AppleWebKit/%d.%d (KHTML, like Gecko) Chrome/%d.0.%d.%d Safari/%d.%d", - macMajor, - macMinor, - webkitMajor, - webkitMinor, - chromeMajor, - chromeBuild, - chromePatch, - safariMajor, - safariMinor, - ) -} - -func (c *SpotifyMetadataClient) randRange(min, max int) int { - if max <= min { - return min - } - return c.rng.Intn(max-min) + min -} - -func (c *SpotifyMetadataClient) getAccessToken(ctx context.Context) (string, error) { - // Return cached token if still valid - if c.cachedToken != "" && time.Now().Before(c.tokenExpiresAt) { - return c.cachedToken, nil - } - - // Prepare request body for Client Credentials Flow - data := url.Values{} - data.Set("grant_type", "client_credentials") - - req, err := http.NewRequestWithContext(ctx, http.MethodPost, spotifyTokenURL, strings.NewReader(data.Encode())) + decodedKey, err := base64.StdEncoding.DecodeString(apiKey) if err != nil { - return "", err + return fmt.Errorf("failed to decode API key: %w", err) } - - // Set Basic Auth header - req.SetBasicAuth(c.clientID, c.clientSecret) - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Accept", "application/json") + req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36") + req.Header.Set("X-API-Key", string(decodedKey)) resp, err := c.httpClient.Do(req) if err != nil { - return "", err + return err } defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("API returned status %d for %s: %s", resp.StatusCode, endpoint, string(body)) + } + body, err := io.ReadAll(resp.Body) if err != nil { - return "", err + return err } - if resp.StatusCode != http.StatusOK { - return "", fmt.Errorf("failed to get access token. Status code: %d, Response: %s", resp.StatusCode, string(body)) + return json.Unmarshal(body, dst) +} + +func parseDuration(durationStr string) int { + if durationStr == "" { + return 0 } - var token accessTokenResponse - if err := json.Unmarshal(body, &token); err != nil { - return "", err + parts := strings.Split(durationStr, ":") + if len(parts) != 2 { + return 0 } - if token.AccessToken == "" { - return "", errors.New("failed to get access token: empty token received") + minutes, err1 := strconv.Atoi(parts[0]) + seconds, err2 := strconv.Atoi(parts[1]) + if err1 != nil || err2 != nil { + return 0 } - // Cache the token - c.cachedToken = token.AccessToken - // Official API returns expires_in in seconds - if expiresIn, ok := token.ExpiresIn.(float64); ok { - c.tokenExpiresAt = time.Now().Add(time.Duration(expiresIn-60) * time.Second) // Refresh 60 seconds before expiry - } - - return token.AccessToken, nil + return (minutes*60 + seconds) * 1000 } func parseSpotifyURI(input string) (spotifyURI, error) { @@ -939,26 +763,6 @@ func parseSpotifyURI(input string) (spotifyURI, error) { return spotifyURI{}, err } - if parsed.Host == "embed.spotify.com" { - if parsed.RawQuery == "" { - return spotifyURI{}, errInvalidSpotifyURL - } - qs, _ := url.ParseQuery(parsed.RawQuery) - embedded := qs.Get("uri") - if embedded == "" { - return spotifyURI{}, errInvalidSpotifyURL - } - return parseSpotifyURI(embedded) - } - - if parsed.Scheme == "" && parsed.Host == "" { - id := strings.Trim(strings.TrimSpace(parsed.Path), "/") - if id == "" { - return spotifyURI{}, errInvalidSpotifyURL - } - return spotifyURI{Type: "playlist", ID: id}, nil - } - if parsed.Host != "open.spotify.com" && parsed.Host != "play.spotify.com" { return spotifyURI{}, errInvalidSpotifyURL } @@ -988,10 +792,6 @@ func parseSpotifyURI(input string) (spotifyURI, error) { } } - if len(parts) == 4 && parts[2] == "playlist" { - return spotifyURI{Type: "playlist", ID: parts[3]}, nil - } - if len(parts) >= 3 && parts[0] == "artist" { if len(parts) >= 3 && parts[2] == "discography" { discType := "all" @@ -1020,171 +820,11 @@ func cleanPathParts(path string) []string { return parts } -func stripLocaleParam(raw string) string { - if raw == "" { - return "" - } - if idx := strings.Index(raw, "&locale="); idx != -1 { - return raw[:idx] - } - if idx := strings.Index(raw, "?locale="); idx != -1 { - return raw[:idx] - } - return raw +func parseArtistIDsFromString(artists string) []string { + + return []string{} } -func firstImageURL(images []image) string { - if len(images) == 0 { - return "" - } - return images[0].URL -} - -func joinArtists(artists []artist) string { - if len(artists) == 0 { - return "" - } - names := make([]string, 0, len(artists)) - for _, a := range artists { - if a.Name != "" { - names = append(names, a.Name) - } - } - return strings.Join(names, ", ") -} - -func firstNonEmpty(values ...string) string { - for _, v := range values { - if strings.TrimSpace(v) != "" { - return v - } - } - return "" -} - -func parseRetryAfter(value string) time.Duration { - if value == "" { - return 5 * time.Second - } - secs, err := strconv.Atoi(strings.TrimSpace(value)) - if err != nil { - return 5 * time.Second - } - return time.Duration(secs+1) * time.Second -} - -func sleepWithContext(ctx context.Context, d time.Duration) error { - if d <= 0 { - return nil - } - timer := time.NewTimer(d) - defer timer.Stop() - select { - case <-ctx.Done(): - return ctx.Err() - case <-timer.C: - return nil - } -} - -func maxInt(a, b int) int { - if a > b { - return a - } - return b -} - - -// SearchResult represents a single search result item -type SearchResult struct { - ID string `json:"id"` - Name string `json:"name"` - Type string `json:"type"` // track, album, artist, playlist - Artists string `json:"artists,omitempty"` - AlbumName string `json:"album_name,omitempty"` - Images string `json:"images"` - ReleaseDate string `json:"release_date,omitempty"` - ExternalURL string `json:"external_urls"` - Duration int `json:"duration_ms,omitempty"` - TotalTracks int `json:"total_tracks,omitempty"` - Owner string `json:"owner,omitempty"` // for playlists -} - -// SearchResponse contains search results grouped by type -type SearchResponse struct { - Tracks []SearchResult `json:"tracks"` - Albums []SearchResult `json:"albums"` - Artists []SearchResult `json:"artists"` - Playlists []SearchResult `json:"playlists"` -} - -// Spotify API search response structures -type searchTracksResponse struct { - Tracks struct { - Items []struct { - ID string `json:"id"` - Name string `json:"name"` - DurationMS int `json:"duration_ms"` - ExternalURL externalURL `json:"external_urls"` - Artists []artist `json:"artists"` - Album struct { - ID string `json:"id"` - Name string `json:"name"` - Images []image `json:"images"` - ReleaseDate string `json:"release_date"` - ExternalURL externalURL `json:"external_urls"` - } `json:"album"` - } `json:"items"` - } `json:"tracks"` -} - -type searchAlbumsResponse struct { - Albums struct { - Items []struct { - ID string `json:"id"` - Name string `json:"name"` - AlbumType string `json:"album_type"` - TotalTracks int `json:"total_tracks"` - ReleaseDate string `json:"release_date"` - Images []image `json:"images"` - ExternalURL externalURL `json:"external_urls"` - Artists []artist `json:"artists"` - } `json:"items"` - } `json:"albums"` -} - -type searchArtistsResponse struct { - Artists struct { - Items []struct { - ID string `json:"id"` - Name string `json:"name"` - Images []image `json:"images"` - ExternalURL externalURL `json:"external_urls"` - Followers struct { - Total int `json:"total"` - } `json:"followers"` - } `json:"items"` - } `json:"artists"` -} - -type searchPlaylistsResponse struct { - Playlists struct { - Items []struct { - ID string `json:"id"` - Name string `json:"name"` - Images []image `json:"images"` - ExternalURL externalURL `json:"external_urls"` - Owner struct { - DisplayName string `json:"display_name"` - } `json:"owner"` - Tracks struct { - Total int `json:"total"` - } `json:"tracks"` - } `json:"items"` - } `json:"playlists"` -} - -// Search performs a search on Spotify and returns results for tracks, albums, artists, and playlists func (c *SpotifyMetadataClient) Search(ctx context.Context, query string, limit int) (*SearchResponse, error) { if query == "" { return nil, errors.New("search query cannot be empty") @@ -1194,14 +834,13 @@ func (c *SpotifyMetadataClient) Search(ctx context.Context, query string, limit limit = 50 } - token, err := c.getAccessToken(ctx) - if err != nil { - return nil, fmt.Errorf("failed to get access token: %w", err) - } - - // URL encode the query encodedQuery := url.QueryEscape(query) - searchURL := fmt.Sprintf("https://api.spotify.com/v1/search?q=%s&type=track,album,artist,playlist&limit=%d", encodedQuery, limit) + searchURL := fmt.Sprintf("%s/search?q=%s&limit=%d&offset=0", apiBaseURL, encodedQuery, limit) + + var apiResp apiSearchResponse + if err := c.getJSON(ctx, searchURL, &apiResp); err != nil { + return nil, fmt.Errorf("search failed: %w", err) + } response := &SearchResponse{ Tracks: make([]SearchResult, 0), @@ -1210,83 +849,60 @@ func (c *SpotifyMetadataClient) Search(ctx context.Context, query string, limit Playlists: make([]SearchResult, 0), } - // Fetch tracks - var tracksResp searchTracksResponse - if err := c.getJSON(ctx, searchURL, token, &tracksResp); err != nil { - return nil, fmt.Errorf("search failed: %w", err) - } - - for _, item := range tracksResp.Tracks.Items { + for _, item := range apiResp.Results.Tracks { response.Tracks = append(response.Tracks, SearchResult{ ID: item.ID, Name: item.Name, Type: "track", - Artists: joinArtists(item.Artists), - AlbumName: item.Album.Name, - Images: firstImageURL(item.Album.Images), - ReleaseDate: item.Album.ReleaseDate, - ExternalURL: item.ExternalURL.Spotify, - Duration: item.DurationMS, + Artists: item.Artists, + AlbumName: item.Album, + Images: item.Cover, + ExternalURL: fmt.Sprintf("https://open.spotify.com/track/%s", item.ID), + Duration: parseDuration(item.Duration), }) } - // Fetch albums - var albumsResp searchAlbumsResponse - if err := c.getJSON(ctx, searchURL, token, &albumsResp); err == nil { - for _, item := range albumsResp.Albums.Items { - response.Albums = append(response.Albums, SearchResult{ - ID: item.ID, - Name: item.Name, - Type: "album", - Artists: joinArtists(item.Artists), - Images: firstImageURL(item.Images), - ReleaseDate: item.ReleaseDate, - ExternalURL: item.ExternalURL.Spotify, - TotalTracks: item.TotalTracks, - }) - } + for _, item := range apiResp.Results.Albums { + response.Albums = append(response.Albums, SearchResult{ + ID: item.ID, + Name: item.Name, + Type: "album", + Artists: item.Artists, + Images: item.Cover, + ReleaseDate: fmt.Sprintf("%d", item.Year), + ExternalURL: fmt.Sprintf("https://open.spotify.com/album/%s", item.ID), + }) } - // Fetch artists - var artistsResp searchArtistsResponse - if err := c.getJSON(ctx, searchURL, token, &artistsResp); err == nil { - for _, item := range artistsResp.Artists.Items { - response.Artists = append(response.Artists, SearchResult{ - ID: item.ID, - Name: item.Name, - Type: "artist", - Images: firstImageURL(item.Images), - ExternalURL: item.ExternalURL.Spotify, - }) - } + for _, item := range apiResp.Results.Artists { + response.Artists = append(response.Artists, SearchResult{ + ID: item.ID, + Name: item.Name, + Type: "artist", + Images: item.Cover, + ExternalURL: fmt.Sprintf("https://open.spotify.com/artist/%s", item.ID), + }) } - // Fetch playlists - var playlistsResp searchPlaylistsResponse - if err := c.getJSON(ctx, searchURL, token, &playlistsResp); err == nil { - for _, item := range playlistsResp.Playlists.Items { - response.Playlists = append(response.Playlists, SearchResult{ - ID: item.ID, - Name: item.Name, - Type: "playlist", - Images: firstImageURL(item.Images), - ExternalURL: item.ExternalURL.Spotify, - Owner: item.Owner.DisplayName, - TotalTracks: item.Tracks.Total, - }) - } + for _, item := range apiResp.Results.Playlists { + response.Playlists = append(response.Playlists, SearchResult{ + ID: item.ID, + Name: item.Name, + Type: "playlist", + Images: item.Cover, + Owner: item.Owner, + ExternalURL: fmt.Sprintf("https://open.spotify.com/playlist/%s", item.ID), + }) } return response, nil } -// SearchSpotify is a convenience wrapper for the Search method func SearchSpotify(ctx context.Context, query string, limit int) (*SearchResponse, error) { client := NewSpotifyMetadataClient() return client.Search(ctx, query, limit) } -// SearchByType searches for a specific type (track, album, artist, playlist) with offset support func (c *SpotifyMetadataClient) SearchByType(ctx context.Context, query string, searchType string, limit int, offset int) ([]SearchResult, error) { if query == "" { return nil, errors.New("search query cannot be empty") @@ -1296,84 +912,65 @@ func (c *SpotifyMetadataClient) SearchByType(ctx context.Context, query string, limit = 50 } - if offset < 0 || offset > 1000 { + if offset < 0 { offset = 0 } - token, err := c.getAccessToken(ctx) - if err != nil { - return nil, fmt.Errorf("failed to get access token: %w", err) - } - encodedQuery := url.QueryEscape(query) - searchURL := fmt.Sprintf("https://api.spotify.com/v1/search?q=%s&type=%s&limit=%d&offset=%d", encodedQuery, searchType, limit, offset) + searchURL := fmt.Sprintf("%s/search?q=%s&limit=%d&offset=%d", apiBaseURL, encodedQuery, limit, offset) + + var apiResp apiSearchResponse + if err := c.getJSON(ctx, searchURL, &apiResp); err != nil { + return nil, fmt.Errorf("search failed: %w", err) + } results := make([]SearchResult, 0) switch searchType { case "track": - var resp searchTracksResponse - if err := c.getJSON(ctx, searchURL, token, &resp); err != nil { - return nil, fmt.Errorf("search failed: %w", err) - } - for _, item := range resp.Tracks.Items { + for _, item := range apiResp.Results.Tracks { results = append(results, SearchResult{ ID: item.ID, Name: item.Name, Type: "track", - Artists: joinArtists(item.Artists), - AlbumName: item.Album.Name, - Images: firstImageURL(item.Album.Images), - ReleaseDate: item.Album.ReleaseDate, - ExternalURL: item.ExternalURL.Spotify, - Duration: item.DurationMS, + Artists: item.Artists, + AlbumName: item.Album, + Images: item.Cover, + ExternalURL: fmt.Sprintf("https://open.spotify.com/track/%s", item.ID), + Duration: parseDuration(item.Duration), }) } case "album": - var resp searchAlbumsResponse - if err := c.getJSON(ctx, searchURL, token, &resp); err != nil { - return nil, fmt.Errorf("search failed: %w", err) - } - for _, item := range resp.Albums.Items { + for _, item := range apiResp.Results.Albums { results = append(results, SearchResult{ ID: item.ID, Name: item.Name, Type: "album", - Artists: joinArtists(item.Artists), - Images: firstImageURL(item.Images), - ReleaseDate: item.ReleaseDate, - ExternalURL: item.ExternalURL.Spotify, - TotalTracks: item.TotalTracks, + Artists: item.Artists, + Images: item.Cover, + ReleaseDate: fmt.Sprintf("%d", item.Year), + ExternalURL: fmt.Sprintf("https://open.spotify.com/album/%s", item.ID), }) } case "artist": - var resp searchArtistsResponse - if err := c.getJSON(ctx, searchURL, token, &resp); err != nil { - return nil, fmt.Errorf("search failed: %w", err) - } - for _, item := range resp.Artists.Items { + for _, item := range apiResp.Results.Artists { results = append(results, SearchResult{ ID: item.ID, Name: item.Name, Type: "artist", - Images: firstImageURL(item.Images), - ExternalURL: item.ExternalURL.Spotify, + Images: item.Cover, + ExternalURL: fmt.Sprintf("https://open.spotify.com/artist/%s", item.ID), }) } case "playlist": - var resp searchPlaylistsResponse - if err := c.getJSON(ctx, searchURL, token, &resp); err != nil { - return nil, fmt.Errorf("search failed: %w", err) - } - for _, item := range resp.Playlists.Items { + for _, item := range apiResp.Results.Playlists { results = append(results, SearchResult{ ID: item.ID, Name: item.Name, Type: "playlist", - Images: firstImageURL(item.Images), - ExternalURL: item.ExternalURL.Spotify, - Owner: item.Owner.DisplayName, - TotalTracks: item.Tracks.Total, + Images: item.Cover, + Owner: item.Owner, + ExternalURL: fmt.Sprintf("https://open.spotify.com/playlist/%s", item.ID), }) } default: @@ -1383,7 +980,6 @@ func (c *SpotifyMetadataClient) SearchByType(ctx context.Context, query string, return results, nil } -// SearchSpotifyByType is a convenience wrapper for SearchByType func SearchSpotifyByType(ctx context.Context, query string, searchType string, limit int, offset int) ([]SearchResult, error) { client := NewSpotifyMetadataClient() return client.SearchByType(ctx, query, searchType, limit, offset) diff --git a/backend/tidal.go b/backend/tidal.go index cedb560..74c63d5 100644 --- a/backend/tidal.go +++ b/backend/tidal.go @@ -62,7 +62,6 @@ type TidalAPIResponse struct { OriginalTrackURL string `json:"OriginalTrackUrl"` } -// TidalAPIResponseV2 is the new API response format (version 2.0) type TidalAPIResponseV2 struct { Version string `json:"version"` Data struct { @@ -83,7 +82,6 @@ type TidalAPIInfo struct { Status string `json:"status"` } -// TidalBTSManifest is the BTS (application/vnd.tidal.bts) manifest format type TidalBTSManifest struct { MimeType string `json:"mimeType"` Codecs string `json:"codecs"` @@ -95,7 +93,6 @@ func NewTidalDownloader(apiURL string) *TidalDownloader { clientID, _ := base64.StdEncoding.DecodeString("NkJEU1JkcEs5aHFFQlRnVQ==") clientSecret, _ := base64.StdEncoding.DecodeString("eGV1UG1ZN25icFo5SUliTEFjUTkzc2hrYTFWTmhlVUFxTjZJY3N6alRHOD0=") - // If apiURL is empty, try to get first available API if apiURL == "" { downloader := &TidalDownloader{ client: &http.Client{ @@ -108,7 +105,6 @@ func NewTidalDownloader(apiURL string) *TidalDownloader { apiURL: "", } - // Try to get available APIs apis, err := downloader.GetAvailableAPIs() if err == nil && len(apis) > 0 { apiURL = apis[0] @@ -128,16 +124,16 @@ func NewTidalDownloader(apiURL string) *TidalDownloader { } func (t *TidalDownloader) GetAvailableAPIs() ([]string, error) { - // Hardcoded API URLs (base64 encoded for obfuscation) + encodedAPIs := []string{ - "dm9nZWwucXFkbC5zaXRl", // API 1 - "bWF1cy5xcWRsLnNpdGU=", // API 2 - "aHVuZC5xcWRsLnNpdGU=", // API 3 - "a2F0emUucXFkbC5zaXRl", // API 4 - "d29sZi5xcWRsLnNpdGU=", // API 5 - "dGlkYWwua2lub3BsdXMub25saW5l", // API 6 - "dGlkYWwtYXBpLmJpbmltdW0ub3Jn", // API 7 - "dHJpdG9uLnNxdWlkLnd0Zg==", // API 8 + "dm9nZWwucXFkbC5zaXRl", + "bWF1cy5xcWRsLnNpdGU=", + "aHVuZC5xcWRsLnNpdGU=", + "a2F0emUucXFkbC5zaXRl", + "d29sZi5xcWRsLnNpdGU=", + "dGlkYWwua2lub3BsdXMub25saW5l", + "dGlkYWwtYXBpLmJpbmltdW0ub3Jn", + "dHJpdG9uLnNxdWlkLnd0Zg==", } var apis []string @@ -155,7 +151,6 @@ func (t *TidalDownloader) GetAvailableAPIs() ([]string, error) { func (t *TidalDownloader) GetAccessToken() (string, error) { data := fmt.Sprintf("client_id=%s&grant_type=client_credentials", t.clientID) - // Decode base64 API URL authURL, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9hdXRoLnRpZGFsLmNvbS92MS9vYXV0aDIvdG9rZW4=") req, err := http.NewRequest("POST", string(authURL), strings.NewReader(data)) if err != nil { @@ -186,19 +181,16 @@ func (t *TidalDownloader) GetAccessToken() (string, error) { return result.AccessToken, nil } -// SearchTracks searches for tracks on Tidal with configurable limit func (t *TidalDownloader) SearchTracks(query string) (*TidalSearchResponse, error) { - return t.SearchTracksWithLimit(query, 50) // Default to 50 results for better matching + return t.SearchTracksWithLimit(query, 50) } -// SearchTracksWithLimit searches for tracks on Tidal with a specific limit func (t *TidalDownloader) SearchTracksWithLimit(query string, limit int) (*TidalSearchResponse, error) { token, err := t.GetAccessToken() if err != nil { return nil, fmt.Errorf("failed to get access token: %w", err) } - // Decode base64 API URL and encode the query parameter searchBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9hcGkudGlkYWwuY29tL3YxL3NlYXJjaC90cmFja3M/cXVlcnk9") searchURL := fmt.Sprintf("%s%s&limit=%d&offset=0&countryCode=US", string(searchBase), url.QueryEscape(query), limit) @@ -228,39 +220,26 @@ func (t *TidalDownloader) SearchTracksWithLimit(query string, limit int) (*Tidal return &result, nil } -// SearchTrackByMetadata searches for a track using artist name and track name -// It tries multiple search strategies including romaji conversion for Japanese text -// Now accepts ISRC for exact matching func (t *TidalDownloader) SearchTrackByMetadata(trackName, artistName string, expectedDuration int) (*TidalTrack, error) { - return t.SearchTrackByMetadataWithISRC(trackName, artistName, "", expectedDuration) -} -// SearchTrackByMetadataWithISRC searches for a track with ISRC matching priority -func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, spotifyISRC string, expectedDuration int) (*TidalTrack, error) { - // Build search queries - multiple strategies queries := []string{} - // Strategy 1: Artist + Track name (original) if artistName != "" && trackName != "" { queries = append(queries, artistName+" "+trackName) } - // Strategy 2: Track name only (sometimes works better) if trackName != "" { queries = append(queries, trackName) } - // Strategy 3: Romaji versions if Japanese detected if ContainsJapanese(trackName) || ContainsJapanese(artistName) { - // Convert to romaji (hiragana/katakana only, kanji stays) + romajiTrack := JapaneseToRomaji(trackName) romajiArtist := JapaneseToRomaji(artistName) - // Clean and remove ALL non-ASCII characters (including kanji) cleanRomajiTrack := cleanToASCII(romajiTrack) cleanRomajiArtist := cleanToASCII(romajiArtist) - // Artist + Track romaji (cleaned to ASCII only) if cleanRomajiArtist != "" && cleanRomajiTrack != "" { romajiQuery := cleanRomajiArtist + " " + cleanRomajiTrack if !containsQuery(queries, romajiQuery) { @@ -269,14 +248,12 @@ func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, s } } - // Track romaji only (cleaned) if cleanRomajiTrack != "" && cleanRomajiTrack != trackName { if !containsQuery(queries, cleanRomajiTrack) { queries = append(queries, cleanRomajiTrack) } } - // Also try with partial romaji (artist + cleaned track) if artistName != "" && cleanRomajiTrack != "" { partialQuery := artistName + " " + cleanRomajiTrack if !containsQuery(queries, partialQuery) { @@ -285,7 +262,6 @@ func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, s } } - // Strategy 4: Artist only as last resort if artistName != "" { artistOnly := cleanToASCII(JapaneseToRomaji(artistName)) if artistOnly != "" && !containsQuery(queries, artistOnly) { @@ -293,7 +269,6 @@ func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, s } } - // Collect all search results from all queries var allTracks []TidalTrack searchedQueries := make(map[string]bool) @@ -306,7 +281,7 @@ func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, s fmt.Printf("Searching Tidal for: %s\n", cleanQuery) - result, err := t.SearchTracksWithLimit(cleanQuery, 100) // Get more results + result, err := t.SearchTracksWithLimit(cleanQuery, 100) if err != nil { fmt.Printf("Search error for '%s': %v\n", cleanQuery, err) continue @@ -322,40 +297,9 @@ func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, s return nil, fmt.Errorf("no tracks found for any search query") } - // Priority 1: Match by ISRC (exact match) - if spotifyISRC != "" { - fmt.Printf("Looking for ISRC match: %s\n", spotifyISRC) - for i := range allTracks { - track := &allTracks[i] - if track.ISRC == spotifyISRC { - fmt.Printf("✓ ISRC match found: %s - %s (ISRC: %s, Quality: %s)\n", - track.Artist.Name, track.Title, track.ISRC, track.AudioQuality) - return track, nil - } - } - fmt.Printf("No exact ISRC match found, trying other matching methods...\n") - } - - // If ISRC was provided but no match found, return error - don't download wrong track - if spotifyISRC != "" { - fmt.Printf("✗ No ISRC match found for: %s\n", spotifyISRC) - fmt.Printf(" Available ISRCs from search results:\n") - // Show first 5 results for debugging - for i, track := range allTracks { - if i >= 5 { - fmt.Printf(" ... and %d more results\n", len(allTracks)-5) - break - } - fmt.Printf(" - %s - %s (ISRC: %s)\n", track.Artist.Name, track.Title, track.ISRC) - } - return nil, fmt.Errorf("ISRC mismatch: no track found with ISRC %s on Tidal", spotifyISRC) - } - - // Only proceed without ISRC matching if no ISRC was provided - // Priority 2: Match by duration (within tolerance) + prefer best quality var bestMatch *TidalTrack if expectedDuration > 0 { - tolerance := 3 // 3 seconds tolerance + tolerance := 3 var durationMatches []*TidalTrack for i := range allTracks { @@ -370,7 +314,7 @@ func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, s } if len(durationMatches) > 0 { - // Find best quality among duration matches + bestMatch = durationMatches[0] for _, track := range durationMatches { for _, tag := range track.MediaMetadata.Tags { @@ -386,7 +330,6 @@ func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, s } } - // Priority 3: Just take the best quality from first results (only when no ISRC provided) bestMatch = &allTracks[0] for i := range allTracks { track := &allTracks[i] @@ -397,7 +340,7 @@ func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, s } } if bestMatch != &allTracks[0] { - break // Found HIRES_LOSSLESS + break } } @@ -407,7 +350,6 @@ func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, s return bestMatch, nil } -// containsQuery checks if a query already exists in the list func containsQuery(queries []string, query string) bool { for _, q := range queries { if q == query { @@ -418,7 +360,7 @@ func containsQuery(queries []string, query string) bool { } func (t *TidalDownloader) GetTidalURLFromSpotify(spotifyTrackID string) (string, error) { - // Decode base64 API URL + spotifyBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9vcGVuLnNwb3RpZnkuY29tL3RyYWNrLw==") spotifyURL := fmt.Sprintf("%s%s", string(spotifyBase), spotifyTrackID) @@ -462,15 +404,12 @@ func (t *TidalDownloader) GetTidalURLFromSpotify(spotifyTrackID string) (string, } func (t *TidalDownloader) GetTrackIDFromURL(tidalURL string) (int64, error) { - // Extract track ID from Tidal URL - // Format: https://listen.tidal.com/track/441821360 - // or: https://tidal.com/browse/track/123456789 + parts := strings.Split(tidalURL, "/track/") if len(parts) < 2 { return 0, fmt.Errorf("invalid tidal URL format") } - // Get the track ID part and remove any query parameters trackIDStr := strings.Split(parts[1], "?")[0] trackIDStr = strings.TrimSpace(trackIDStr) @@ -489,7 +428,6 @@ func (t *TidalDownloader) GetTrackInfoByID(trackID int64) (*TidalTrack, error) { return nil, fmt.Errorf("failed to get access token: %w", err) } - // Decode base64 API URL trackBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9hcGkudGlkYWwuY29tL3YxL3RyYWNrcy8=") trackURL := fmt.Sprintf("%s%d?countryCode=US", string(trackBase), trackID) @@ -538,24 +476,21 @@ func (t *TidalDownloader) GetDownloadURL(trackID int64, quality string) (string, return "", fmt.Errorf("API returned status code: %d", resp.StatusCode) } - // Read body to try both formats body, err := io.ReadAll(resp.Body) if err != nil { fmt.Printf("✗ Failed to read response body: %v\n", err) return "", fmt.Errorf("failed to read response: %w", err) } - // Try v2 format first (object with manifest) var v2Response TidalAPIResponseV2 if err := json.Unmarshal(body, &v2Response); err == nil && v2Response.Data.Manifest != "" { fmt.Println("✓ Tidal manifest found (v2 API)") return "MANIFEST:" + v2Response.Data.Manifest, nil } - // Fallback to v1 format (array with OriginalTrackUrl) var apiResponses []TidalAPIResponse if err := json.Unmarshal(body, &apiResponses); err != nil { - // Truncate body for error message (max 200 chars) + bodyStr := string(body) if len(bodyStr) > 200 { bodyStr = bodyStr[:200] + "..." @@ -582,7 +517,7 @@ func (t *TidalDownloader) GetDownloadURL(trackID int64, quality string) (string, func (t *TidalDownloader) DownloadAlbumArt(albumID string) ([]byte, error) { albumID = strings.ReplaceAll(albumID, "-", "/") - // Decode base64 API URL + imageBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9yZXNvdXJjZXMudGlkYWwuY29tL2ltYWdlcy8=") artURL := fmt.Sprintf("%s%s/1280x1280.jpg", string(imageBase), albumID) @@ -600,7 +535,7 @@ func (t *TidalDownloader) DownloadAlbumArt(albumID string) ([]byte, error) { } func (t *TidalDownloader) DownloadFile(url, filepath string) error { - // Check if this is a manifest-based download + if strings.HasPrefix(url, "MANIFEST:") { return t.DownloadFromManifest(strings.TrimPrefix(url, "MANIFEST:"), filepath) } @@ -622,33 +557,28 @@ func (t *TidalDownloader) DownloadFile(url, filepath string) error { } defer out.Close() - // 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)) fmt.Println("Download complete") return nil } -// DownloadFromManifest downloads audio from manifest (supports BTS and DASH formats) func (t *TidalDownloader) DownloadFromManifest(manifestB64, outputPath string) error { directURL, initURL, mediaURLs, err := parseManifest(manifestB64) if err != nil { return fmt.Errorf("failed to parse manifest: %w", err) } - // Create HTTP client with longer timeout client := &http.Client{ Timeout: 120 * time.Second, } - // If we have a direct URL (BTS format), download directly if directURL != "" { fmt.Println("Downloading file...") @@ -668,7 +598,6 @@ func (t *TidalDownloader) DownloadFromManifest(manifestB64, outputPath string) e } defer out.Close() - // Use progress writer to track download pw := NewProgressWriter(out) _, err = io.Copy(pw, resp.Body) if err != nil { @@ -680,17 +609,14 @@ func (t *TidalDownloader) DownloadFromManifest(manifestB64, outputPath string) e return nil } - // DASH format - download segments to temporary M4A file, then remux to FLAC fmt.Printf("Downloading %d segments...\n", len(mediaURLs)+1) - // Create temporary file for M4A segments tempPath := outputPath + ".m4a.tmp" out, err := os.Create(tempPath) if err != nil { return fmt.Errorf("failed to create temp file: %w", err) } - // Download initialization segment fmt.Print("Downloading init segment... ") resp, err := client.Get(initURL) if err != nil { @@ -713,7 +639,6 @@ func (t *TidalDownloader) DownloadFromManifest(manifestB64, outputPath string) e } fmt.Println("OK") - // Download media segments with progress tracking totalSegments := len(mediaURLs) var totalBytes int64 lastTime := time.Now() @@ -740,12 +665,11 @@ func (t *TidalDownloader) DownloadFromManifest(manifestB64, outputPath string) e return fmt.Errorf("failed to write segment %d: %w", i+1, err) } - // Calculate speed and update progress for frontend mbDownloaded := float64(totalBytes) / (1024 * 1024) now := time.Now() timeDiff := now.Sub(lastTime).Seconds() var speedMBps float64 - if timeDiff > 0.1 { // Update speed every 100ms + if timeDiff > 0.1 { bytesDiff := float64(totalBytes - lastBytes) speedMBps = (bytesDiff / (1024 * 1024)) / timeDiff SetDownloadSpeed(speedMBps) @@ -754,38 +678,42 @@ func (t *TidalDownloader) DownloadFromManifest(manifestB64, outputPath string) e } SetDownloadProgress(mbDownloaded) - // Show progress with size in terminal fmt.Printf("\rDownloading: %.2f MB (%d/%d segments)", mbDownloaded, i+1, totalSegments) } - // Close temp file before remuxing out.Close() - // Get temp file size tempInfo, _ := os.Stat(tempPath) fmt.Printf("\rDownloaded: %.2f MB (Complete) \n", float64(tempInfo.Size())/(1024*1024)) - // Remux M4A to FLAC using ffmpeg - // DASH segments are in fMP4 container with FLAC codec, need to extract to native FLAC fmt.Println("Converting to FLAC...") - cmd := exec.Command("ffmpeg", "-y", "-i", tempPath, "-vn", "-c:a", "flac", outputPath) + ffmpegPath, err := GetFFmpegPath() + if err != nil { + return fmt.Errorf("ffmpeg not found: %w", err) + } + + if err := ValidateExecutable(ffmpegPath); err != nil { + return fmt.Errorf("invalid ffmpeg executable: %w", err) + } + + cmd := exec.Command(ffmpegPath, "-y", "-i", tempPath, "-vn", "-c:a", "flac", outputPath) + setHideWindow(cmd) var stderr strings.Builder cmd.Stderr = &stderr if err := cmd.Run(); err != nil { - // If ffmpeg fails, try to keep the M4A file for debugging + m4aPath := strings.TrimSuffix(outputPath, ".flac") + ".m4a" os.Rename(tempPath, m4aPath) return fmt.Errorf("ffmpeg conversion failed (M4A saved as %s): %w - %s", m4aPath, err, stderr.String()) } - // Remove temp file os.Remove(tempPath) fmt.Println("Download complete") return nil } -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) { +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, spotifyTotalDiscs int, spotifyCopyright, spotifyPublisher, spotifyURL string) (string, error) { if outputDir != "." { if err := os.MkdirAll(outputDir, 0755); err != nil { return "", fmt.Errorf("directory error: %w", err) @@ -794,13 +722,11 @@ func (t *TidalDownloader) DownloadByURL(tidalURL, outputDir, quality, filenameFo fmt.Printf("Using Tidal URL: %s\n", tidalURL) - // Extract track ID from URL trackID, err := t.GetTrackIDFromURL(tidalURL) if err != nil { return "", err } - // Get track info by ID trackInfo, err := t.GetTrackInfoByID(trackID) if err != nil { return "", err @@ -810,24 +736,15 @@ func (t *TidalDownloader) DownloadByURL(tidalURL, outputDir, quality, filenameFo return "", fmt.Errorf("no track ID found") } - // All metadata from Spotify - no fallback to Tidal artistName := spotifyArtistName trackTitle := spotifyTrackName albumTitle := spotifyAlbumName - // Sanitize for filename only (not for metadata) artistNameForFile := sanitizeFilename(artistName) trackTitleForFile := sanitizeFilename(trackTitle) albumTitleForFile := sanitizeFilename(albumTitle) albumArtistForFile := sanitizeFilename(spotifyAlbumArtist) - // 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) - return "EXISTS:" + existingFile, nil - } - - // Build filename based on format settings (use sanitized versions for filename) filename := buildTidalFilename(trackTitleForFile, artistNameForFile, albumTitleForFile, albumArtistForFile, spotifyReleaseDate, trackInfo.TrackNumber, spotifyDiscNumber, filenameFormat, includeTrackNumber, position, useAlbumTrackNumber) outputFilename := filepath.Join(outputDir, filename) @@ -849,7 +766,7 @@ func (t *TidalDownloader) DownloadByURL(tidalURL, outputDir, quality, filenameFo fmt.Println("Adding metadata...") coverPath := "" - // Use Spotify cover URL (with max resolution if enabled) - all metadata from Spotify + if spotifyCoverURL != "" { coverPath = outputFilename + ".cover.jpg" coverClient := NewCoverClient() @@ -862,25 +779,24 @@ func (t *TidalDownloader) DownloadByURL(tidalURL, outputDir, quality, filenameFo } } - // 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 + if trackNumberToEmbed == 0 { + trackNumberToEmbed = 1 } - // ALL metadata from Spotify metadata := Metadata{ Title: trackTitle, Artist: artistName, Album: albumTitle, AlbumArtist: spotifyAlbumArtist, - Date: spotifyReleaseDate, // Recorded date (full date YYYY-MM-DD) + Date: spotifyReleaseDate, TrackNumber: trackNumberToEmbed, - TotalTracks: spotifyTotalTracks, // Total tracks in album from Spotify - DiscNumber: spotifyDiscNumber, // Disc number from Spotify - ISRC: spotifyISRC, // ISRC from Spotify + TotalTracks: spotifyTotalTracks, + DiscNumber: spotifyDiscNumber, + TotalDiscs: spotifyTotalDiscs, + URL: spotifyURL, + Copyright: spotifyCopyright, + Publisher: spotifyPublisher, Description: "https://github.com/afkarxyz/SpotiFLAC", } @@ -895,7 +811,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, spotifyAlbumArtist, spotifyReleaseDate string, useAlbumTrackNumber bool, spotifyCoverURL string, embedMaxQualityCover bool, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int, spotifyISRC string) (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, spotifyTotalDiscs int, spotifyCopyright, spotifyPublisher, spotifyURL string) (string, error) { apis, err := t.GetAvailableAPIs() if err != nil { return "", fmt.Errorf("no APIs available for fallback: %w", err) @@ -909,13 +825,11 @@ func (t *TidalDownloader) DownloadByURLWithFallback(tidalURL, outputDir, quality fmt.Printf("Using Tidal URL: %s\n", tidalURL) - // Extract track ID from URL trackID, err := t.GetTrackIDFromURL(tidalURL) if err != nil { return "", err } - // Get track info by ID trackInfo, err := t.GetTrackInfoByID(trackID) if err != nil { return "", err @@ -925,23 +839,15 @@ func (t *TidalDownloader) DownloadByURLWithFallback(tidalURL, outputDir, quality return "", fmt.Errorf("no track ID found") } - // All metadata from Spotify - no fallback to Tidal artistName := spotifyArtistName trackTitle := spotifyTrackName albumTitle := spotifyAlbumName - // Sanitize for filename only (not for metadata) artistNameForFile := sanitizeFilename(artistName) trackTitleForFile := sanitizeFilename(trackTitle) albumTitleForFile := sanitizeFilename(albumTitle) albumArtistForFile := sanitizeFilename(spotifyAlbumArtist) - // 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) - return "EXISTS:" + existingFile, nil - } - filename := buildTidalFilename(trackTitleForFile, artistNameForFile, albumTitleForFile, albumArtistForFile, spotifyReleaseDate, trackInfo.TrackNumber, spotifyDiscNumber, filenameFormat, includeTrackNumber, position, useAlbumTrackNumber) outputFilename := filepath.Join(outputDir, filename) @@ -950,13 +856,11 @@ func (t *TidalDownloader) DownloadByURLWithFallback(tidalURL, outputDir, quality return "EXISTS:" + outputFilename, nil } - // Request download URL from ALL APIs in parallel - use first success successAPI, downloadURL, err := getDownloadURLParallel(apis, trackInfo.ID, quality) if err != nil { return "", err } - // Download the file fmt.Printf("Downloading to: %s\n", outputFilename) downloader := NewTidalDownloader(successAPI) if err := downloader.DownloadFile(downloadURL, outputFilename); err != nil { @@ -966,7 +870,7 @@ func (t *TidalDownloader) DownloadByURLWithFallback(tidalURL, outputDir, quality fmt.Println("Adding metadata...") coverPath := "" - // Use Spotify cover URL (with max resolution if enabled) - all metadata from Spotify + if spotifyCoverURL != "" { coverPath = outputFilename + ".cover.jpg" coverClient := NewCoverClient() @@ -979,23 +883,24 @@ func (t *TidalDownloader) DownloadByURLWithFallback(tidalURL, outputDir, quality } } - // Determine track number to embed - ALL from Spotify trackNumberToEmbed := spotifyTrackNumber - if position > 0 && !useAlbumTrackNumber { - trackNumberToEmbed = position // Use playlist position + if trackNumberToEmbed == 0 { + trackNumberToEmbed = 1 } - // ALL metadata from Spotify metadata := Metadata{ Title: trackTitle, Artist: artistName, Album: albumTitle, AlbumArtist: spotifyAlbumArtist, - Date: spotifyReleaseDate, // Recorded date (full date YYYY-MM-DD) + Date: spotifyReleaseDate, TrackNumber: trackNumberToEmbed, - TotalTracks: spotifyTotalTracks, // Total tracks in album from Spotify - DiscNumber: spotifyDiscNumber, // Disc number from Spotify - ISRC: spotifyISRC, // ISRC from Spotify + TotalTracks: spotifyTotalTracks, + DiscNumber: spotifyDiscNumber, + TotalDiscs: spotifyTotalDiscs, + URL: spotifyURL, + Copyright: spotifyCopyright, + Publisher: spotifyPublisher, Description: "https://github.com/afkarxyz/SpotiFLAC", } @@ -1010,142 +915,16 @@ 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, spotifyAlbumArtist, spotifyReleaseDate string, useAlbumTrackNumber bool, spotifyCoverURL string, embedMaxQualityCover bool, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int, spotifyISRC string) (string, error) { - // Get Tidal URL from Spotify track ID +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, spotifyTotalDiscs int, spotifyCopyright, spotifyPublisher, spotifyURL string) (string, error) { + 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, spotifyAlbumArtist, spotifyReleaseDate, spotifyISRC, 0, outputDir, quality, filenameFormat, includeTrackNumber, position, useAlbumTrackNumber, spotifyCoverURL, embedMaxQualityCover, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks) + return "", fmt.Errorf("songlink couldn't find Tidal URL: %w", err) } - return t.DownloadByURLWithFallback(tidalURL, outputDir, quality, filenameFormat, includeTrackNumber, position, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, useAlbumTrackNumber, spotifyCoverURL, embedMaxQualityCover, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks, spotifyISRC) + return t.DownloadByURLWithFallback(tidalURL, outputDir, quality, filenameFormat, includeTrackNumber, position, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, useAlbumTrackNumber, spotifyCoverURL, embedMaxQualityCover, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks, spotifyTotalDiscs, spotifyCopyright, spotifyPublisher, spotifyURL) } -// 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, 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, 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, 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, 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, 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) - } - } - - // Search for the track with ISRC matching - trackInfo, err := t.SearchTrackByMetadataWithISRC(trackName, artistName, spotifyISRC, expectedDuration) - if err != nil { - return "", fmt.Errorf("search fallback failed: %w", err) - } - - if trackInfo.ID == 0 { - return "", fmt.Errorf("no track ID found from search") - } - - // All metadata from Spotify - no fallback to Tidal - finalArtistName := artistName - finalTrackTitle := trackName - finalAlbumTitle := albumName - - // Sanitize for filename only (not for metadata) - finalArtistNameForFile := sanitizeFilename(finalArtistName) - finalTrackTitleForFile := sanitizeFilename(finalTrackTitle) - finalAlbumTitleForFile := sanitizeFilename(finalAlbumTitle) - finalAlbumArtistForFile := sanitizeFilename(albumArtist) - - // 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(finalTrackTitleForFile, finalArtistNameForFile, finalAlbumTitleForFile, finalAlbumArtistForFile, releaseDate, spotifyTrackNumber, spotifyDiscNumber, filenameFormat, includeTrackNumber, position, useAlbumTrackNumber) - outputFilename := filepath.Join(outputDir, filename) - - if fileInfo, err := os.Stat(outputFilename); err == nil && fileInfo.Size() > 0 { - fmt.Printf("File already exists: %s (%.2f MB)\n", outputFilename, float64(fileInfo.Size())/(1024*1024)) - return "EXISTS:" + outputFilename, nil - } - - // Get download URL - downloadURL, err := t.GetDownloadURL(trackInfo.ID, quality) - if err != nil { - return "", err - } - - fmt.Printf("Downloading to: %s\n", outputFilename) - if err := t.DownloadFile(downloadURL, outputFilename); err != nil { - return "", err - } - - fmt.Println("Adding metadata...") - - coverPath := "" - // Use Spotify cover URL (with max resolution if enabled) - all metadata from Spotify - if spotifyCoverURL != "" { - coverPath = outputFilename + ".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 - 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, - AlbumArtist: albumArtist, - Date: releaseDate, // 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, // ISRC from Spotify - Description: "https://github.com/afkarxyz/SpotiFLAC", - } - - if err := EmbedMetadata(outputFilename, metadata, coverPath); err != nil { - fmt.Printf("Tagging failed: %v\n", err) - } else { - fmt.Println("Metadata saved") - } - - fmt.Println("Done") - fmt.Println("✓ Downloaded successfully from Tidal (via search)") - return outputFilename, nil -} - -// DASH MPD XML structures for parsing manifest type MPD struct { XMLName xml.Name `xml:"MPD"` Period struct { @@ -1166,11 +945,8 @@ type MPD struct { } `xml:"Period"` } -// parseManifest extracts download URL from base64 encoded manifest -// Supports both BTS (JSON) and DASH (XML) formats -// Returns: directURL (for BTS), or initURL + mediaURLs (for DASH) func parseManifest(manifestB64 string) (directURL string, initURL string, mediaURLs []string, err error) { - // Decode base64 manifest + manifestBytes, err := base64.StdEncoding.DecodeString(manifestB64) if err != nil { return "", "", nil, fmt.Errorf("failed to decode manifest: %w", err) @@ -1178,9 +954,8 @@ func parseManifest(manifestB64 string) (directURL string, initURL string, mediaU manifestStr := string(manifestBytes) - // Check if it's BTS format (JSON) or DASH format (XML) if strings.HasPrefix(manifestStr, "{") { - // BTS format - JSON with direct URLs + var btsManifest TidalBTSManifest if err := json.Unmarshal(manifestBytes, &btsManifest); err != nil { return "", "", nil, fmt.Errorf("failed to parse BTS manifest: %w", err) @@ -1194,10 +969,8 @@ func parseManifest(manifestB64 string) (directURL string, initURL string, mediaU return btsManifest.URLs[0], "", nil, nil } - // DASH format - XML with segments fmt.Println("Manifest: DASH format") - // Parse XML var mpd MPD if err := xml.Unmarshal(manifestBytes, &mpd); err != nil { return "", "", nil, fmt.Errorf("failed to parse manifest XML: %w", err) @@ -1208,7 +981,7 @@ func parseManifest(manifestB64 string) (directURL string, initURL string, mediaU mediaTemplate := segTemplate.Media if initURL == "" || mediaTemplate == "" { - // Fallback: try regex extraction + initRe := regexp.MustCompile(`initialization="([^"]+)"`) mediaRe := regexp.MustCompile(`media="([^"]+)"`) @@ -1224,17 +997,14 @@ func parseManifest(manifestB64 string) (directURL string, initURL string, mediaU return "", "", nil, fmt.Errorf("no initialization URL found in manifest") } - // Unescape HTML entities in URLs initURL = strings.ReplaceAll(initURL, "&", "&") mediaTemplate = strings.ReplaceAll(mediaTemplate, "&", "&") - // Calculate segment count from timeline segmentCount := 0 for _, seg := range segTemplate.Timeline.Segments { segmentCount += seg.Repeat + 1 } - // If no segments found via XML, try regex if segmentCount == 0 { segRe := regexp.MustCompile(` 0 { - fmt.Printf("File already exists: %s (%.2f MB)\n", outputFilename, float64(fileInfo.Size())/(1024*1024)) - return "EXISTS:" + outputFilename, nil - } - - // Request download URL from ALL APIs in parallel - use first success - successAPI, downloadURL, err := getDownloadURLParallel(apis, trackInfo.ID, quality) - if err != nil { - return "", err - } - - // Download the file using the successful API - fmt.Printf("Downloading to: %s\n", outputFilename) - downloader := NewTidalDownloader(successAPI) - if err := downloader.DownloadFile(downloadURL, outputFilename); err != nil { - return "", fmt.Errorf("download failed: %w", err) - } - - // Success! Add metadata - fmt.Println("Adding metadata...") - - coverPath := "" - // Use Spotify cover URL (with max resolution if enabled) - all metadata from Spotify - if spotifyCoverURL != "" { - coverPath = outputFilename + ".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 - 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, - AlbumArtist: albumArtist, - Date: releaseDate, // 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, // ISRC from Spotify - Description: "https://github.com/afkarxyz/SpotiFLAC", - } - - if err := EmbedMetadata(outputFilename, metadata, coverPath); err != nil { - fmt.Printf("Tagging failed: %v\n", err) - } else { - fmt.Println("Metadata saved") - } - - fmt.Println("Done") - fmt.Println("✓ Downloaded successfully from Tidal (via search)") - return outputFilename, nil -} - -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, 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, 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, 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, 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, spotifyAlbumArtist, spotifyReleaseDate, useAlbumTrackNumber, spotifyCoverURL, embedMaxQualityCover, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks, spotifyISRC) -} - func buildTidalFilename(title, artist, album, albumArtist, releaseDate string, trackNumber, discNumber 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 } - // Extract year from release date (format: YYYY-MM-DD or YYYY) year := "" if len(releaseDate) >= 4 { year = releaseDate[:4] } - // Check if format is a template (contains {}) if strings.Contains(format, "{") { filename = format filename = strings.ReplaceAll(filename, "{title}", title) @@ -1527,34 +1139,31 @@ func buildTidalFilename(title, artist, album, albumArtist, releaseDate string, t filename = strings.ReplaceAll(filename, "{album_artist}", albumArtist) filename = strings.ReplaceAll(filename, "{year}", year) - // Handle disc number if discNumber > 0 { filename = strings.ReplaceAll(filename, "{disc}", fmt.Sprintf("%d", discNumber)) } else { filename = strings.ReplaceAll(filename, "{disc}", "") } - // 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" + default: 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) } diff --git a/frontend/package.json b/frontend/package.json index 8c049f8..a4f3e89 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -27,7 +27,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "lucide-react": "^0.562.0", - "motion": "^12.23.26", + "motion": "^12.24.12", "next-themes": "^0.4.6", "react": "^19.2.3", "react-dom": "^19.2.3", @@ -44,11 +44,11 @@ "eslint": "^9.39.2", "eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-refresh": "^0.4.26", - "globals": "^16.5.0", + "globals": "^17.0.0", "sharp": "^0.34.5", "tw-animate-css": "^1.4.0", "typescript": "~5.9.3", - "typescript-eslint": "^8.50.1", - "vite": "^7.3.0" + "typescript-eslint": "^8.52.0", + "vite": "^7.3.1" } } diff --git a/frontend/package.json.md5 b/frontend/package.json.md5 index a252310..a438c3e 100644 --- a/frontend/package.json.md5 +++ b/frontend/package.json.md5 @@ -1 +1 @@ -0f9764c2a4597a75120d3e76c32af7a9 \ No newline at end of file +be90455e8d3a26cf5c12d4fa0779bc1a \ No newline at end of file diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index a5f754e..461df76 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -43,7 +43,7 @@ importers: version: 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@tailwindcss/vite': specifier: ^4.1.18 - version: 4.1.18(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)) + version: 4.1.18(vite@7.3.1(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)) class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -54,8 +54,8 @@ importers: specifier: ^0.562.0 version: 0.562.0(react@19.2.3) motion: - specifier: ^12.23.26 - version: 12.23.26(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + specifier: ^12.24.12 + version: 12.24.12(react-dom@19.2.3(react@19.2.3))(react@19.2.3) next-themes: specifier: ^0.4.6 version: 0.4.6(react-dom@19.2.3(react@19.2.3))(react@19.2.3) @@ -89,7 +89,7 @@ importers: version: 19.2.3(@types/react@19.2.7) '@vitejs/plugin-react': specifier: ^5.1.2 - version: 5.1.2(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)) + version: 5.1.2(vite@7.3.1(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)) eslint: specifier: ^9.39.2 version: 9.39.2(jiti@2.6.1) @@ -100,8 +100,8 @@ importers: specifier: ^0.4.26 version: 0.4.26(eslint@9.39.2(jiti@2.6.1)) globals: - specifier: ^16.5.0 - version: 16.5.0 + specifier: ^17.0.0 + version: 17.0.0 sharp: specifier: ^0.34.5 version: 0.34.5 @@ -112,11 +112,11 @@ importers: specifier: ~5.9.3 version: 5.9.3 typescript-eslint: - specifier: ^8.50.1 - version: 8.50.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + specifier: ^8.52.0 + version: 8.52.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) vite: - specifier: ^7.3.0 - version: 7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2) + specifier: ^7.3.1 + version: 7.3.1(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2) packages: @@ -203,8 +203,8 @@ packages: resolution: {integrity: sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==} engines: {node: '>=6.9.0'} - '@emnapi/runtime@1.7.1': - resolution: {integrity: sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA==} + '@emnapi/runtime@1.8.1': + resolution: {integrity: sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==} '@esbuild/aix-ppc64@0.27.2': resolution: {integrity: sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==} @@ -362,8 +362,8 @@ packages: cpu: [x64] os: [win32] - '@eslint-community/eslint-utils@4.9.0': - resolution: {integrity: sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==} + '@eslint-community/eslint-utils@4.9.1': + resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} peerDependencies: eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 @@ -1026,113 +1026,128 @@ packages: '@rolldown/pluginutils@1.0.0-beta.53': resolution: {integrity: sha512-vENRlFU4YbrwVqNDZ7fLvy+JR1CRkyr01jhSiDpE1u6py3OMzQfztQU2jxykW3ALNxO4kSlqIDeYyD0Y9RcQeQ==} - '@rollup/rollup-android-arm-eabi@4.54.0': - resolution: {integrity: sha512-OywsdRHrFvCdvsewAInDKCNyR3laPA2mc9bRYJ6LBp5IyvF3fvXbbNR0bSzHlZVFtn6E0xw2oZlyjg4rKCVcng==} + '@rollup/rollup-android-arm-eabi@4.55.1': + resolution: {integrity: sha512-9R0DM/ykwfGIlNu6+2U09ga0WXeZ9MRC2Ter8jnz8415VbuIykVuc6bhdrbORFZANDmTDvq26mJrEVTl8TdnDg==} cpu: [arm] os: [android] - '@rollup/rollup-android-arm64@4.54.0': - resolution: {integrity: sha512-Skx39Uv+u7H224Af+bDgNinitlmHyQX1K/atIA32JP3JQw6hVODX5tkbi2zof/E69M1qH2UoN3Xdxgs90mmNYw==} + '@rollup/rollup-android-arm64@4.55.1': + resolution: {integrity: sha512-eFZCb1YUqhTysgW3sj/55du5cG57S7UTNtdMjCW7LwVcj3dTTcowCsC8p7uBdzKsZYa8J7IDE8lhMI+HX1vQvg==} cpu: [arm64] os: [android] - '@rollup/rollup-darwin-arm64@4.54.0': - resolution: {integrity: sha512-k43D4qta/+6Fq+nCDhhv9yP2HdeKeP56QrUUTW7E6PhZP1US6NDqpJj4MY0jBHlJivVJD5P8NxrjuobZBJTCRw==} + '@rollup/rollup-darwin-arm64@4.55.1': + resolution: {integrity: sha512-p3grE2PHcQm2e8PSGZdzIhCKbMCw/xi9XvMPErPhwO17vxtvCN5FEA2mSLgmKlCjHGMQTP6phuQTYWUnKewwGg==} cpu: [arm64] os: [darwin] - '@rollup/rollup-darwin-x64@4.54.0': - resolution: {integrity: sha512-cOo7biqwkpawslEfox5Vs8/qj83M/aZCSSNIWpVzfU2CYHa2G3P1UN5WF01RdTHSgCkri7XOlTdtk17BezlV3A==} + '@rollup/rollup-darwin-x64@4.55.1': + resolution: {integrity: sha512-rDUjG25C9qoTm+e02Esi+aqTKSBYwVTaoS1wxcN47/Luqef57Vgp96xNANwt5npq9GDxsH7kXxNkJVEsWEOEaQ==} cpu: [x64] os: [darwin] - '@rollup/rollup-freebsd-arm64@4.54.0': - resolution: {integrity: sha512-miSvuFkmvFbgJ1BevMa4CPCFt5MPGw094knM64W9I0giUIMMmRYcGW/JWZDriaw/k1kOBtsWh1z6nIFV1vPNtA==} + '@rollup/rollup-freebsd-arm64@4.55.1': + resolution: {integrity: sha512-+JiU7Jbp5cdxekIgdte0jfcu5oqw4GCKr6i3PJTlXTCU5H5Fvtkpbs4XJHRmWNXF+hKmn4v7ogI5OQPaupJgOg==} cpu: [arm64] os: [freebsd] - '@rollup/rollup-freebsd-x64@4.54.0': - resolution: {integrity: sha512-KGXIs55+b/ZfZsq9aR026tmr/+7tq6VG6MsnrvF4H8VhwflTIuYh+LFUlIsRdQSgrgmtM3fVATzEAj4hBQlaqQ==} + '@rollup/rollup-freebsd-x64@4.55.1': + resolution: {integrity: sha512-V5xC1tOVWtLLmr3YUk2f6EJK4qksksOYiz/TCsFHu/R+woubcLWdC9nZQmwjOAbmExBIVKsm1/wKmEy4z4u4Bw==} cpu: [x64] os: [freebsd] - '@rollup/rollup-linux-arm-gnueabihf@4.54.0': - resolution: {integrity: sha512-EHMUcDwhtdRGlXZsGSIuXSYwD5kOT9NVnx9sqzYiwAc91wfYOE1g1djOEDseZJKKqtHAHGwnGPQu3kytmfaXLQ==} + '@rollup/rollup-linux-arm-gnueabihf@4.55.1': + resolution: {integrity: sha512-Rn3n+FUk2J5VWx+ywrG/HGPTD9jXNbicRtTM11e/uorplArnXZYsVifnPPqNNP5BsO3roI4n8332ukpY/zN7rQ==} cpu: [arm] os: [linux] - '@rollup/rollup-linux-arm-musleabihf@4.54.0': - resolution: {integrity: sha512-+pBrqEjaakN2ySv5RVrj/qLytYhPKEUwk+e3SFU5jTLHIcAtqh2rLrd/OkbNuHJpsBgxsD8ccJt5ga/SeG0JmA==} + '@rollup/rollup-linux-arm-musleabihf@4.55.1': + resolution: {integrity: sha512-grPNWydeKtc1aEdrJDWk4opD7nFtQbMmV7769hiAaYyUKCT1faPRm2av8CX1YJsZ4TLAZcg9gTR1KvEzoLjXkg==} cpu: [arm] os: [linux] - '@rollup/rollup-linux-arm64-gnu@4.54.0': - resolution: {integrity: sha512-NSqc7rE9wuUaRBsBp5ckQ5CVz5aIRKCwsoa6WMF7G01sX3/qHUw/z4pv+D+ahL1EIKy6Enpcnz1RY8pf7bjwng==} + '@rollup/rollup-linux-arm64-gnu@4.55.1': + resolution: {integrity: sha512-a59mwd1k6x8tXKcUxSyISiquLwB5pX+fJW9TkWU46lCqD/GRDe9uDN31jrMmVP3feI3mhAdvcCClhV8V5MhJFQ==} cpu: [arm64] os: [linux] - '@rollup/rollup-linux-arm64-musl@4.54.0': - resolution: {integrity: sha512-gr5vDbg3Bakga5kbdpqx81m2n9IX8M6gIMlQQIXiLTNeQW6CucvuInJ91EuCJ/JYvc+rcLLsDFcfAD1K7fMofg==} + '@rollup/rollup-linux-arm64-musl@4.55.1': + resolution: {integrity: sha512-puS1MEgWX5GsHSoiAsF0TYrpomdvkaXm0CofIMG5uVkP6IBV+ZO9xhC5YEN49nsgYo1DuuMquF9+7EDBVYu4uA==} cpu: [arm64] os: [linux] - '@rollup/rollup-linux-loong64-gnu@4.54.0': - resolution: {integrity: sha512-gsrtB1NA3ZYj2vq0Rzkylo9ylCtW/PhpLEivlgWe0bpgtX5+9j9EZa0wtZiCjgu6zmSeZWyI/e2YRX1URozpIw==} + '@rollup/rollup-linux-loong64-gnu@4.55.1': + resolution: {integrity: sha512-r3Wv40in+lTsULSb6nnoudVbARdOwb2u5fpeoOAZjFLznp6tDU8kd+GTHmJoqZ9lt6/Sys33KdIHUaQihFcu7g==} cpu: [loong64] os: [linux] - '@rollup/rollup-linux-ppc64-gnu@4.54.0': - resolution: {integrity: sha512-y3qNOfTBStmFNq+t4s7Tmc9hW2ENtPg8FeUD/VShI7rKxNW7O4fFeaYbMsd3tpFlIg1Q8IapFgy7Q9i2BqeBvA==} + '@rollup/rollup-linux-loong64-musl@4.55.1': + resolution: {integrity: sha512-MR8c0+UxAlB22Fq4R+aQSPBayvYa3+9DrwG/i1TKQXFYEaoW3B5b/rkSRIypcZDdWjWnpcvxbNaAJDcSbJU3Lw==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-ppc64-gnu@4.55.1': + resolution: {integrity: sha512-3KhoECe1BRlSYpMTeVrD4sh2Pw2xgt4jzNSZIIPLFEsnQn9gAnZagW9+VqDqAHgm1Xc77LzJOo2LdigS5qZ+gw==} cpu: [ppc64] os: [linux] - '@rollup/rollup-linux-riscv64-gnu@4.54.0': - resolution: {integrity: sha512-89sepv7h2lIVPsFma8iwmccN7Yjjtgz0Rj/Ou6fEqg3HDhpCa+Et+YSufy27i6b0Wav69Qv4WBNl3Rs6pwhebQ==} + '@rollup/rollup-linux-ppc64-musl@4.55.1': + resolution: {integrity: sha512-ziR1OuZx0vdYZZ30vueNZTg73alF59DicYrPViG0NEgDVN8/Jl87zkAPu4u6VjZST2llgEUjaiNl9JM6HH1Vdw==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-riscv64-gnu@4.55.1': + resolution: {integrity: sha512-uW0Y12ih2XJRERZ4jAfKamTyIHVMPQnTZcQjme2HMVDAHY4amf5u414OqNYC+x+LzRdRcnIG1YodLrrtA8xsxw==} cpu: [riscv64] os: [linux] - '@rollup/rollup-linux-riscv64-musl@4.54.0': - resolution: {integrity: sha512-ZcU77ieh0M2Q8Ur7D5X7KvK+UxbXeDHwiOt/CPSBTI1fBmeDMivW0dPkdqkT4rOgDjrDDBUed9x4EgraIKoR2A==} + '@rollup/rollup-linux-riscv64-musl@4.55.1': + resolution: {integrity: sha512-u9yZ0jUkOED1BFrqu3BwMQoixvGHGZ+JhJNkNKY/hyoEgOwlqKb62qu+7UjbPSHYjiVy8kKJHvXKv5coH4wDeg==} cpu: [riscv64] os: [linux] - '@rollup/rollup-linux-s390x-gnu@4.54.0': - resolution: {integrity: sha512-2AdWy5RdDF5+4YfG/YesGDDtbyJlC9LHmL6rZw6FurBJ5n4vFGupsOBGfwMRjBYH7qRQowT8D/U4LoSvVwOhSQ==} + '@rollup/rollup-linux-s390x-gnu@4.55.1': + resolution: {integrity: sha512-/0PenBCmqM4ZUd0190j7J0UsQ/1nsi735iPRakO8iPciE7BQ495Y6msPzaOmvx0/pn+eJVVlZrNrSh4WSYLxNg==} cpu: [s390x] os: [linux] - '@rollup/rollup-linux-x64-gnu@4.54.0': - resolution: {integrity: sha512-WGt5J8Ij/rvyqpFexxk3ffKqqbLf9AqrTBbWDk7ApGUzaIs6V+s2s84kAxklFwmMF/vBNGrVdYgbblCOFFezMQ==} + '@rollup/rollup-linux-x64-gnu@4.55.1': + resolution: {integrity: sha512-a8G4wiQxQG2BAvo+gU6XrReRRqj+pLS2NGXKm8io19goR+K8lw269eTrPkSdDTALwMmJp4th2Uh0D8J9bEV1vg==} cpu: [x64] os: [linux] - '@rollup/rollup-linux-x64-musl@4.54.0': - resolution: {integrity: sha512-JzQmb38ATzHjxlPHuTH6tE7ojnMKM2kYNzt44LO/jJi8BpceEC8QuXYA908n8r3CNuG/B3BV8VR3Hi1rYtmPiw==} + '@rollup/rollup-linux-x64-musl@4.55.1': + resolution: {integrity: sha512-bD+zjpFrMpP/hqkfEcnjXWHMw5BIghGisOKPj+2NaNDuVT+8Ds4mPf3XcPHuat1tz89WRL+1wbcxKY3WSbiT7w==} cpu: [x64] os: [linux] - '@rollup/rollup-openharmony-arm64@4.54.0': - resolution: {integrity: sha512-huT3fd0iC7jigGh7n3q/+lfPcXxBi+om/Rs3yiFxjvSxbSB6aohDFXbWvlspaqjeOh+hx7DDHS+5Es5qRkWkZg==} + '@rollup/rollup-openbsd-x64@4.55.1': + resolution: {integrity: sha512-eLXw0dOiqE4QmvikfQ6yjgkg/xDM+MdU9YJuP4ySTibXU0oAvnEWXt7UDJmD4UkYialMfOGFPJnIHSe/kdzPxg==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.55.1': + resolution: {integrity: sha512-xzm44KgEP11te3S2HCSyYf5zIzWmx3n8HDCc7EE59+lTcswEWNpvMLfd9uJvVX8LCg9QWG67Xt75AuHn4vgsXw==} cpu: [arm64] os: [openharmony] - '@rollup/rollup-win32-arm64-msvc@4.54.0': - resolution: {integrity: sha512-c2V0W1bsKIKfbLMBu/WGBz6Yci8nJ/ZJdheE0EwB73N3MvHYKiKGs3mVilX4Gs70eGeDaMqEob25Tw2Gb9Nqyw==} + '@rollup/rollup-win32-arm64-msvc@4.55.1': + resolution: {integrity: sha512-yR6Bl3tMC/gBok5cz/Qi0xYnVbIxGx5Fcf/ca0eB6/6JwOY+SRUcJfI0OpeTpPls7f194as62thCt/2BjxYN8g==} cpu: [arm64] os: [win32] - '@rollup/rollup-win32-ia32-msvc@4.54.0': - resolution: {integrity: sha512-woEHgqQqDCkAzrDhvDipnSirm5vxUXtSKDYTVpZG3nUdW/VVB5VdCYA2iReSj/u3yCZzXID4kuKG7OynPnB3WQ==} + '@rollup/rollup-win32-ia32-msvc@4.55.1': + resolution: {integrity: sha512-3fZBidchE0eY0oFZBnekYCfg+5wAB0mbpCBuofh5mZuzIU/4jIVkbESmd2dOsFNS78b53CYv3OAtwqkZZmU5nA==} cpu: [ia32] os: [win32] - '@rollup/rollup-win32-x64-gnu@4.54.0': - resolution: {integrity: sha512-dzAc53LOuFvHwbCEOS0rPbXp6SIhAf2txMP5p6mGyOXXw5mWY8NGGbPMPrs4P1WItkfApDathBj/NzMLUZ9rtQ==} + '@rollup/rollup-win32-x64-gnu@4.55.1': + resolution: {integrity: sha512-xGGY5pXj69IxKb4yv/POoocPy/qmEGhimy/FoTpTSVju3FYXUQQMFCaZZXJVidsmGxRioZAwpThl/4zX41gRKg==} cpu: [x64] os: [win32] - '@rollup/rollup-win32-x64-msvc@4.54.0': - resolution: {integrity: sha512-hYT5d3YNdSh3mbCU1gwQyPgQd3T2ne0A3KG8KSBdav5TiBg6eInVmV+TeR5uHufiIgSFg0XsOWGW5/RhNcSvPg==} + '@rollup/rollup-win32-x64-msvc@4.55.1': + resolution: {integrity: sha512-SPEpaL6DX4rmcXtnhdrQYgzQ5W2uW3SCJch88lB2zImhJRhIIK44fkUrgIV/Q8yUNfw5oyZ5vkeQsZLhCb06lw==} cpu: [x64] os: [win32] @@ -1255,63 +1270,63 @@ packages: '@types/react@19.2.7': resolution: {integrity: sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==} - '@typescript-eslint/eslint-plugin@8.50.1': - resolution: {integrity: sha512-PKhLGDq3JAg0Jk/aK890knnqduuI/Qj+udH7wCf0217IGi4gt+acgCyPVe79qoT+qKUvHMDQkwJeKW9fwl8Cyw==} + '@typescript-eslint/eslint-plugin@8.52.0': + resolution: {integrity: sha512-okqtOgqu2qmZJ5iN4TWlgfF171dZmx2FzdOv2K/ixL2LZWDStL8+JgQerI2sa8eAEfoydG9+0V96m7V+P8yE1Q==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - '@typescript-eslint/parser': ^8.50.1 + '@typescript-eslint/parser': ^8.52.0 eslint: ^8.57.0 || ^9.0.0 typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/parser@8.50.1': - resolution: {integrity: sha512-hM5faZwg7aVNa819m/5r7D0h0c9yC4DUlWAOvHAtISdFTc8xB86VmX5Xqabrama3wIPJ/q9RbGS1worb6JfnMg==} + '@typescript-eslint/parser@8.52.0': + resolution: {integrity: sha512-iIACsx8pxRnguSYhHiMn2PvhvfpopO9FXHyn1mG5txZIsAaB6F0KwbFnUQN3KCiG3Jcuad/Cao2FAs1Wp7vAyg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/project-service@8.50.1': - resolution: {integrity: sha512-E1ur1MCVf+YiP89+o4Les/oBAVzmSbeRB0MQLfSlYtbWU17HPxZ6Bhs5iYmKZRALvEuBoXIZMOIRRc/P++Ortg==} + '@typescript-eslint/project-service@8.52.0': + resolution: {integrity: sha512-xD0MfdSdEmeFa3OmVqonHi+Cciab96ls1UhIF/qX/O/gPu5KXD0bY9lu33jj04fjzrXHcuvjBcBC+D3SNSadaw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/scope-manager@8.50.1': - resolution: {integrity: sha512-mfRx06Myt3T4vuoHaKi8ZWNTPdzKPNBhiblze5N50//TSHOAQQevl/aolqA/BcqqbJ88GUnLqjjcBc8EWdBcVw==} + '@typescript-eslint/scope-manager@8.52.0': + resolution: {integrity: sha512-ixxqmmCcc1Nf8S0mS0TkJ/3LKcC8mruYJPOU6Ia2F/zUUR4pApW7LzrpU3JmtePbRUTes9bEqRc1Gg4iyRnDzA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/tsconfig-utils@8.50.1': - resolution: {integrity: sha512-ooHmotT/lCWLXi55G4mvaUF60aJa012QzvLK0Y+Mp4WdSt17QhMhWOaBWeGTFVkb2gDgBe19Cxy1elPXylslDw==} + '@typescript-eslint/tsconfig-utils@8.52.0': + resolution: {integrity: sha512-jl+8fzr/SdzdxWJznq5nvoI7qn2tNYV/ZBAEcaFMVXf+K6jmXvAFrgo/+5rxgnL152f//pDEAYAhhBAZGrVfwg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/type-utils@8.50.1': - resolution: {integrity: sha512-7J3bf022QZE42tYMO6SL+6lTPKFk/WphhRPe9Tw/el+cEwzLz1Jjz2PX3GtGQVxooLDKeMVmMt7fWpYRdG5Etg==} + '@typescript-eslint/type-utils@8.52.0': + resolution: {integrity: sha512-JD3wKBRWglYRQkAtsyGz1AewDu3mTc7NtRjR/ceTyGoPqmdS5oCdx/oZMWD5Zuqmo6/MpsYs0wp6axNt88/2EQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/types@8.50.1': - resolution: {integrity: sha512-v5lFIS2feTkNyMhd7AucE/9j/4V9v5iIbpVRncjk/K0sQ6Sb+Np9fgYS/63n6nwqahHQvbmujeBL7mp07Q9mlA==} + '@typescript-eslint/types@8.52.0': + resolution: {integrity: sha512-LWQV1V4q9V4cT4H5JCIx3481iIFxH1UkVk+ZkGGAV1ZGcjGI9IoFOfg3O6ywz8QqCDEp7Inlg6kovMofsNRaGg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/typescript-estree@8.50.1': - resolution: {integrity: sha512-woHPdW+0gj53aM+cxchymJCrh0cyS7BTIdcDxWUNsclr9VDkOSbqC13juHzxOmQ22dDkMZEpZB+3X1WpUvzgVQ==} + '@typescript-eslint/typescript-estree@8.52.0': + resolution: {integrity: sha512-XP3LClsCc0FsTK5/frGjolyADTh3QmsLp6nKd476xNI9CsSsLnmn4f0jrzNoAulmxlmNIpeXuHYeEQv61Q6qeQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/utils@8.50.1': - resolution: {integrity: sha512-lCLp8H1T9T7gPbEuJSnHwnSuO9mDf8mfK/Nion5mZmiEaQD9sWf9W4dfeFqRyqRjF06/kBuTmAqcs9sewM2NbQ==} + '@typescript-eslint/utils@8.52.0': + resolution: {integrity: sha512-wYndVMWkweqHpEpwPhwqE2lnD2DxC6WVLupU/DOt/0/v+/+iQbbzO3jOHjmBMnhu0DgLULvOaU4h4pwHYi2oRQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/visitor-keys@8.50.1': - resolution: {integrity: sha512-IrDKrw7pCRUR94zeuCSUWQ+w8JEf5ZX5jl/e6AHGSLi1/zIr0lgutfn/7JpfCey+urpgQEdrZVYzCaVVKiTwhQ==} + '@typescript-eslint/visitor-keys@8.52.0': + resolution: {integrity: sha512-ink3/Zofus34nmBsPjow63FP5M7IGff0RKAgqR6+CFpdk22M7aLwC9gOcLGYqr7MczLPzZVERW9hRog3O4n1sQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} '@vitejs/plugin-react@5.1.2': @@ -1347,8 +1362,8 @@ packages: balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} - baseline-browser-mapping@2.9.11: - resolution: {integrity: sha512-Sg0xJUNDU1sJNGdfGWhVHX0kkZ+HWcvmVymJbj6NSgZZmW/8S9Y2HQ5euytnIgakgxN6papOAWiwDo1ctFDcoQ==} + baseline-browser-mapping@2.9.13: + resolution: {integrity: sha512-WhtvB2NG2wjr04+h77sg3klAIwrgOqnjS49GGudnUPGFFgg7G17y7Qecqp+2Dr5kUDxNRBca0SK7cG8JwzkWDQ==} hasBin: true brace-expansion@1.1.12: @@ -1366,8 +1381,8 @@ packages: resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} engines: {node: '>=6'} - caniuse-lite@1.0.30001761: - resolution: {integrity: sha512-JF9ptu1vP2coz98+5051jZ4PwQgd2ni8A+gYSN7EA7dPKIMf0pDlSUxhdmVOaV3/fYK5uWBkgSXJaRLr4+3A6g==} + caniuse-lite@1.0.30001763: + resolution: {integrity: sha512-mh/dGtq56uN98LlNX9qdbKnzINhX0QzhiWBFEkFfsFO4QyCvL8YegrJAazCwXIeqkIob8BlZPGM3xdnY+sgmvQ==} chalk@4.1.2: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} @@ -1476,8 +1491,8 @@ packages: resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - esquery@1.6.0: - resolution: {integrity: sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==} + esquery@1.7.0: + resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} engines: {node: '>=0.10'} esrecurse@4.3.0: @@ -1525,8 +1540,8 @@ packages: flatted@3.3.3: resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==} - framer-motion@12.23.26: - resolution: {integrity: sha512-cPcIhgR42xBn1Uj+PzOyheMtZ73H927+uWPDVhUMqxy8UHt6Okavb6xIz9J/phFUHUj0OncR6UvMfJTXoc/LKA==} + framer-motion@12.24.12: + resolution: {integrity: sha512-W+tBOI1SDGNMH4D4mADY95qYd16Drke2Tj9zlGlwTGSCi6yy8wbMmPY1mvirfcTK8HBeuuCd2PflHdN/zbL4ew==} peerDependencies: '@emotion/is-prop-valid': '*' react: ^18.0.0 || ^19.0.0 @@ -1560,8 +1575,8 @@ packages: resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} engines: {node: '>=18'} - globals@16.5.0: - resolution: {integrity: sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==} + globals@17.0.0: + resolution: {integrity: sha512-gv5BeD2EssA793rlFWVPMMCqefTlpusw6/2TbAVMy0FzcG8wKJn4O+NqJ4+XWmmwrayJgw5TzrmWjFgmz1XPqw==} engines: {node: '>=18'} graceful-fs@4.2.11: @@ -1736,14 +1751,14 @@ packages: resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==} engines: {node: '>=16 || 14 >=14.17'} - motion-dom@12.23.23: - resolution: {integrity: sha512-n5yolOs0TQQBRUFImrRfs/+6X4p3Q4n1dUEqt/H58Vx7OW6RF+foWEgmTVDhIWJIMXOuNNL0apKH2S16en9eiA==} + motion-dom@12.24.11: + resolution: {integrity: sha512-DlWOmsXMJrV8lzZyd+LKjG2CXULUs++bkq8GZ2Sr0R0RRhs30K2wtY+LKiTjhmJU3W61HK+rB0GLz6XmPvTA1A==} - motion-utils@12.23.6: - resolution: {integrity: sha512-eAWoPgr4eFEOFfg2WjIsMoqJTW6Z8MTUCgn/GZ3VRpClWBdnbjryiA3ZSNLyxCTmCQx4RmYX6jX1iWHbenUPNQ==} + motion-utils@12.24.10: + resolution: {integrity: sha512-x5TFgkCIP4pPsRLpKoI86jv/q8t8FQOiM/0E8QKBzfMozWHfkKap2gA1hOki+B5g3IsBNpxbUnfOum1+dgvYww==} - motion@12.23.26: - resolution: {integrity: sha512-Ll8XhVxY8LXMVYTCfme27WH2GjBrCIzY4+ndr5QKxsK+YwCtOi2B/oBi5jcIbik5doXuWT/4KKDOVAZJkeY5VQ==} + motion@12.24.12: + resolution: {integrity: sha512-usaP62NpHmM8++QrEnNoCco6qrtK1AtzkeHfgW+4qICE0k7ykK+dPJGaRjEzo7sF1GcrYskrGBB/r5RtqnminQ==} peerDependencies: '@emotion/is-prop-valid': '*' react: ^18.0.0 || ^19.0.0 @@ -1866,8 +1881,8 @@ packages: resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} engines: {node: '>=4'} - rollup@4.54.0: - resolution: {integrity: sha512-3nk8Y3a9Ea8szgKhinMlGMhGMw89mqule3KWczxhIzqudyHdCIOHw8WJlj/r329fACjKLEh13ZSk7oE22kyeIw==} + rollup@4.55.1: + resolution: {integrity: sha512-wDv/Ht1BNHB4upNbK74s9usvl7hObDnvVzknxqY/E/O3X6rW1U1rV1aENEfJ54eFZDTNo7zv1f5N4edCluH7+A==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true @@ -1927,8 +1942,8 @@ packages: resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} engines: {node: '>=12.0.0'} - ts-api-utils@2.1.0: - resolution: {integrity: sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==} + ts-api-utils@2.4.0: + resolution: {integrity: sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==} engines: {node: '>=18.12'} peerDependencies: typescript: '>=4.8.4' @@ -1943,8 +1958,8 @@ packages: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} - typescript-eslint@8.50.1: - resolution: {integrity: sha512-ytTHO+SoYSbhAH9CrYnMhiLx8To6PSSvqnvXyPUgPETCvB6eBKmTI9w6XMPS3HsBRGkwTVBX+urA8dYQx6bHfQ==} + typescript-eslint@8.52.0: + resolution: {integrity: sha512-atlQQJ2YkO4pfTVQmQ+wvYQwexPDOIgo+RaVcD7gHgzy/IQA+XTyuxNM9M9TVXvttkF7koBHmcwisKdOAf2EcA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 @@ -1987,8 +2002,8 @@ packages: '@types/react': optional: true - vite@7.3.0: - resolution: {integrity: sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==} + vite@7.3.1: + resolution: {integrity: sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: @@ -2049,8 +2064,8 @@ packages: peerDependencies: zod: ^3.25.0 || ^4.0.0 - zod@4.2.1: - resolution: {integrity: sha512-0wZ1IRqGGhMP76gLqz8EyfBXKk0J2qo2+H3fi4mcUP/KtTocoX08nmIAHl1Z2kJIZbZee8KOpBCSNPRgauucjw==} + zod@4.3.5: + resolution: {integrity: sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g==} snapshots: @@ -2166,7 +2181,7 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 - '@emnapi/runtime@1.7.1': + '@emnapi/runtime@1.8.1': dependencies: tslib: 2.8.1 optional: true @@ -2249,7 +2264,7 @@ snapshots: '@esbuild/win32-x64@0.27.2': optional: true - '@eslint-community/eslint-utils@4.9.0(eslint@9.39.2(jiti@2.6.1))': + '@eslint-community/eslint-utils@4.9.1(eslint@9.39.2(jiti@2.6.1))': dependencies: eslint: 9.39.2(jiti@2.6.1) eslint-visitor-keys: 3.4.3 @@ -2407,7 +2422,7 @@ snapshots: '@img/sharp-wasm32@0.34.5': dependencies: - '@emnapi/runtime': 1.7.1 + '@emnapi/runtime': 1.8.1 optional: true '@img/sharp-win32-arm64@0.34.5': @@ -2865,70 +2880,79 @@ snapshots: '@rolldown/pluginutils@1.0.0-beta.53': {} - '@rollup/rollup-android-arm-eabi@4.54.0': + '@rollup/rollup-android-arm-eabi@4.55.1': optional: true - '@rollup/rollup-android-arm64@4.54.0': + '@rollup/rollup-android-arm64@4.55.1': optional: true - '@rollup/rollup-darwin-arm64@4.54.0': + '@rollup/rollup-darwin-arm64@4.55.1': optional: true - '@rollup/rollup-darwin-x64@4.54.0': + '@rollup/rollup-darwin-x64@4.55.1': optional: true - '@rollup/rollup-freebsd-arm64@4.54.0': + '@rollup/rollup-freebsd-arm64@4.55.1': optional: true - '@rollup/rollup-freebsd-x64@4.54.0': + '@rollup/rollup-freebsd-x64@4.55.1': optional: true - '@rollup/rollup-linux-arm-gnueabihf@4.54.0': + '@rollup/rollup-linux-arm-gnueabihf@4.55.1': optional: true - '@rollup/rollup-linux-arm-musleabihf@4.54.0': + '@rollup/rollup-linux-arm-musleabihf@4.55.1': optional: true - '@rollup/rollup-linux-arm64-gnu@4.54.0': + '@rollup/rollup-linux-arm64-gnu@4.55.1': optional: true - '@rollup/rollup-linux-arm64-musl@4.54.0': + '@rollup/rollup-linux-arm64-musl@4.55.1': optional: true - '@rollup/rollup-linux-loong64-gnu@4.54.0': + '@rollup/rollup-linux-loong64-gnu@4.55.1': optional: true - '@rollup/rollup-linux-ppc64-gnu@4.54.0': + '@rollup/rollup-linux-loong64-musl@4.55.1': optional: true - '@rollup/rollup-linux-riscv64-gnu@4.54.0': + '@rollup/rollup-linux-ppc64-gnu@4.55.1': optional: true - '@rollup/rollup-linux-riscv64-musl@4.54.0': + '@rollup/rollup-linux-ppc64-musl@4.55.1': optional: true - '@rollup/rollup-linux-s390x-gnu@4.54.0': + '@rollup/rollup-linux-riscv64-gnu@4.55.1': optional: true - '@rollup/rollup-linux-x64-gnu@4.54.0': + '@rollup/rollup-linux-riscv64-musl@4.55.1': optional: true - '@rollup/rollup-linux-x64-musl@4.54.0': + '@rollup/rollup-linux-s390x-gnu@4.55.1': optional: true - '@rollup/rollup-openharmony-arm64@4.54.0': + '@rollup/rollup-linux-x64-gnu@4.55.1': optional: true - '@rollup/rollup-win32-arm64-msvc@4.54.0': + '@rollup/rollup-linux-x64-musl@4.55.1': optional: true - '@rollup/rollup-win32-ia32-msvc@4.54.0': + '@rollup/rollup-openbsd-x64@4.55.1': optional: true - '@rollup/rollup-win32-x64-gnu@4.54.0': + '@rollup/rollup-openharmony-arm64@4.55.1': optional: true - '@rollup/rollup-win32-x64-msvc@4.54.0': + '@rollup/rollup-win32-arm64-msvc@4.55.1': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.55.1': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.55.1': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.55.1': optional: true '@tailwindcss/node@4.1.18': @@ -2992,12 +3016,12 @@ snapshots: '@tailwindcss/oxide-win32-arm64-msvc': 4.1.18 '@tailwindcss/oxide-win32-x64-msvc': 4.1.18 - '@tailwindcss/vite@4.1.18(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2))': + '@tailwindcss/vite@4.1.18(vite@7.3.1(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2))': dependencies: '@tailwindcss/node': 4.1.18 '@tailwindcss/oxide': 4.1.18 tailwindcss: 4.1.18 - vite: 7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2) + vite: 7.3.1(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2) '@types/babel__core@7.20.5': dependencies: @@ -3036,98 +3060,98 @@ snapshots: dependencies: csstype: 3.2.3 - '@typescript-eslint/eslint-plugin@8.50.1(@typescript-eslint/parser@8.50.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/eslint-plugin@8.52.0(@typescript-eslint/parser@8.52.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 8.50.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/scope-manager': 8.50.1 - '@typescript-eslint/type-utils': 8.50.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/utils': 8.50.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/visitor-keys': 8.50.1 + '@typescript-eslint/parser': 8.52.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.52.0 + '@typescript-eslint/type-utils': 8.52.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/utils': 8.52.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.52.0 eslint: 9.39.2(jiti@2.6.1) ignore: 7.0.5 natural-compare: 1.4.0 - ts-api-utils: 2.1.0(typescript@5.9.3) + ts-api-utils: 2.4.0(typescript@5.9.3) typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.50.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/parser@8.52.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': dependencies: - '@typescript-eslint/scope-manager': 8.50.1 - '@typescript-eslint/types': 8.50.1 - '@typescript-eslint/typescript-estree': 8.50.1(typescript@5.9.3) - '@typescript-eslint/visitor-keys': 8.50.1 + '@typescript-eslint/scope-manager': 8.52.0 + '@typescript-eslint/types': 8.52.0 + '@typescript-eslint/typescript-estree': 8.52.0(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.52.0 debug: 4.4.3 eslint: 9.39.2(jiti@2.6.1) typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/project-service@8.50.1(typescript@5.9.3)': + '@typescript-eslint/project-service@8.52.0(typescript@5.9.3)': dependencies: - '@typescript-eslint/tsconfig-utils': 8.50.1(typescript@5.9.3) - '@typescript-eslint/types': 8.50.1 + '@typescript-eslint/tsconfig-utils': 8.52.0(typescript@5.9.3) + '@typescript-eslint/types': 8.52.0 debug: 4.4.3 typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/scope-manager@8.50.1': + '@typescript-eslint/scope-manager@8.52.0': dependencies: - '@typescript-eslint/types': 8.50.1 - '@typescript-eslint/visitor-keys': 8.50.1 + '@typescript-eslint/types': 8.52.0 + '@typescript-eslint/visitor-keys': 8.52.0 - '@typescript-eslint/tsconfig-utils@8.50.1(typescript@5.9.3)': + '@typescript-eslint/tsconfig-utils@8.52.0(typescript@5.9.3)': dependencies: typescript: 5.9.3 - '@typescript-eslint/type-utils@8.50.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/type-utils@8.52.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': dependencies: - '@typescript-eslint/types': 8.50.1 - '@typescript-eslint/typescript-estree': 8.50.1(typescript@5.9.3) - '@typescript-eslint/utils': 8.50.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/types': 8.52.0 + '@typescript-eslint/typescript-estree': 8.52.0(typescript@5.9.3) + '@typescript-eslint/utils': 8.52.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) debug: 4.4.3 eslint: 9.39.2(jiti@2.6.1) - ts-api-utils: 2.1.0(typescript@5.9.3) + ts-api-utils: 2.4.0(typescript@5.9.3) typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/types@8.50.1': {} + '@typescript-eslint/types@8.52.0': {} - '@typescript-eslint/typescript-estree@8.50.1(typescript@5.9.3)': + '@typescript-eslint/typescript-estree@8.52.0(typescript@5.9.3)': dependencies: - '@typescript-eslint/project-service': 8.50.1(typescript@5.9.3) - '@typescript-eslint/tsconfig-utils': 8.50.1(typescript@5.9.3) - '@typescript-eslint/types': 8.50.1 - '@typescript-eslint/visitor-keys': 8.50.1 + '@typescript-eslint/project-service': 8.52.0(typescript@5.9.3) + '@typescript-eslint/tsconfig-utils': 8.52.0(typescript@5.9.3) + '@typescript-eslint/types': 8.52.0 + '@typescript-eslint/visitor-keys': 8.52.0 debug: 4.4.3 minimatch: 9.0.5 semver: 7.7.3 tinyglobby: 0.2.15 - ts-api-utils: 2.1.0(typescript@5.9.3) + ts-api-utils: 2.4.0(typescript@5.9.3) typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.50.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/utils@8.52.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': dependencies: - '@eslint-community/eslint-utils': 4.9.0(eslint@9.39.2(jiti@2.6.1)) - '@typescript-eslint/scope-manager': 8.50.1 - '@typescript-eslint/types': 8.50.1 - '@typescript-eslint/typescript-estree': 8.50.1(typescript@5.9.3) + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(jiti@2.6.1)) + '@typescript-eslint/scope-manager': 8.52.0 + '@typescript-eslint/types': 8.52.0 + '@typescript-eslint/typescript-estree': 8.52.0(typescript@5.9.3) eslint: 9.39.2(jiti@2.6.1) typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/visitor-keys@8.50.1': + '@typescript-eslint/visitor-keys@8.52.0': dependencies: - '@typescript-eslint/types': 8.50.1 + '@typescript-eslint/types': 8.52.0 eslint-visitor-keys: 4.2.1 - '@vitejs/plugin-react@5.1.2(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2))': + '@vitejs/plugin-react@5.1.2(vite@7.3.1(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2))': dependencies: '@babel/core': 7.28.5 '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.28.5) @@ -3135,7 +3159,7 @@ snapshots: '@rolldown/pluginutils': 1.0.0-beta.53 '@types/babel__core': 7.20.5 react-refresh: 0.18.0 - vite: 7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2) + vite: 7.3.1(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2) transitivePeerDependencies: - supports-color @@ -3164,7 +3188,7 @@ snapshots: balanced-match@1.0.2: {} - baseline-browser-mapping@2.9.11: {} + baseline-browser-mapping@2.9.13: {} brace-expansion@1.1.12: dependencies: @@ -3177,15 +3201,15 @@ snapshots: browserslist@4.28.1: dependencies: - baseline-browser-mapping: 2.9.11 - caniuse-lite: 1.0.30001761 + baseline-browser-mapping: 2.9.13 + caniuse-lite: 1.0.30001763 electron-to-chromium: 1.5.267 node-releases: 2.0.27 update-browserslist-db: 1.2.3(browserslist@4.28.1) callsites@3.1.0: {} - caniuse-lite@1.0.30001761: {} + caniuse-lite@1.0.30001763: {} chalk@4.1.2: dependencies: @@ -3272,8 +3296,8 @@ snapshots: '@babel/parser': 7.28.5 eslint: 9.39.2(jiti@2.6.1) hermes-parser: 0.25.1 - zod: 4.2.1 - zod-validation-error: 4.0.2(zod@4.2.1) + zod: 4.3.5 + zod-validation-error: 4.0.2(zod@4.3.5) transitivePeerDependencies: - supports-color @@ -3292,7 +3316,7 @@ snapshots: eslint@9.39.2(jiti@2.6.1): dependencies: - '@eslint-community/eslint-utils': 4.9.0(eslint@9.39.2(jiti@2.6.1)) + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(jiti@2.6.1)) '@eslint-community/regexpp': 4.12.2 '@eslint/config-array': 0.21.1 '@eslint/config-helpers': 0.4.2 @@ -3312,7 +3336,7 @@ snapshots: eslint-scope: 8.4.0 eslint-visitor-keys: 4.2.1 espree: 10.4.0 - esquery: 1.6.0 + esquery: 1.7.0 esutils: 2.0.3 fast-deep-equal: 3.1.3 file-entry-cache: 8.0.0 @@ -3337,7 +3361,7 @@ snapshots: acorn-jsx: 5.3.2(acorn@8.15.0) eslint-visitor-keys: 4.2.1 - esquery@1.6.0: + esquery@1.7.0: dependencies: estraverse: 5.3.0 @@ -3375,10 +3399,10 @@ snapshots: flatted@3.3.3: {} - framer-motion@12.23.26(react-dom@19.2.3(react@19.2.3))(react@19.2.3): + framer-motion@12.24.12(react-dom@19.2.3(react@19.2.3))(react@19.2.3): dependencies: - motion-dom: 12.23.23 - motion-utils: 12.23.6 + motion-dom: 12.24.11 + motion-utils: 12.24.10 tslib: 2.8.1 optionalDependencies: react: 19.2.3 @@ -3397,7 +3421,7 @@ snapshots: globals@14.0.0: {} - globals@16.5.0: {} + globals@17.0.0: {} graceful-fs@4.2.11: {} @@ -3530,15 +3554,15 @@ snapshots: dependencies: brace-expansion: 2.0.2 - motion-dom@12.23.23: + motion-dom@12.24.11: dependencies: - motion-utils: 12.23.6 + motion-utils: 12.24.10 - motion-utils@12.23.6: {} + motion-utils@12.24.10: {} - motion@12.23.26(react-dom@19.2.3(react@19.2.3))(react@19.2.3): + motion@12.24.12(react-dom@19.2.3(react@19.2.3))(react@19.2.3): dependencies: - framer-motion: 12.23.26(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + framer-motion: 12.24.12(react-dom@19.2.3(react@19.2.3))(react@19.2.3) tslib: 2.8.1 optionalDependencies: react: 19.2.3 @@ -3634,32 +3658,35 @@ snapshots: resolve-from@4.0.0: {} - rollup@4.54.0: + rollup@4.55.1: dependencies: '@types/estree': 1.0.8 optionalDependencies: - '@rollup/rollup-android-arm-eabi': 4.54.0 - '@rollup/rollup-android-arm64': 4.54.0 - '@rollup/rollup-darwin-arm64': 4.54.0 - '@rollup/rollup-darwin-x64': 4.54.0 - '@rollup/rollup-freebsd-arm64': 4.54.0 - '@rollup/rollup-freebsd-x64': 4.54.0 - '@rollup/rollup-linux-arm-gnueabihf': 4.54.0 - '@rollup/rollup-linux-arm-musleabihf': 4.54.0 - '@rollup/rollup-linux-arm64-gnu': 4.54.0 - '@rollup/rollup-linux-arm64-musl': 4.54.0 - '@rollup/rollup-linux-loong64-gnu': 4.54.0 - '@rollup/rollup-linux-ppc64-gnu': 4.54.0 - '@rollup/rollup-linux-riscv64-gnu': 4.54.0 - '@rollup/rollup-linux-riscv64-musl': 4.54.0 - '@rollup/rollup-linux-s390x-gnu': 4.54.0 - '@rollup/rollup-linux-x64-gnu': 4.54.0 - '@rollup/rollup-linux-x64-musl': 4.54.0 - '@rollup/rollup-openharmony-arm64': 4.54.0 - '@rollup/rollup-win32-arm64-msvc': 4.54.0 - '@rollup/rollup-win32-ia32-msvc': 4.54.0 - '@rollup/rollup-win32-x64-gnu': 4.54.0 - '@rollup/rollup-win32-x64-msvc': 4.54.0 + '@rollup/rollup-android-arm-eabi': 4.55.1 + '@rollup/rollup-android-arm64': 4.55.1 + '@rollup/rollup-darwin-arm64': 4.55.1 + '@rollup/rollup-darwin-x64': 4.55.1 + '@rollup/rollup-freebsd-arm64': 4.55.1 + '@rollup/rollup-freebsd-x64': 4.55.1 + '@rollup/rollup-linux-arm-gnueabihf': 4.55.1 + '@rollup/rollup-linux-arm-musleabihf': 4.55.1 + '@rollup/rollup-linux-arm64-gnu': 4.55.1 + '@rollup/rollup-linux-arm64-musl': 4.55.1 + '@rollup/rollup-linux-loong64-gnu': 4.55.1 + '@rollup/rollup-linux-loong64-musl': 4.55.1 + '@rollup/rollup-linux-ppc64-gnu': 4.55.1 + '@rollup/rollup-linux-ppc64-musl': 4.55.1 + '@rollup/rollup-linux-riscv64-gnu': 4.55.1 + '@rollup/rollup-linux-riscv64-musl': 4.55.1 + '@rollup/rollup-linux-s390x-gnu': 4.55.1 + '@rollup/rollup-linux-x64-gnu': 4.55.1 + '@rollup/rollup-linux-x64-musl': 4.55.1 + '@rollup/rollup-openbsd-x64': 4.55.1 + '@rollup/rollup-openharmony-arm64': 4.55.1 + '@rollup/rollup-win32-arm64-msvc': 4.55.1 + '@rollup/rollup-win32-ia32-msvc': 4.55.1 + '@rollup/rollup-win32-x64-gnu': 4.55.1 + '@rollup/rollup-win32-x64-msvc': 4.55.1 fsevents: 2.3.3 scheduler@0.27.0: {} @@ -3729,7 +3756,7 @@ snapshots: fdir: 6.5.0(picomatch@4.0.3) picomatch: 4.0.3 - ts-api-utils@2.1.0(typescript@5.9.3): + ts-api-utils@2.4.0(typescript@5.9.3): dependencies: typescript: 5.9.3 @@ -3741,12 +3768,12 @@ snapshots: dependencies: prelude-ls: 1.2.1 - typescript-eslint@8.50.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3): + typescript-eslint@8.52.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3): dependencies: - '@typescript-eslint/eslint-plugin': 8.50.1(@typescript-eslint/parser@8.50.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/parser': 8.50.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/typescript-estree': 8.50.1(typescript@5.9.3) - '@typescript-eslint/utils': 8.50.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/eslint-plugin': 8.52.0(@typescript-eslint/parser@8.52.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/parser': 8.52.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.52.0(typescript@5.9.3) + '@typescript-eslint/utils': 8.52.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) eslint: 9.39.2(jiti@2.6.1) typescript: 5.9.3 transitivePeerDependencies: @@ -3781,13 +3808,13 @@ snapshots: optionalDependencies: '@types/react': 19.2.7 - vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2): + vite@7.3.1(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2): dependencies: esbuild: 0.27.2 fdir: 6.5.0(picomatch@4.0.3) picomatch: 4.0.3 postcss: 8.5.6 - rollup: 4.54.0 + rollup: 4.55.1 tinyglobby: 0.2.15 optionalDependencies: '@types/node': 25.0.3 @@ -3805,8 +3832,8 @@ snapshots: yocto-queue@0.1.0: {} - zod-validation-error@4.0.2(zod@4.2.1): + zod-validation-error@4.0.2(zod@4.3.5): dependencies: - zod: 4.2.1 + zod: 4.3.5 - zod@4.2.1: {} + zod@4.3.5: {} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 7210b0b..9ab6032 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,12 +1,6 @@ import { useState, useEffect, useCallback } from "react"; import { Button } from "@/components/ui/button"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogTitle, -} from "@/components/ui/dialog"; +import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; import { Label } from "@/components/ui/label"; import { Input } from "@/components/ui/input"; import { Search, X, ArrowUp } from "lucide-react"; @@ -15,8 +9,6 @@ import { getSettings, getSettingsWithDefaults, saveSettings, applyThemeMode, app import { applyTheme } from "@/lib/themes"; import { OpenFolder } from "../wailsjs/go/main/App"; import { toastWithSound as toast } from "@/lib/toast-with-sound"; - -// Components import { TitleBar } from "@/components/TitleBar"; import { Sidebar, type PageType } from "@/components/Sidebar"; import { Header } from "@/components/Header"; @@ -33,543 +25,328 @@ import { FileManagerPage } from "@/components/FileManagerPage"; import { SettingsPage } from "@/components/SettingsPage"; import { DebugLoggerPage } from "@/components/DebugLoggerPage"; import type { HistoryItem } from "@/components/FetchHistory"; - -// Hooks import { useDownload } from "@/hooks/useDownload"; import { useMetadata } from "@/hooks/useMetadata"; import { useLyrics } from "@/hooks/useLyrics"; import { useCover } from "@/hooks/useCover"; import { useAvailability } from "@/hooks/useAvailability"; import { useDownloadQueueDialog } from "@/hooks/useDownloadQueueDialog"; - const HISTORY_KEY = "spotiflac_fetch_history"; const MAX_HISTORY = 5; - function App() { - const [currentPage, setCurrentPage] = useState("main"); - const [spotifyUrl, setSpotifyUrl] = useState(""); - const [selectedTracks, setSelectedTracks] = useState([]); - const [searchQuery, setSearchQuery] = useState(""); - const [sortBy, setSortBy] = useState("default"); - const [currentListPage, setCurrentListPage] = useState(1); - const [hasUpdate, setHasUpdate] = useState(false); - const [releaseDate, setReleaseDate] = useState(null); - const [fetchHistory, setFetchHistory] = useState([]); - const [isSearchMode, setIsSearchMode] = useState(false); - const [showScrollTop, setShowScrollTop] = useState(false); - - const ITEMS_PER_PAGE = 50; - const CURRENT_VERSION = "7.0"; - - const download = useDownload(); - const metadata = useMetadata(); - const lyrics = useLyrics(); - const cover = useCover(); - const availability = useAvailability(); - const downloadQueue = useDownloadQueueDialog(); - - - useEffect(() => { - const initSettings = async () => { - const settings = getSettings(); - applyThemeMode(settings.themeMode); - applyTheme(settings.theme); - applyFont(settings.fontFamily); - - // Initialize default download path if not set - if (!settings.downloadPath) { - const settingsWithDefaults = await getSettingsWithDefaults(); - saveSettings(settingsWithDefaults); - } + const [currentPage, setCurrentPage] = useState("main"); + const [spotifyUrl, setSpotifyUrl] = useState(""); + const [selectedTracks, setSelectedTracks] = useState([]); + const [searchQuery, setSearchQuery] = useState(""); + const [sortBy, setSortBy] = useState("default"); + const [currentListPage, setCurrentListPage] = useState(1); + const [hasUpdate, setHasUpdate] = useState(false); + const [releaseDate, setReleaseDate] = useState(null); + const [fetchHistory, setFetchHistory] = useState([]); + const [isSearchMode, setIsSearchMode] = useState(false); + const [showScrollTop, setShowScrollTop] = useState(false); + const [hasUnsavedSettings, setHasUnsavedSettings] = useState(false); + const [pendingPageChange, setPendingPageChange] = useState(null); + const [showUnsavedChangesDialog, setShowUnsavedChangesDialog] = useState(false); + const [resetSettingsFn, setResetSettingsFn] = useState<(() => void) | null>(null); + const ITEMS_PER_PAGE = 50; + const CURRENT_VERSION = "7.0.1"; + const download = useDownload(); + const metadata = useMetadata(); + const lyrics = useLyrics(); + const cover = useCover(); + const availability = useAvailability(); + const downloadQueue = useDownloadQueueDialog(); + useEffect(() => { + const initSettings = async () => { + const settings = getSettings(); + applyThemeMode(settings.themeMode); + applyTheme(settings.theme); + applyFont(settings.fontFamily); + if (!settings.downloadPath) { + const settingsWithDefaults = await getSettingsWithDefaults(); + saveSettings(settingsWithDefaults); + } + }; + initSettings(); + const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)"); + const handleChange = () => { + const currentSettings = getSettings(); + if (currentSettings.themeMode === "auto") { + applyThemeMode("auto"); + applyTheme(currentSettings.theme); + } + }; + mediaQuery.addEventListener("change", handleChange); + checkForUpdates(); + loadHistory(); + const handleScroll = () => { + setShowScrollTop(window.scrollY > 300); + }; + window.addEventListener("scroll", handleScroll); + return () => { + mediaQuery.removeEventListener("change", handleChange); + window.removeEventListener("scroll", handleScroll); + }; + }, []); + const scrollToTop = useCallback(() => { + window.scrollTo({ top: 0, behavior: "smooth" }); + }, []); + useEffect(() => { + setSelectedTracks([]); + setSearchQuery(""); + download.resetDownloadedTracks(); + lyrics.resetLyricsState(); + cover.resetCoverState(); + availability.clearAvailability(); + setSortBy("default"); + setCurrentListPage(1); + }, [metadata.metadata]); + const checkForUpdates = async () => { + try { + const response = await fetch("https://api.github.com/repos/afkarxyz/SpotiFLAC/releases/latest"); + const data = await response.json(); + const latestVersion = data.tag_name?.replace(/^v/, "") || ""; + if (data.published_at) { + setReleaseDate(data.published_at); + } + if (latestVersion && latestVersion > CURRENT_VERSION) { + setHasUpdate(true); + } + } + catch (err) { + console.error("Failed to check for updates:", err); + } }; - initSettings(); - - const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)"); - const handleChange = () => { - const currentSettings = getSettings(); - if (currentSettings.themeMode === "auto") { - applyThemeMode("auto"); - applyTheme(currentSettings.theme); - } + const loadHistory = () => { + try { + const saved = localStorage.getItem(HISTORY_KEY); + if (saved) { + setFetchHistory(JSON.parse(saved)); + } + } + catch (err) { + console.error("Failed to load history:", err); + } }; - - mediaQuery.addEventListener("change", handleChange); - checkForUpdates(); - loadHistory(); - - // Scroll listener for jump to top button - const handleScroll = () => { - setShowScrollTop(window.scrollY > 300); + const saveHistory = (history: HistoryItem[]) => { + try { + localStorage.setItem(HISTORY_KEY, JSON.stringify(history)); + } + catch (err) { + console.error("Failed to save history:", err); + } }; - window.addEventListener("scroll", handleScroll); - - return () => { - mediaQuery.removeEventListener("change", handleChange); - window.removeEventListener("scroll", handleScroll); + const addToHistory = (item: Omit) => { + setFetchHistory((prev) => { + const filtered = prev.filter((h) => h.url !== item.url); + const newItem: HistoryItem = { + ...item, + id: crypto.randomUUID(), + timestamp: Date.now(), + }; + const updated = [newItem, ...filtered].slice(0, MAX_HISTORY); + saveHistory(updated); + return updated; + }); }; - }, []); + const removeFromHistory = (id: string) => { + setFetchHistory((prev) => { + const updated = prev.filter((h) => h.id !== id); + saveHistory(updated); + return updated; + }); + }; + const handleHistorySelect = async (item: HistoryItem) => { + setSpotifyUrl(item.url); + const updatedUrl = await metadata.handleFetchMetadata(item.url); + if (updatedUrl) { + setSpotifyUrl(updatedUrl); + } + }; + const handleFetchMetadata = async () => { + const updatedUrl = await metadata.handleFetchMetadata(spotifyUrl); + if (updatedUrl) { + setSpotifyUrl(updatedUrl); + } + }; + useEffect(() => { + if (!metadata.metadata || !spotifyUrl) + return; + let historyItem: Omit | null = null; + if ("track" in metadata.metadata) { + const { track } = metadata.metadata; + historyItem = { + url: spotifyUrl, + type: "track", + name: track.name, + artist: track.artists, + image: track.images, + }; + } + else if ("album_info" in metadata.metadata) { + const { album_info } = metadata.metadata; + historyItem = { + url: spotifyUrl, + type: "album", + name: album_info.name, + artist: `${album_info.total_tracks} tracks`, + image: album_info.images, + }; + } + else if ("playlist_info" in metadata.metadata) { + const { playlist_info } = metadata.metadata; + historyItem = { + url: spotifyUrl, + type: "playlist", + name: playlist_info.owner.name, + artist: `${playlist_info.tracks.total} tracks`, + image: playlist_info.cover || playlist_info.owner.images || "", + }; + } + else if ("artist_info" in metadata.metadata) { + const { artist_info } = metadata.metadata; + historyItem = { + url: spotifyUrl, + type: "artist", + name: artist_info.name, + artist: `${artist_info.total_albums} albums`, + image: artist_info.images, + }; + } + if (historyItem) { + addToHistory(historyItem); + } + }, [metadata.metadata]); + const handleSearchChange = (value: string) => { + setSearchQuery(value); + setCurrentListPage(1); + }; + const toggleTrackSelection = (isrc: string) => { + setSelectedTracks((prev) => prev.includes(isrc) ? prev.filter((id) => id !== isrc) : [...prev, isrc]); + }; + const toggleSelectAll = (tracks: any[]) => { + const tracksWithIsrc = tracks.filter((track) => track.isrc).map((track) => track.isrc); + if (selectedTracks.length === tracksWithIsrc.length) { + setSelectedTracks([]); + } + else { + setSelectedTracks(tracksWithIsrc); + } + }; + const handleOpenFolder = async () => { + const settings = getSettings(); + if (!settings.downloadPath) { + toast.error("Download path not set"); + return; + } + try { + await OpenFolder(settings.downloadPath); + } + catch (error) { + console.error("Error opening folder:", error); + toast.error(`Error opening folder: ${error}`); + } + }; + const renderMetadata = () => { + if (!metadata.metadata) + return null; + if ("track" in metadata.metadata) { + const { track } = metadata.metadata; + return ( lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, undefined, undefined, albumArtist, releaseDate, discNumber)} onCheckAvailability={availability.checkAvailability} onDownloadCover={(coverUrl, trackName, artistName, albumName, _playlistName, _position, trackId, albumArtist, releaseDate, discNumber) => cover.handleDownloadCover(coverUrl, trackName, artistName, albumName, undefined, undefined, trackId, albumArtist, releaseDate, discNumber)} onOpenFolder={handleOpenFolder}/>); + } + if ("album_info" in metadata.metadata) { + const { album_info, track_list } = metadata.metadata; + return ( lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, album_info.name, position, albumArtist, releaseDate, discNumber, true)} onDownloadCover={(coverUrl, trackName, artistName, albumName, _folderName, _isArtistDiscography, position, trackId, albumArtist, releaseDate, discNumber) => cover.handleDownloadCover(coverUrl, trackName, artistName, albumName, album_info.name, position, trackId, albumArtist, releaseDate, discNumber, true)} onCheckAvailability={availability.checkAvailability} onDownloadAllLyrics={() => lyrics.handleDownloadAllLyrics(track_list, album_info.name, undefined, true)} onDownloadAllCovers={() => cover.handleDownloadAllCovers(track_list, album_info.name, true)} onDownloadAll={() => download.handleDownloadAll(track_list, undefined, true)} onDownloadSelected={() => download.handleDownloadSelected(selectedTracks, track_list, undefined, true)} onStopDownload={download.handleStopDownload} onOpenFolder={handleOpenFolder} onPageChange={setCurrentListPage} onArtistClick={async (artist) => { + const artistUrl = await metadata.handleArtistClick(artist); + if (artistUrl) { + setSpotifyUrl(artistUrl); + } + }} onTrackClick={async (track) => { + if (track.external_urls) { + setSpotifyUrl(track.external_urls); + await metadata.handleFetchMetadata(track.external_urls); + } + }}/>); + } + if ("playlist_info" in metadata.metadata) { + const { playlist_info, track_list } = metadata.metadata; + return ( lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, playlist_info.owner.name, position, albumArtist, releaseDate, discNumber)} onDownloadCover={(coverUrl, trackName, artistName, albumName, _folderName, _isArtistDiscography, position, trackId, albumArtist, releaseDate, discNumber) => cover.handleDownloadCover(coverUrl, trackName, artistName, albumName, playlist_info.owner.name, position, trackId, albumArtist, releaseDate, discNumber)} 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={() => download.handleDownloadSelected(selectedTracks, track_list, playlist_info.owner.name)} onStopDownload={download.handleStopDownload} onOpenFolder={handleOpenFolder} onPageChange={setCurrentListPage} onAlbumClick={metadata.handleAlbumClick} onArtistClick={async (artist) => { + const artistUrl = await metadata.handleArtistClick(artist); + if (artistUrl) { + setSpotifyUrl(artistUrl); + } + }} onTrackClick={async (track) => { + if (track.external_urls) { + setSpotifyUrl(track.external_urls); + await metadata.handleFetchMetadata(track.external_urls); + } + }}/>); + } + if ("artist_info" in metadata.metadata) { + const { artist_info, album_list, track_list } = metadata.metadata; + return ( lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, artist_info.name, position, albumArtist, releaseDate, discNumber)} onDownloadCover={(coverUrl, trackName, artistName, albumName, _folderName, _isArtistDiscography, position, trackId, albumArtist, releaseDate, discNumber) => cover.handleDownloadCover(coverUrl, trackName, artistName, albumName, artist_info.name, position, trackId, albumArtist, releaseDate, discNumber)} 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={() => download.handleDownloadSelected(selectedTracks, track_list, artist_info.name)} onStopDownload={download.handleStopDownload} onOpenFolder={handleOpenFolder} onAlbumClick={metadata.handleAlbumClick} onArtistClick={async (artist) => { + const artistUrl = await metadata.handleArtistClick(artist); + if (artistUrl) { + setSpotifyUrl(artistUrl); + } + }} onPageChange={setCurrentListPage} onTrackClick={async (track) => { + if (track.external_urls) { + setSpotifyUrl(track.external_urls); + await metadata.handleFetchMetadata(track.external_urls); + } + }}/>); + } + return null; + }; + const handlePageChange = (page: PageType) => { + if (currentPage === "settings" && hasUnsavedSettings && page !== "settings") { + setPendingPageChange(page); + setShowUnsavedChangesDialog(true); + return; + } + setCurrentPage(page); + }; + const handleDiscardChanges = () => { + setShowUnsavedChangesDialog(false); + if (resetSettingsFn) { + resetSettingsFn(); + } + const savedSettings = getSettings(); + applyThemeMode(savedSettings.themeMode); + applyTheme(savedSettings.theme); + applyFont(savedSettings.fontFamily); + if (pendingPageChange) { + setCurrentPage(pendingPageChange); + setPendingPageChange(null); + } + }; + const handleCancelNavigation = () => { + setShowUnsavedChangesDialog(false); + setPendingPageChange(null); + }; + const renderPage = () => { + switch (currentPage) { + case "settings": + return ; + case "debug": + return ; + case "audio-analysis": + return ; + case "audio-converter": + return ; + case "file-manager": + return ; + default: + return (<> +
- const scrollToTop = useCallback(() => { - window.scrollTo({ top: 0, behavior: "smooth" }); - }, []); - - useEffect(() => { - setSelectedTracks([]); - setSearchQuery(""); - download.resetDownloadedTracks(); - lyrics.resetLyricsState(); - cover.resetCoverState(); - availability.clearAvailability(); - setSortBy("default"); - setCurrentListPage(1); - }, [metadata.metadata]); - - const checkForUpdates = async () => { - try { - const response = await fetch( - "https://api.github.com/repos/afkarxyz/SpotiFLAC/releases/latest" - ); - const data = await response.json(); - const latestVersion = data.tag_name?.replace(/^v/, "") || ""; - - if (data.published_at) { - setReleaseDate(data.published_at); - } - - if (latestVersion && latestVersion > CURRENT_VERSION) { - setHasUpdate(true); - } - } catch (err) { - console.error("Failed to check for updates:", err); - } - }; - - const loadHistory = () => { - try { - const saved = localStorage.getItem(HISTORY_KEY); - if (saved) { - setFetchHistory(JSON.parse(saved)); - } - } catch (err) { - console.error("Failed to load history:", err); - } - }; - - const saveHistory = (history: HistoryItem[]) => { - try { - localStorage.setItem(HISTORY_KEY, JSON.stringify(history)); - } catch (err) { - console.error("Failed to save history:", err); - } - }; - - const addToHistory = (item: Omit) => { - setFetchHistory((prev) => { - const filtered = prev.filter((h) => h.url !== item.url); - const newItem: HistoryItem = { - ...item, - id: crypto.randomUUID(), - timestamp: Date.now(), - }; - const updated = [newItem, ...filtered].slice(0, MAX_HISTORY); - saveHistory(updated); - return updated; - }); - }; - - const removeFromHistory = (id: string) => { - setFetchHistory((prev) => { - const updated = prev.filter((h) => h.id !== id); - saveHistory(updated); - return updated; - }); - }; - - const handleHistorySelect = async (item: HistoryItem) => { - setSpotifyUrl(item.url); - const updatedUrl = await metadata.handleFetchMetadata(item.url); - if (updatedUrl) { - setSpotifyUrl(updatedUrl); - } - }; - - const handleFetchMetadata = async () => { - const updatedUrl = await metadata.handleFetchMetadata(spotifyUrl); - if (updatedUrl) { - setSpotifyUrl(updatedUrl); - } - }; - - useEffect(() => { - if (!metadata.metadata || !spotifyUrl) return; - - let historyItem: Omit | null = null; - - if ("track" in metadata.metadata) { - const { track } = metadata.metadata; - historyItem = { - url: spotifyUrl, - type: "track", - name: track.name, - artist: track.artists, - image: track.images, - }; - } else if ("album_info" in metadata.metadata) { - const { album_info } = metadata.metadata; - historyItem = { - url: spotifyUrl, - type: "album", - name: album_info.name, - artist: album_info.artists, - image: album_info.images, - }; - } else if ("playlist_info" in metadata.metadata) { - const { playlist_info } = metadata.metadata; - historyItem = { - url: spotifyUrl, - type: "playlist", - name: playlist_info.owner.name, - artist: `${playlist_info.tracks.total} tracks • ${playlist_info.owner.display_name}`, - image: playlist_info.owner.images || "", - }; - } else if ("artist_info" in metadata.metadata) { - const { artist_info } = metadata.metadata; - historyItem = { - url: spotifyUrl, - type: "artist", - name: artist_info.name, - artist: `${artist_info.total_albums} albums`, - image: artist_info.images, - }; - } - - if (historyItem) { - addToHistory(historyItem); - } - }, [metadata.metadata]); - - const handleSearchChange = (value: string) => { - setSearchQuery(value); - setCurrentListPage(1); - }; - - const toggleTrackSelection = (isrc: string) => { - setSelectedTracks((prev) => - prev.includes(isrc) ? prev.filter((id) => id !== isrc) : [...prev, isrc] - ); - }; - - const toggleSelectAll = (tracks: any[]) => { - const tracksWithIsrc = tracks.filter((track) => track.isrc).map((track) => track.isrc); - if (selectedTracks.length === tracksWithIsrc.length) { - setSelectedTracks([]); - } else { - setSelectedTracks(tracksWithIsrc); - } - }; - - const handleOpenFolder = async () => { - const settings = getSettings(); - if (!settings.downloadPath) { - toast.error("Download path not set"); - return; - } - - try { - await OpenFolder(settings.downloadPath); - } catch (error) { - console.error("Error opening folder:", error); - toast.error(`Error opening folder: ${error}`); - } - }; - - - const renderMetadata = () => { - if (!metadata.metadata) return null; - - if ("track" in metadata.metadata) { - const { track } = metadata.metadata; - return ( - - lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, undefined, undefined, albumArtist, releaseDate, discNumber) - } - onCheckAvailability={availability.checkAvailability} - onDownloadCover={(coverUrl, trackName, artistName, albumName, _playlistName, _position, trackId, albumArtist, releaseDate, discNumber) => - cover.handleDownloadCover(coverUrl, trackName, artistName, albumName, undefined, undefined, trackId, albumArtist, releaseDate, discNumber) - } - onOpenFolder={handleOpenFolder} - /> - ); - } - - if ("album_info" in metadata.metadata) { - const { album_info, track_list } = metadata.metadata; - return ( - - lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, album_info.name, position, albumArtist, releaseDate, discNumber, true) - } - onDownloadCover={(coverUrl, trackName, artistName, albumName, _folderName, _isArtistDiscography, position, trackId, albumArtist, releaseDate, discNumber) => - cover.handleDownloadCover(coverUrl, trackName, artistName, albumName, album_info.name, position, trackId, albumArtist, releaseDate, discNumber, true) - } - onCheckAvailability={availability.checkAvailability} - onDownloadAllLyrics={() => lyrics.handleDownloadAllLyrics(track_list, album_info.name, undefined, true)} - onDownloadAllCovers={() => cover.handleDownloadAllCovers(track_list, album_info.name, true)} - onDownloadAll={() => download.handleDownloadAll(track_list, undefined, true)} - onDownloadSelected={() => - download.handleDownloadSelected(selectedTracks, track_list, undefined, true) - } - onStopDownload={download.handleStopDownload} - onOpenFolder={handleOpenFolder} - onPageChange={setCurrentListPage} - onArtistClick={async (artist) => { - const artistUrl = await metadata.handleArtistClick(artist); - if (artistUrl) { - setSpotifyUrl(artistUrl); - } - }} - onTrackClick={async (track) => { - if (track.external_urls) { - setSpotifyUrl(track.external_urls); - await metadata.handleFetchMetadata(track.external_urls); - } - }} - /> - ); - } - - if ("playlist_info" in metadata.metadata) { - const { playlist_info, track_list } = metadata.metadata; - return ( - - lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, playlist_info.owner.name, position, albumArtist, releaseDate, discNumber) - } - onDownloadCover={(coverUrl, trackName, artistName, albumName, _folderName, _isArtistDiscography, position, trackId, albumArtist, releaseDate, discNumber) => - cover.handleDownloadCover(coverUrl, trackName, artistName, albumName, playlist_info.owner.name, position, trackId, albumArtist, releaseDate, discNumber) - } - 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={() => - download.handleDownloadSelected( - selectedTracks, - track_list, - playlist_info.owner.name - ) - } - onStopDownload={download.handleStopDownload} - onOpenFolder={handleOpenFolder} - onPageChange={setCurrentListPage} - onAlbumClick={metadata.handleAlbumClick} - onArtistClick={async (artist) => { - const artistUrl = await metadata.handleArtistClick(artist); - if (artistUrl) { - setSpotifyUrl(artistUrl); - } - }} - onTrackClick={async (track) => { - if (track.external_urls) { - setSpotifyUrl(track.external_urls); - await metadata.handleFetchMetadata(track.external_urls); - } - }} - /> - ); - } - - if ("artist_info" in metadata.metadata) { - const { artist_info, album_list, track_list } = metadata.metadata; - return ( - - lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, artist_info.name, position, albumArtist, releaseDate, discNumber) - } - onDownloadCover={(coverUrl, trackName, artistName, albumName, _folderName, _isArtistDiscography, position, trackId, albumArtist, releaseDate, discNumber) => - cover.handleDownloadCover(coverUrl, trackName, artistName, albumName, artist_info.name, position, trackId, albumArtist, releaseDate, discNumber) - } - 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={() => - download.handleDownloadSelected(selectedTracks, track_list, artist_info.name) - } - onStopDownload={download.handleStopDownload} - onOpenFolder={handleOpenFolder} - onAlbumClick={metadata.handleAlbumClick} - onArtistClick={async (artist) => { - const artistUrl = await metadata.handleArtistClick(artist); - if (artistUrl) { - setSpotifyUrl(artistUrl); - } - }} - onPageChange={setCurrentListPage} - onTrackClick={async (track) => { - if (track.external_urls) { - setSpotifyUrl(track.external_urls); - await metadata.handleFetchMetadata(track.external_urls); - } - }} - /> - ); - } - - return null; - }; - - - const renderPage = () => { - switch (currentPage) { - case "settings": - return ; - case "debug": - return ; - case "audio-analysis": - return ; - case "audio-converter": - return ; - case "file-manager": - return ; - default: - return ( - <> -
- - {/* Timeout Dialog */} - + +
-
Fetch Artist @@ -577,22 +354,13 @@ function App() { Set timeout for fetching metadata. Longer timeout is recommended for artists with large discography. - {metadata.pendingArtistName && ( -
+ {metadata.pendingArtistName && (

{metadata.pendingArtistName}

-
- )} +
)}
- metadata.setTimeoutValue(Number(e.target.value))} - /> + metadata.setTimeoutValue(Number(e.target.value))}/>

Default: 60 seconds. For large discographies, try 300-600 seconds (5-10 minutes). @@ -600,120 +368,104 @@ function App() {

-
- {/* Album Fetch Dialog */} +
-
Fetch Album Do you want to fetch metadata for this album? - {metadata.selectedAlbum && ( -
+ {metadata.selectedAlbum && (

{metadata.selectedAlbum.name}

-
- )} +
)}
- { - setSpotifyUrl(url); - const updatedUrl = await metadata.handleFetchMetadata(url); - if (updatedUrl) { - setSpotifyUrl(updatedUrl); - } - }} - history={fetchHistory} - onHistorySelect={handleHistorySelect} - onHistoryRemove={removeFromHistory} - hasResult={!!metadata.metadata} - searchMode={isSearchMode} - onSearchModeChange={setIsSearchMode} - /> + { + setSpotifyUrl(url); + const updatedUrl = await metadata.handleFetchMetadata(url); + if (updatedUrl) { + setSpotifyUrl(updatedUrl); + } + }} history={fetchHistory} onHistorySelect={handleHistorySelect} onHistoryRemove={removeFromHistory} hasResult={!!metadata.metadata} searchMode={isSearchMode} onSearchModeChange={setIsSearchMode}/> {!isSearchMode && metadata.metadata && renderMetadata()} - - ); - } - }; - - return ( - + ); + } + }; + return (
- + - {/* Main content area with sidebar offset */} +
{renderPage()}
- {/* Download Progress Toast - Bottom Left */} - + + - {/* Download Queue Dialog */} - + + - {/* Jump to Top Button - Bottom Right */} - {showScrollTop && ( - - )} + + {showScrollTop && ()} + + + + + + Unsaved Changes + + You have unsaved changes in Settings. Are you sure you want to leave? Your changes will be lost. + + + + + + + +
-
- ); +
); } - export default App; diff --git a/frontend/src/components/AlbumInfo.tsx b/frontend/src/components/AlbumInfo.tsx index b16a2f7..acbb7e7 100644 --- a/frontend/src/components/AlbumInfo.tsx +++ b/frontend/src/components/AlbumInfo.tsx @@ -7,144 +7,85 @@ import { SearchAndSort } from "./SearchAndSort"; import { TrackList } from "./TrackList"; import { DownloadProgress } from "./DownloadProgress"; import type { TrackMetadata, TrackAvailability } from "@/types/api"; - interface AlbumInfoProps { - albumInfo: { - name: string; - artists: string; - images: string; - release_date: string; - total_tracks: number; - artist_id?: string; - artist_url?: string; - }; - trackList: TrackMetadata[]; - searchQuery: string; - sortBy: string; - selectedTracks: string[]; - downloadedTracks: Set; - failedTracks: Set; - skippedTracks: Set; - downloadingTrack: string | null; - isDownloading: boolean; - bulkDownloadType: "all" | "selected" | null; - downloadProgress: number; - currentDownloadInfo: { name: string; artists: string } | null; - currentPage: number; - itemsPerPage: number; - // Lyrics props - downloadedLyrics?: Set; - failedLyrics?: Set; - skippedLyrics?: Set; - downloadingLyricsTrack?: string | null; - // Availability props - checkingAvailabilityTrack?: string | null; - availabilityMap?: Map; - // Cover props - downloadedCovers?: Set; - failedCovers?: Set; - skippedCovers?: Set; - 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, 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, albumArtist?: string, releaseDate?: string, discNumber?: number) => void; - onDownloadCover?: (coverUrl: string, trackName: string, artistName: string, albumName: string, folderName?: string, isArtistDiscography?: boolean, position?: number, trackId?: string, albumArtist?: string, releaseDate?: string, discNumber?: number) => void; - onCheckAvailability?: (spotifyId: string) => void; - onDownloadAllLyrics?: () => void; - onDownloadAllCovers?: () => void; - onDownloadAll: () => void; - onDownloadSelected: () => void; - onStopDownload: () => void; - onOpenFolder: () => void; - onPageChange: (page: number) => void; - onArtistClick?: (artist: { id: string; name: string; external_urls: string }) => void; - onTrackClick?: (track: TrackMetadata) => void; + albumInfo: { + name: string; + artists: string; + images: string; + release_date: string; + total_tracks: number; + artist_id?: string; + artist_url?: string; + }; + trackList: TrackMetadata[]; + searchQuery: string; + sortBy: string; + selectedTracks: string[]; + downloadedTracks: Set; + failedTracks: Set; + skippedTracks: Set; + downloadingTrack: string | null; + isDownloading: boolean; + bulkDownloadType: "all" | "selected" | null; + downloadProgress: number; + currentDownloadInfo: { + name: string; + artists: string; + } | null; + currentPage: number; + itemsPerPage: number; + downloadedLyrics?: Set; + failedLyrics?: Set; + skippedLyrics?: Set; + downloadingLyricsTrack?: string | null; + checkingAvailabilityTrack?: string | null; + availabilityMap?: Map; + downloadedCovers?: Set; + failedCovers?: Set; + skippedCovers?: Set; + 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, folderName?: string, durationMs?: number, position?: number, albumArtist?: string, releaseDate?: string, coverUrl?: string, spotifyTrackNumber?: number, spotifyDiscNumber?: number, spotifyTotalTracks?: number, spotifyTotalDiscs?: number, copyright?: string, publisher?: string) => void; + onDownloadLyrics?: (spotifyId: string, name: string, artists: string, albumName: string, folderName?: string, isArtistDiscography?: boolean, position?: number, albumArtist?: string, releaseDate?: string, discNumber?: number) => void; + onDownloadCover?: (coverUrl: string, trackName: string, artistName: string, albumName: string, folderName?: string, isArtistDiscography?: boolean, position?: number, trackId?: string, albumArtist?: string, releaseDate?: string, discNumber?: number) => void; + onCheckAvailability?: (spotifyId: string) => void; + onDownloadAllLyrics?: () => void; + onDownloadAllCovers?: () => void; + onDownloadAll: () => void; + onDownloadSelected: () => void; + onStopDownload: () => void; + onOpenFolder: () => void; + onPageChange: (page: number) => void; + onArtistClick?: (artist: { + id: string; + name: string; + external_urls: string; + }) => void; + onTrackClick?: (track: TrackMetadata) => void; } - -export function AlbumInfo({ - albumInfo, - trackList, - searchQuery, - sortBy, - selectedTracks, - downloadedTracks, - failedTracks, - skippedTracks, - downloadingTrack, - isDownloading, - bulkDownloadType, - downloadProgress, - currentDownloadInfo, - currentPage, - itemsPerPage, - downloadedLyrics, - failedLyrics, - skippedLyrics, - downloadingLyricsTrack, - checkingAvailabilityTrack, - availabilityMap, - downloadedCovers, - failedCovers, - skippedCovers, - downloadingCoverTrack, - isBulkDownloadingCovers, - isBulkDownloadingLyrics, - onSearchChange, - onSortChange, - onToggleTrack, - onToggleSelectAll, - onDownloadTrack, - onDownloadLyrics, - onDownloadCover, - onCheckAvailability, - onDownloadAllLyrics, - onDownloadAllCovers, - onDownloadAll, - onDownloadSelected, - onStopDownload, - onOpenFolder, - onPageChange, - onArtistClick, - onTrackClick, -}: AlbumInfoProps) { - return ( -
+export function AlbumInfo({ albumInfo, trackList, searchQuery, sortBy, selectedTracks, downloadedTracks, failedTracks, skippedTracks, downloadingTrack, isDownloading, bulkDownloadType, downloadProgress, currentDownloadInfo, currentPage, itemsPerPage, downloadedLyrics, failedLyrics, skippedLyrics, downloadingLyricsTrack, checkingAvailabilityTrack, availabilityMap, downloadedCovers, failedCovers, skippedCovers, downloadingCoverTrack, isBulkDownloadingCovers, isBulkDownloadingLyrics, onSearchChange, onSortChange, onToggleTrack, onToggleSelectAll, onDownloadTrack, onDownloadLyrics, onDownloadCover, onCheckAvailability, onDownloadAllLyrics, onDownloadAllCovers, onDownloadAll, onDownloadSelected, onStopDownload, onOpenFolder, onPageChange, onArtistClick, onTrackClick, }: AlbumInfoProps) { + return (
- {albumInfo.images && ( - {albumInfo.name} - )} + {albumInfo.images && ({albumInfo.name})}

Album

{albumInfo.name}

- {onArtistClick && albumInfo.artist_id && albumInfo.artist_url ? ( - - onArtistClick({ - id: albumInfo.artist_id!, - name: albumInfo.artists, - external_urls: albumInfo.artist_url!, - }) - } - > + {onArtistClick && albumInfo.artist_id && albumInfo.artist_url ? ( onArtistClick({ + id: albumInfo.artist_id!, + name: albumInfo.artists, + external_urls: albumInfo.artist_url!, + })}> {albumInfo.artists} - - ) : ( - {albumInfo.artists} - )} + ) : ({albumInfo.artists})} {albumInfo.release_date} @@ -155,119 +96,46 @@ export function AlbumInfo({
- {selectedTracks.length > 0 && ( - - )} - {onDownloadAllLyrics && ( - + )} + {onDownloadAllLyrics && ( -

Download All Lyrics

-
- )} - {onDownloadAllCovers && ( - + )} + {onDownloadAllCovers && ( -

Download All Covers

-
- )} - {downloadedTracks.size > 0 && ( - - )} + )}
- {isDownloading && ( - - )} + {isDownloading && ()}
- - + +
-
- ); +
); } diff --git a/frontend/src/components/ArtistInfo.tsx b/frontend/src/components/ArtistInfo.tsx index c8678dd..71e14e4 100644 --- a/frontend/src/components/ArtistInfo.tsx +++ b/frontend/src/components/ArtistInfo.tsx @@ -1,315 +1,451 @@ import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; -import { Download, FolderOpen, ImageDown, FileText } from "lucide-react"; +import { Download, FolderOpen, ImageDown, FileText, BadgeCheck } from "lucide-react"; import { Spinner } from "@/components/ui/spinner"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { SearchAndSort } from "./SearchAndSort"; import { TrackList } from "./TrackList"; import { DownloadProgress } from "./DownloadProgress"; import type { TrackMetadata, TrackAvailability } from "@/types/api"; - +import { downloadHeader, downloadGalleryImage, downloadAvatar } from "@/lib/api"; +import { getSettings } from "@/lib/settings"; +import { toastWithSound as toast } from "@/lib/toast-with-sound"; +import { useState } from "react"; interface ArtistInfoProps { - artistInfo: { - name: string; - images: string; - followers: number; - genres: string[]; - }; - albumList: Array<{ - id: string; - name: string; - images: string; - release_date: string; - album_type: string; - external_urls: string; - }>; - trackList: TrackMetadata[]; - searchQuery: string; - sortBy: string; - selectedTracks: string[]; - downloadedTracks: Set; - failedTracks: Set; - skippedTracks: Set; - downloadingTrack: string | null; - isDownloading: boolean; - bulkDownloadType: "all" | "selected" | null; - downloadProgress: number; - currentDownloadInfo: { name: string; artists: string } | null; - currentPage: number; - itemsPerPage: number; - // Lyrics props - downloadedLyrics?: Set; - failedLyrics?: Set; - skippedLyrics?: Set; - downloadingLyricsTrack?: string | null; - // Availability props - checkingAvailabilityTrack?: string | null; - availabilityMap?: Map; - // Cover props - downloadedCovers?: Set; - failedCovers?: Set; - skippedCovers?: Set; - 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, 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, albumArtist?: string, releaseDate?: string, discNumber?: number) => void; - onDownloadCover?: (coverUrl: string, trackName: string, artistName: string, albumName: string, folderName?: string, isArtistDiscography?: boolean, position?: number, trackId?: string, albumArtist?: string, releaseDate?: string, discNumber?: number) => void; - onCheckAvailability?: (spotifyId: string) => void; - onDownloadAllLyrics?: () => void; - onDownloadAllCovers?: () => void; - onDownloadAll: () => void; - onDownloadSelected: () => void; - onStopDownload: () => void; - onOpenFolder: () => void; - onAlbumClick: (album: { id: string; name: string; external_urls: string }) => void; - onArtistClick: (artist: { id: string; name: string; external_urls: string }) => void; - onPageChange: (page: number) => void; - onTrackClick?: (track: TrackMetadata) => void; + artistInfo: { + name: string; + images: string; + header?: string; + gallery?: string[]; + followers: number; + genres: string[]; + biography?: string; + verified?: boolean; + listeners?: number; + rank?: number; + }; + albumList: Array<{ + id: string; + name: string; + images: string; + release_date: string; + album_type: string; + external_urls: string; + }>; + trackList: TrackMetadata[]; + searchQuery: string; + sortBy: string; + selectedTracks: string[]; + downloadedTracks: Set; + failedTracks: Set; + skippedTracks: Set; + downloadingTrack: string | null; + isDownloading: boolean; + bulkDownloadType: "all" | "selected" | null; + downloadProgress: number; + currentDownloadInfo: { + name: string; + artists: string; + } | null; + currentPage: number; + itemsPerPage: number; + downloadedLyrics?: Set; + failedLyrics?: Set; + skippedLyrics?: Set; + downloadingLyricsTrack?: string | null; + checkingAvailabilityTrack?: string | null; + availabilityMap?: Map; + downloadedCovers?: Set; + failedCovers?: Set; + skippedCovers?: Set; + 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, folderName?: string, durationMs?: number, position?: number, albumArtist?: string, releaseDate?: string, coverUrl?: string, spotifyTrackNumber?: number, spotifyDiscNumber?: number, spotifyTotalTracks?: number, spotifyTotalDiscs?: number, copyright?: string, publisher?: string) => void; + onDownloadLyrics?: (spotifyId: string, name: string, artists: string, albumName: string, folderName?: string, isArtistDiscography?: boolean, position?: number, albumArtist?: string, releaseDate?: string, discNumber?: number) => void; + onDownloadCover?: (coverUrl: string, trackName: string, artistName: string, albumName: string, folderName?: string, isArtistDiscography?: boolean, position?: number, trackId?: string, albumArtist?: string, releaseDate?: string, discNumber?: number) => void; + onCheckAvailability?: (spotifyId: string) => void; + onDownloadAllLyrics?: () => void; + onDownloadAllCovers?: () => void; + onDownloadAll: () => void; + onDownloadSelected: () => void; + onStopDownload: () => void; + onOpenFolder: () => void; + onAlbumClick: (album: { + id: string; + name: string; + external_urls: string; + }) => void; + onArtistClick: (artist: { + id: string; + name: string; + external_urls: string; + }) => void; + onPageChange: (page: number) => void; + onTrackClick?: (track: TrackMetadata) => void; } - -export function ArtistInfo({ - artistInfo, - albumList, - trackList, - searchQuery, - sortBy, - selectedTracks, - downloadedTracks, - failedTracks, - skippedTracks, - downloadingTrack, - isDownloading, - bulkDownloadType, - downloadProgress, - currentDownloadInfo, - currentPage, - itemsPerPage, - downloadedLyrics, - failedLyrics, - skippedLyrics, - downloadingLyricsTrack, - checkingAvailabilityTrack, - availabilityMap, - downloadedCovers, - failedCovers, - skippedCovers, - downloadingCoverTrack, - isBulkDownloadingCovers, - isBulkDownloadingLyrics, - onSearchChange, - onSortChange, - onToggleTrack, - onToggleSelectAll, - onDownloadTrack, - onDownloadLyrics, - onDownloadCover, - onCheckAvailability, - onDownloadAllLyrics, - onDownloadAllCovers, - onDownloadAll, - onDownloadSelected, - onStopDownload, - onOpenFolder, - onAlbumClick, - onArtistClick, - onPageChange, - onTrackClick, -}: ArtistInfoProps) { - return ( -
- - -
- {artistInfo.images && ( - {artistInfo.name} - )} -
-

Artist

-

{artistInfo.name}

-
- {artistInfo.followers.toLocaleString()} followers - - {albumList.length} albums - - {trackList.length} tracks - {artistInfo.genres.length > 0 && ( - <> - - {artistInfo.genres.join(", ")} - - )} +export function ArtistInfo({ artistInfo, albumList, trackList, searchQuery, sortBy, selectedTracks, downloadedTracks, failedTracks, skippedTracks, downloadingTrack, isDownloading, bulkDownloadType, downloadProgress, currentDownloadInfo, currentPage, itemsPerPage, downloadedLyrics, failedLyrics, skippedLyrics, downloadingLyricsTrack, checkingAvailabilityTrack, availabilityMap, downloadedCovers, failedCovers, skippedCovers, downloadingCoverTrack, isBulkDownloadingCovers, isBulkDownloadingLyrics, onSearchChange, onSortChange, onToggleTrack, onToggleSelectAll, onDownloadTrack, onDownloadLyrics, onDownloadCover, onCheckAvailability, onDownloadAllLyrics, onDownloadAllCovers, onDownloadAll, onDownloadSelected, onStopDownload, onOpenFolder, onAlbumClick, onArtistClick, onPageChange, onTrackClick, }: ArtistInfoProps) { + const [downloadingHeader, setDownloadingHeader] = useState(false); + const [downloadingAvatar, setDownloadingAvatar] = useState(false); + const [downloadingGalleryIndex, setDownloadingGalleryIndex] = useState(null); + const [downloadingAllGallery, setDownloadingAllGallery] = useState(false); + const handleDownloadHeader = async () => { + if (!artistInfo.header) + return; + setDownloadingHeader(true); + try { + const settings = getSettings(); + const response = await downloadHeader({ + header_url: artistInfo.header, + artist_name: artistInfo.name, + output_dir: settings.downloadPath, + }); + if (response.success) { + if (response.already_exists) { + toast.info("Header already exists"); + } + else { + toast.success("Header downloaded successfully"); + } + } + else { + toast.error(response.error || "Failed to download header"); + } + } + catch (error) { + toast.error(`Error downloading header: ${error}`); + } + finally { + setDownloadingHeader(false); + } + }; + const handleDownloadAvatar = async () => { + if (!artistInfo.images) + return; + setDownloadingAvatar(true); + try { + const settings = getSettings(); + const response = await downloadAvatar({ + avatar_url: artistInfo.images, + artist_name: artistInfo.name, + output_dir: settings.downloadPath, + }); + if (response.success) { + if (response.already_exists) { + toast.info("Avatar already exists"); + } + else { + toast.success("Avatar downloaded successfully"); + } + } + else { + toast.error(response.error || "Failed to download avatar"); + } + } + catch (error) { + toast.error(`Error downloading avatar: ${error}`); + } + finally { + setDownloadingAvatar(false); + } + }; + const handleDownloadGalleryImage = async (imageUrl: string, index: number) => { + setDownloadingGalleryIndex(index); + try { + const settings = getSettings(); + const response = await downloadGalleryImage({ + image_url: imageUrl, + artist_name: artistInfo.name, + image_index: index, + output_dir: settings.downloadPath, + }); + if (response.success) { + if (response.already_exists) { + toast.info(`Gallery image ${index + 1} already exists`); + } + else { + toast.success(`Gallery image ${index + 1} downloaded successfully`); + } + } + else { + toast.error(response.error || `Failed to download gallery image ${index + 1}`); + } + } + catch (error) { + toast.error(`Error downloading gallery image ${index + 1}: ${error}`); + } + finally { + setDownloadingGalleryIndex(null); + } + }; + const handleDownloadAllGallery = async () => { + if (!artistInfo.gallery || artistInfo.gallery.length === 0) + return; + setDownloadingAllGallery(true); + try { + const settings = getSettings(); + let successCount = 0; + let existsCount = 0; + let failCount = 0; + for (let index = 0; index < artistInfo.gallery.length; index++) { + const imageUrl = artistInfo.gallery[index]; + try { + const response = await downloadGalleryImage({ + image_url: imageUrl, + artist_name: artistInfo.name, + image_index: index, + output_dir: settings.downloadPath, + }); + if (response.success) { + if (response.already_exists) { + existsCount++; + } + else { + successCount++; + } + } + else { + failCount++; + } + } + catch (error) { + failCount++; + } + } + if (failCount === 0) { + if (existsCount > 0 && successCount > 0) { + toast.success(`${successCount} images downloaded, ${existsCount} already existed`); + } + else if (existsCount > 0) { + toast.info(`All ${existsCount} images already exist`); + } + else { + toast.success(`All ${successCount} gallery images downloaded successfully`); + } + } + else { + toast.error(`${failCount} images failed to download`); + } + } + catch (error) { + toast.error(`Error downloading gallery images: ${error}`); + } + finally { + setDownloadingAllGallery(false); + } + }; + return (
+ + {artistInfo.header ? (<> +
+
+
+
+ + + + + +

Download Header

+
+
+
+
+
+ {artistInfo.images && (
+ {artistInfo.name} +
+ + + + + +

Download Avatar

+
+
+
+
)} +
+

Artist

+
+

{artistInfo.name}

+ {artistInfo.verified && ()} +
+ {artistInfo.biography && (

{artistInfo.biography}

)} +
+ {artistInfo.followers.toLocaleString()} followers + {artistInfo.listeners && (<> + + {artistInfo.listeners.toLocaleString()} listeners + )} + {artistInfo.rank && (<> + + #{artistInfo.rank} rank + )} + + {albumList.length} {albumList.length === 1 ? "album" : "albums"} + + {trackList.length} {trackList.length === 1 ? "track" : "tracks"} + {artistInfo.genres.length > 0 && (<> + + {artistInfo.genres.join(", ")} + )} +
+
+
-
- + ) : ( +
+ {artistInfo.images && (
+ {artistInfo.name} +
+ + + + + +

Download Avatar

+
+
+
+
)} +
+

Artist

+
+

{artistInfo.name}

+ {artistInfo.verified && ()} +
+ {artistInfo.biography && (

{artistInfo.biography}

)} +
+ {artistInfo.followers.toLocaleString()} followers + {artistInfo.listeners && (<> + + {artistInfo.listeners.toLocaleString()} listeners + )} + {artistInfo.rank && (<> + + #{artistInfo.rank} rank + )} + + {albumList.length} albums + + {trackList.length} tracks + {artistInfo.genres.length > 0 && (<> + + {artistInfo.genres.join(", ")} + )} +
+
+
+
)} - {albumList.length > 0 && ( -
+ {artistInfo.gallery && artistInfo.gallery.length > 0 && (
+
+

Gallery ({artistInfo.gallery.length})

+ + + + + +

Download All Gallery

+
+
+
+
+ {artistInfo.gallery.map((imageUrl, index) => (
+
+ {`${artistInfo.name} +
+ + + + + +

Download Image {index + 1}

+
+
+
+
+
))} +
+
)} + + {albumList.length > 0 && (

Discography

- {albumList.map((album) => ( -
- onAlbumClick({ + {albumList.map((album) => (
onAlbumClick({ id: album.id, name: album.name, external_urls: album.external_urls, - }) - } - > + })}>
- {album.images && ( - {album.name} - )} + {album.images && ({album.name})}

{album.name}

- {album.release_date?.split("-")[0]} • {album.album_type} + {album.release_date?.split("-")[0]}

-
- ))} +
))}
-
- )} +
)} - {trackList.length > 0 && ( -
+ {trackList.length > 0 && (
-

Popular Tracks

+

All Tracks

- {selectedTracks.length > 0 && ( - - )} - {onDownloadAllLyrics && ( - + )} + {onDownloadAllLyrics && ( -

Download All Lyrics

-
- )} - {onDownloadAllCovers && ( - + )} + {onDownloadAllCovers && ( -

Download All Covers

-
- )} - {downloadedTracks.size > 0 && ( - - )} + )}
- {isDownloading && ( - - )} - - -
- )} -
- ); + {isDownloading && ()} + + +
)} +
); } diff --git a/frontend/src/components/AudioAnalysis.tsx b/frontend/src/components/AudioAnalysis.tsx index 098eaac..7976465 100644 --- a/frontend/src/components/AudioAnalysis.tsx +++ b/frontend/src/components/AudioAnalysis.tsx @@ -1,144 +1,109 @@ import { Card, CardContent, CardHeader } from "@/components/ui/card"; import { Spinner } from "@/components/ui/spinner"; import { Button } from "@/components/ui/button"; -import { - Activity, - Waves, - Radio, - TrendingUp, - FileAudio, - Clock, - Gauge, - HardDrive -} from "lucide-react"; +import { Activity, Waves, Radio, TrendingUp, FileAudio, Clock, Gauge, HardDrive } from "lucide-react"; import type { AnalysisResult } from "@/types/api"; - interface AudioAnalysisProps { - result: AnalysisResult | null; - analyzing: boolean; - onAnalyze?: () => void; - showAnalyzeButton?: boolean; - filePath?: string; + result: AnalysisResult | null; + analyzing: boolean; + onAnalyze?: () => void; + showAnalyzeButton?: boolean; + filePath?: string; } - -export function AudioAnalysis({ - result, - analyzing, - onAnalyze, - showAnalyzeButton = true, - filePath -}: AudioAnalysisProps) { - if (analyzing) { - return ( - +export function AudioAnalysis({ result, analyzing, onAnalyze, showAnalyzeButton = true, filePath }: AudioAnalysisProps) { + if (analyzing) { + return (
Analyzing audio quality...
-
- ); - } - - if (!result && showAnalyzeButton) { - return ( - + ); + } + if (!result && showAnalyzeButton) { + return (
- +

Audio Quality Analysis

Verify the true lossless quality of downloaded files

- {onAnalyze && ( - - )} + )}
-
- ); - } - - if (!result) { - return null; - } - - const formatDuration = (seconds: number) => { - const mins = Math.floor(seconds / 60); - const secs = Math.floor(seconds % 60); - return `${mins}:${secs.toString().padStart(2, '0')}`; - }; - - const formatNumber = (num: number) => { - return num.toFixed(2); - }; - - 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; - - return ( - + ); + } + if (!result) { + return null; + } + const formatDuration = (seconds: number) => { + const mins = Math.floor(seconds / 60); + const secs = Math.floor(seconds % 60); + return `${mins}:${secs.toString().padStart(2, '0')}`; + }; + const formatNumber = (num: number) => { + return num.toFixed(2); + }; + 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]; + }; + const nyquistFreq = result.sample_rate / 2; + return ( - {filePath && ( -

{filePath}

- )} + {filePath && (

{filePath}

)}
- {/* Audio Properties - Single line */} +
- + Sample Rate: {(result.sample_rate / 1000).toFixed(1)} kHz
- + Bit Depth: {result.bit_depth}
- + Channels: {result.channels === 2 ? "Stereo" : result.channels === 1 ? "Mono" : `${result.channels}`}
- + Duration: {formatDuration(result.duration)}
- + Nyquist: {(nyquistFreq / 1000).toFixed(1)} kHz
- {result.file_size > 0 && ( -
- + {result.file_size > 0 && (
+ Size: {formatFileSize(result.file_size)} -
- )} +
)}
- {/* Dynamic Range - Single line */} +
- + Dynamic Range: {formatNumber(result.dynamic_range)} dB
@@ -156,6 +121,5 @@ export function AudioAnalysis({
- - ); + ); } diff --git a/frontend/src/components/AudioAnalysisPage.tsx b/frontend/src/components/AudioAnalysisPage.tsx index bb90977..503afb2 100644 --- a/frontend/src/components/AudioAnalysisPage.tsx +++ b/frontend/src/components/AudioAnalysisPage.tsx @@ -7,149 +7,107 @@ import { useAudioAnalysis } from "@/hooks/useAudioAnalysis"; import { SelectFile } from "../../wailsjs/go/main/App"; import { toastWithSound as toast } from "@/lib/toast-with-sound"; import { OnFileDrop, OnFileDropOff } from "../../wailsjs/runtime/runtime"; - interface AudioAnalysisPageProps { - onBack?: () => void; + onBack?: () => void; } - export function AudioAnalysisPage({ onBack }: AudioAnalysisPageProps) { - const { analyzing, result, analyzeFile, clearResult, selectedFilePath, spectrumLoading } = useAudioAnalysis(); - const [isDragging, setIsDragging] = useState(false); - - const handleSelectFile = async () => { - try { - const filePath = await SelectFile(); - if (filePath) { - await analyzeFile(filePath); - } - } catch (err) { - toast.error("File Selection Failed", { - description: err instanceof Error ? err.message : "Failed to select file", - }); - } - }; - - const handleFileDrop = useCallback( - async (_x: number, _y: number, paths: string[]) => { - setIsDragging(false); - - if (paths.length === 0) return; - - const filePath = paths[0]; - - if (!filePath.toLowerCase().endsWith(".flac")) { - toast.error("Invalid File Type", { - description: "Please drop a FLAC file for analysis", - }); - return; - } - - await analyzeFile(filePath); - }, - [analyzeFile] - ); - - useEffect(() => { - OnFileDrop((x, y, paths) => { - handleFileDrop(x, y, paths); - }, true); - - return () => { - OnFileDropOff(); + const { analyzing, result, analyzeFile, clearResult, selectedFilePath, spectrumLoading } = useAudioAnalysis(); + const [isDragging, setIsDragging] = useState(false); + const handleSelectFile = async () => { + try { + const filePath = await SelectFile(); + if (filePath) { + await analyzeFile(filePath); + } + } + catch (err) { + toast.error("File Selection Failed", { + description: err instanceof Error ? err.message : "Failed to select file", + }); + } }; - }, [handleFileDrop]); - - const handleAnalyzeAnother = () => { - clearResult(); - }; - - return ( -
- {/* Header */} + const handleFileDrop = useCallback(async (_x: number, _y: number, paths: string[]) => { + setIsDragging(false); + if (paths.length === 0) + return; + const filePath = paths[0]; + if (!filePath.toLowerCase().endsWith(".flac")) { + toast.error("Invalid File Type", { + description: "Please drop a FLAC file for analysis", + }); + return; + } + await analyzeFile(filePath); + }, [analyzeFile]); + useEffect(() => { + OnFileDrop((x, y, paths) => { + handleFileDrop(x, y, paths); + }, true); + return () => { + OnFileDropOff(); + }; + }, [handleFileDrop]); + const handleAnalyzeAnother = () => { + clearResult(); + }; + return (
+
- {onBack && ( - - )} + {onBack && ()}

Audio Quality Analyzer

- {result && ( - - )} + )}
- {/* File Selection */} - {!result && !analyzing && ( -
{ - e.preventDefault(); - setIsDragging(true); - }} - onDragLeave={(e) => { - e.preventDefault(); - setIsDragging(false); - }} - onDrop={(e) => { - e.preventDefault(); - setIsDragging(false); - }} - style={{ "--wails-drop-target": "drop" } as React.CSSProperties} - > + + {!result && !analyzing && (
{ + e.preventDefault(); + setIsDragging(true); + }} onDragLeave={(e) => { + e.preventDefault(); + setIsDragging(false); + }} onDrop={(e) => { + e.preventDefault(); + setIsDragging(false); + }} style={{ "--wails-drop-target": "drop" } as React.CSSProperties}>
- +

{isDragging - ? "Drop your FLAC file here" - : "Drag and drop a FLAC file here, or click the button below to select"} + ? "Drop your FLAC file here" + : "Drag and drop a FLAC file here, or click the button below to select"}

-
- )} +
)} - {/* Loading State */} - {analyzing && !result && ( -
+ + {analyzing && !result && (

Analyzing audio file...

-
- )} +
)} - {/* Analysis Results */} - {result && ( -
- {/* Detailed Analysis */} - + + {result && (
+ + - {/* Spectrum Visualization */} - {spectrumLoading ? ( -
+ + {spectrumLoading ? (

Loading spectrum data...

-
- ) : ( - - )} -
- )} -
- ); +
) : ()} +
)} +
); } diff --git a/frontend/src/components/AudioConverterPage.tsx b/frontend/src/components/AudioConverterPage.tsx index 81a876e..b8ad9df 100644 --- a/frontend/src/components/AudioConverterPage.tsx +++ b/frontend/src/components/AudioConverterPage.tsx @@ -1,509 +1,411 @@ 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 { Progress } from "@/components/ui/progress"; +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 { 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"; - +import { useDownloadProgress } from "@/hooks/useDownloadProgress"; interface AudioFile { - path: string; - name: string; - format: string; - size: number; - status: "pending" | "converting" | "success" | "error"; - error?: string; - outputPath?: string; + 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]; + 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" }, + { 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" }, + { value: "aac", label: "AAC" }, + { value: "alac", label: "ALAC" }, ]; - const STORAGE_KEY = "spotiflac_audio_converter_state"; - export function AudioConverterPage() { - const [ffmpegInstalled, setFfmpegInstalled] = useState(false); - const [installingFfmpeg, setInstallingFfmpeg] = useState(false); - const [files, setFiles] = useState(() => { - // 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; + const [ffmpegInstalled, setFfmpegInstalled] = useState(false); + const [installingFfmpeg, setInstallingFfmpeg] = useState(false); + const downloadProgress = useDownloadProgress(); + const [files, setFiles] = useState(() => { + 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) { + console.error("Failed to load saved state:", err); } - } - } 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; + 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 "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) { + } + 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) { + } + 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) { + } + return "aac"; + }); + const [converting, setConverting] = useState(false); + const [isDragging, setIsDragging] = useState(false); + const [isFullscreen, setIsFullscreen] = useState(false); + 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); + } + }, []); + useEffect(() => { + checkFfmpegInstallation(); + }, []); + useEffect(() => { + saveState({ files, outputFormat, bitrate, m4aCodec }); + }, [files, outputFormat, bitrate, m4aCodec, saveState]); + useEffect(() => { + if (files.length === 0) + return; + const allMP3 = files.every((f) => f.format === "mp3"); + if (allMP3 && outputFormat !== "m4a") { + setOutputFormat("m4a"); + } + const hasFlac = files.some((f) => f.format === "flac"); + if (!hasFlac && m4aCodec === "alac") { + setM4aCodec("aac"); + } + }, [files, outputFormat, m4aCodec]); + const isFormatDisabled = files.length > 0 && files.every((f) => f.format === "mp3"); + const hasFlacFiles = files.some((f) => f.format === "flac"); + 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 checkFfmpegInstallation = async () => { + try { + const installed = await IsFFmpegInstalled(); + setFfmpegInstalled(installed); + } + catch (err) { + console.error("Failed to check ffmpeg:", err); + setFfmpegInstalled(false); } - } - } 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> => - (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)`, - }); + 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", + }); + } } - - 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", + 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"]; + const m4aFiles = paths.filter((path) => { + const ext = path.toLowerCase().slice(path.lastIndexOf(".")); + return ext === ".m4a"; }); - } - - 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, + 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.", + }); + } + const GetFileSizes = (files: string[]): Promise> => (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(() => { + if (ffmpegInstalled === true) { + OnFileDrop((x, y, paths) => { + handleFileDrop(x, y, paths); + }, true); + return () => { + OnFileDropOff(); }; - } - 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 ; - case "success": - return ; - case "error": - return ; - default: - return ; - } - }; - - // 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 ( -
+ } + }, [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 { + const inputPaths = files.map((f) => f.path); + 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 : "", + }); + 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 ; + case "success": + return ; + case "error": + return ; + default: + return ; + } + }; + const convertableCount = files.filter((f) => f.status === "pending" || f.status === "success").length; + const successCount = files.filter((f) => f.status === "success").length; + if (ffmpegInstalled === false) { + return (

Audio Converter

-
+
- +

FFmpeg is required to convert audio files

- + + {installingFfmpeg && downloadProgress.is_downloading && downloadProgress.mb_downloaded > 0 && (
+
+ Downloading FFmpeg + + {downloadProgress.mb_downloaded.toFixed(2)} MB + {downloadProgress.speed_mbps > 0 && ( + @ {downloadProgress.speed_mbps.toFixed(2)} MB/s + )} + +
+ +
)}
-
- ); - } - - return ( -
- {/* Header */} +
); + } + return (
+

Audio Converter

- {files.length > 0 && ( -
+ {files.length > 0 && (
- -
- )} +
)}
- {/* Drop Zone / File List */} -
{ - 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 ? ( - <> + : "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 ? (<>
- +

{isDragging @@ -511,113 +413,74 @@ export function AudioConverterPage() { : "Drag and drop audio files here, or click the button below to select"}

Supported formats: FLAC, MP3

- - ) : ( -
- {/* Settings Row - Only show when files exist */} + ) : (
+
- {/* Format and Bitrate in one line */} +
- { - if (value && !isFormatDisabled) setOutputFormat(value as "mp3" | "m4a"); - }} - disabled={isFormatDisabled} - > - {!isFormatDisabled && ( - + { + if (value && !isFormatDisabled) + setOutputFormat(value as "mp3" | "m4a"); + }} disabled={isFormatDisabled}> + {!isFormatDisabled && ( MP3 - - )} + )} M4A
- {/* Codec selection for M4A - only show ALAC option when input has FLAC files */} - {outputFormat === "m4a" && hasFlacFiles && ( -
+ + {outputFormat === "m4a" && hasFlacFiles && (
- { - if (value) setM4aCodec(value as "aac" | "alac"); - }} - > - {M4A_CODEC_OPTIONS.map((option) => ( - + { + if (value) + setM4aCodec(value as "aac" | "alac"); + }}> + {M4A_CODEC_OPTIONS.map((option) => ( {option.label} - - ))} + ))} -
- )} - {/* Bitrate selection - hide for ALAC (lossless) */} - {!(outputFormat === "m4a" && m4aCodec === "alac") && ( -
+
)} + + {!(outputFormat === "m4a" && m4aCodec === "alac") && (
- { - if (value) setBitrate(value); - }} - > - {BITRATE_OPTIONS.map((option) => ( - + { + if (value) + setBitrate(value); + }}> + {BITRATE_OPTIONS.map((option) => ( {option.label} - - ))} + ))} -
- )} +
)}
- {/* File List Header */} +
{files.length} file(s) • {successCount} converted
- {/* File List */} +
- {files.map((file) => ( -
+ {files.map((file) => (
{getStatusIcon(file.status)}

{file.name}

- {file.error && ( -

+ {file.error && (

{file.error} -

- )} +

)}
{formatFileSize(file.size)} @@ -625,47 +488,25 @@ export function AudioConverterPage() { {file.format} - {file.status !== "converting" && ( - - )} -
- ))} + {file.status !== "converting" && ()} +
))}
- {/* Convert Button */} +
-
-
- )} +
)}
-
- ); +
); } - - - diff --git a/frontend/src/components/DebugLoggerPage.tsx b/frontend/src/components/DebugLoggerPage.tsx index f4dc62e..7162d14 100644 --- a/frontend/src/components/DebugLoggerPage.tsx +++ b/frontend/src/components/DebugLoggerPage.tsx @@ -2,100 +2,72 @@ import { useState, useEffect, useRef } from "react"; import { Trash2, Copy, Check } from "lucide-react"; import { Button } from "@/components/ui/button"; import { logger, type LogEntry } from "@/lib/logger"; - const levelColors: Record = { - info: "text-blue-500", - success: "text-green-500", - warning: "text-yellow-500", - error: "text-red-500", - debug: "text-gray-500", + 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 DebugLoggerPage() { - const [logs, setLogs] = useState([]); - const [copied, setCopied] = useState(false); - const scrollRef = useRef(null); - - useEffect(() => { - const unsubscribe = logger.subscribe(() => { - setLogs(logger.getLogs()); + return date.toLocaleTimeString("en-US", { + hour12: false, + hour: "2-digit", + minute: "2-digit", + second: "2-digit", }); - setLogs(logger.getLogs()); - return () => { - unsubscribe(); +} +export function DebugLoggerPage() { + const [logs, setLogs] = useState([]); + const [copied, setCopied] = useState(false); + const scrollRef = useRef(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(); }; - }, []); - - 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 ( -
+ 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 (

Debug Logs

- -
-
- {logs.length === 0 ? ( -

no logs yet...

- ) : ( - logs.map((log, i) => ( -
+
+ {logs.length === 0 ? (

no logs yet...

) : (logs.map((log, i) => (
[{formatTime(log.timestamp)}] @@ -103,10 +75,7 @@ export function DebugLoggerPage() { [{log.level}] {log.message} -
- )) - )} +
)))}
-
- ); +
); } diff --git a/frontend/src/components/DownloadProgress.tsx b/frontend/src/components/DownloadProgress.tsx index 57ea8ba..04fd13f 100644 --- a/frontend/src/components/DownloadProgress.tsx +++ b/frontend/src/components/DownloadProgress.tsx @@ -1,30 +1,29 @@ import { Button } from "@/components/ui/button"; import { Progress } from "@/components/ui/progress"; import { StopCircle } from "lucide-react"; - interface DownloadProgressProps { - progress: number; - currentTrack: { name: string; artists: string } | null; - onStop: () => void; + progress: number; + currentTrack: { + name: string; + artists: string; + } | null; + onStop: () => void; } - export function DownloadProgress({ progress, currentTrack, onStop }: DownloadProgressProps) { - const clampedProgress = Math.min(100, Math.max(0, progress)); - return ( -
+ const clampedProgress = Math.min(100, Math.max(0, progress)); + return (
- +

{clampedProgress}% -{" "} {currentTrack - ? `${currentTrack.name} - ${currentTrack.artists}` - : "Preparing download..."} + ? `${currentTrack.name} - ${currentTrack.artists}` + : "Preparing download..."}

-
- ); +
); } diff --git a/frontend/src/components/DownloadProgressToast.tsx b/frontend/src/components/DownloadProgressToast.tsx index 4499047..3af9f2a 100644 --- a/frontend/src/components/DownloadProgressToast.tsx +++ b/frontend/src/components/DownloadProgressToast.tsx @@ -2,47 +2,30 @@ import { useDownloadProgress } from "@/hooks/useDownloadProgress"; import { useDownloadQueueData } from "@/hooks/useDownloadQueueData"; import { Download, ChevronRight } from "lucide-react"; import { Button } from "@/components/ui/button"; - interface DownloadProgressToastProps { - onClick: () => void; + onClick: () => void; } - export function DownloadProgressToast({ onClick }: DownloadProgressToastProps) { - const progress = useDownloadProgress(); - const queueInfo = useDownloadQueueData(); - - // Show indicator if there are any queued or downloading items - // Don't show for completed/failed/skipped only - const hasActiveDownloads = queueInfo.queue.some( - item => item.status === "queued" || item.status === "downloading" - ); - - if (!hasActiveDownloads) { - return null; - } - - return ( -
- -
- ); +
); } diff --git a/frontend/src/components/DownloadQueue.tsx b/frontend/src/components/DownloadQueue.tsx index 299ae40..bdeb15f 100644 --- a/frontend/src/components/DownloadQueue.tsx +++ b/frontend/src/components/DownloadQueue.tsx @@ -1,194 +1,158 @@ import { useEffect, useState } from "react"; import { X, Download, CheckCircle2, XCircle, Clock, FileCheck, Trash2, HardDrive, Zap, Timer } from "lucide-react"; import { Button } from "@/components/ui/button"; -import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog"; - +import { Dialog, DialogContent, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; import { Badge } from "@/components/ui/badge"; import { GetDownloadQueue, ClearCompletedDownloads } from "../../wailsjs/go/main/App"; import { backend } from "../../wailsjs/go/models"; - interface DownloadQueueProps { - isOpen: boolean; - onClose: () => void; + isOpen: boolean; + onClose: () => void; } - export function DownloadQueue({ isOpen, onClose }: DownloadQueueProps) { - const [queueInfo, setQueueInfo] = useState( - new backend.DownloadQueueInfo({ - is_downloading: false, - queue: [], - current_speed: 0, - total_downloaded: 0, - session_start_time: 0, - queued_count: 0, - completed_count: 0, - failed_count: 0, - skipped_count: 0, - }) - ); - - useEffect(() => { - if (!isOpen) return; - - const fetchQueue = async () => { - try { - const info = await GetDownloadQueue(); - setQueueInfo(info); - } catch (error) { - console.error("Failed to get download queue:", error); - } + const [queueInfo, setQueueInfo] = useState(new backend.DownloadQueueInfo({ + is_downloading: false, + queue: [], + current_speed: 0, + total_downloaded: 0, + session_start_time: 0, + queued_count: 0, + completed_count: 0, + failed_count: 0, + skipped_count: 0, + })); + useEffect(() => { + if (!isOpen) + return; + const fetchQueue = async () => { + try { + const info = await GetDownloadQueue(); + setQueueInfo(info); + } + catch (error) { + console.error("Failed to get download queue:", error); + } + }; + fetchQueue(); + const interval = setInterval(fetchQueue, 500); + return () => clearInterval(interval); + }, [isOpen]); + const handleClearHistory = async () => { + try { + await ClearCompletedDownloads(); + const info = await GetDownloadQueue(); + setQueueInfo(info); + } + catch (error) { + console.error("Failed to clear history:", error); + } }; - - // Initial fetch - fetchQueue(); - - // Poll every 500ms when dialog is open - const interval = setInterval(fetchQueue, 500); - - return () => clearInterval(interval); - }, [isOpen]); - - const handleClearHistory = async () => { - try { - await ClearCompletedDownloads(); - // Refetch immediately to update UI - const info = await GetDownloadQueue(); - setQueueInfo(info); - } catch (error) { - console.error("Failed to clear history:", error); - } - }; - - const getStatusIcon = (status: string) => { - switch (status) { - case "downloading": - return ; - case "completed": - return ; - case "failed": - return ; - case "skipped": - return ; - case "queued": - return ; - default: - return null; - } - }; - - const getStatusBadge = (status: string) => { - const variants: Record = { - downloading: "default", - completed: "outline", - failed: "destructive", - skipped: "secondary", - queued: "outline", + const getStatusIcon = (status: string) => { + switch (status) { + case "downloading": + return ; + case "completed": + return ; + case "failed": + return ; + case "skipped": + return ; + case "queued": + return ; + default: + return null; + } }; - - return ( - + const getStatusBadge = (status: string) => { + const variants: Record = { + downloading: "default", + completed: "outline", + failed: "destructive", + skipped: "secondary", + queued: "outline", + }; + return ( {status} - - ); - }; - - // Format session duration - const formatDuration = (startTimestamp: number) => { - if (startTimestamp === 0) return "—"; - const now = Math.floor(Date.now() / 1000); - const durationSeconds = now - startTimestamp; - - const hours = Math.floor(durationSeconds / 3600); - const minutes = Math.floor((durationSeconds % 3600) / 60); - const seconds = durationSeconds % 60; - - if (hours > 0) { - return `${hours}h ${minutes}m ${seconds}s`; - } else if (minutes > 0) { - return `${minutes}m ${seconds}s`; - } else { - return `${seconds}s`; - } - }; - - return ( - + ); + }; + const formatDuration = (startTimestamp: number) => { + if (startTimestamp === 0) + return "—"; + const now = Math.floor(Date.now() / 1000); + const durationSeconds = now - startTimestamp; + const hours = Math.floor(durationSeconds / 3600); + const minutes = Math.floor((durationSeconds % 3600) / 60); + const seconds = durationSeconds % 60; + if (hours > 0) { + return `${hours}h ${minutes}m ${seconds}s`; + } + else if (minutes > 0) { + return `${minutes}m ${seconds}s`; + } + else { + return `${seconds}s`; + } + }; + return (
Download Queue
- {(queueInfo.completed_count > 0 || queueInfo.failed_count > 0 || queueInfo.skipped_count > 0) && ( - - )} - )} +
- {/* Queue Status */} +
- + Queued: {queueInfo.queued_count}
- + Completed: {queueInfo.completed_count}
- + Skipped: {queueInfo.skipped_count}
- + Failed: {queueInfo.failed_count}
- {/* Session Stats */} +
- + Downloaded: {queueInfo.total_downloaded > 0 ? `${queueInfo.total_downloaded.toFixed(2)} MB` : "0.00 MB"}
- + Speed: - {queueInfo.current_speed > 0 && queueInfo.is_downloading - ? `${queueInfo.current_speed.toFixed(2)} MB/s` - : "—"} + {queueInfo.current_speed > 0 && queueInfo.is_downloading + ? `${queueInfo.current_speed.toFixed(2)} MB/s` + : "—"}
- + Duration: {queueInfo.session_start_time > 0 ? formatDuration(queueInfo.session_start_time) : "—"} @@ -198,20 +162,13 @@ export function DownloadQueue({ isOpen, onClose }: DownloadQueueProps) { - {/* Download Queue List */} +
- {queueInfo.queue.length === 0 ? ( -
- + {queueInfo.queue.length === 0 ? (
+

No downloads in queue

-
- ) : ( - queueInfo.queue.map((item) => ( -
+
) : (queueInfo.queue.map((item) => (
{getStatusIcon(item.status)}
@@ -227,61 +184,48 @@ export function DownloadQueue({ isOpen, onClose }: DownloadQueueProps) { {getStatusBadge(item.status)}
- {/* Info for downloading items */} - {item.status === "downloading" && ( -
+ + {item.status === "downloading" && (
{item.progress > 0 - ? `${item.progress.toFixed(2)} MB` - : queueInfo.is_downloading && queueInfo.current_speed > 0 - ? "Downloading..." - : "Starting..."} + ? `${item.progress.toFixed(2)} MB` + : queueInfo.is_downloading && queueInfo.current_speed > 0 + ? "Downloading..." + : "Starting..."} {item.speed > 0 - ? `${item.speed.toFixed(2)} MB/s` - : queueInfo.current_speed > 0 - ? `${queueInfo.current_speed.toFixed(2)} MB/s` - : "—"} + ? `${item.speed.toFixed(2)} MB/s` + : queueInfo.current_speed > 0 + ? `${queueInfo.current_speed.toFixed(2)} MB/s` + : "—"} -
- )} +
)} - {/* Completed info */} - {item.status === "completed" && ( -
+ + {item.status === "completed" && (
{item.progress.toFixed(2)} MB -
- )} +
)} - {/* Skipped info */} - {item.status === "skipped" && ( -
+ + {item.status === "skipped" && (
File already exists -
- )} +
)} - {/* Error message */} - {item.status === "failed" && item.error_message && ( -
+ + {item.status === "failed" && item.error_message && (
{item.error_message} -
- )} +
)} - {/* File path for completed/skipped */} - {(item.status === "completed" || item.status === "skipped") && item.file_path && ( -
+ + {(item.status === "completed" || item.status === "skipped") && item.file_path && (
{item.file_path} -
- )} +
)}
-
- )) - )} +
)))}
-
- ); +
); } diff --git a/frontend/src/components/FetchHistory.tsx b/frontend/src/components/FetchHistory.tsx index 9d9ff83..774b3a0 100644 --- a/frontend/src/components/FetchHistory.tsx +++ b/frontend/src/components/FetchHistory.tsx @@ -1,91 +1,96 @@ -import { X } from "lucide-react"; - +import { X, Music2, Disc3, ListMusic, UserRound } from "lucide-react"; export interface HistoryItem { - id: string; - url: string; - type: "track" | "album" | "playlist" | "artist"; - name: string; - artist: string; - image: string; - timestamp: number; + id: string; + url: string; + type: "track" | "album" | "playlist" | "artist"; + name: string; + artist: string; + image: string; + timestamp: number; } - interface FetchHistoryProps { - history: HistoryItem[]; - onSelect: (item: HistoryItem) => void; - onRemove: (id: string) => void; + history: HistoryItem[]; + onSelect: (item: HistoryItem) => void; + onRemove: (id: string) => void; } - export function FetchHistory({ history, onSelect, onRemove }: FetchHistoryProps) { - if (history.length === 0) return null; - - const getTypeLabel = (type: string) => { - switch (type) { - case "track": - return "Track"; - case "album": - return "Album"; - case "playlist": - return "Playlist"; - case "artist": - return "Artist"; - default: - return type; - } - }; - - return ( -
- Recent Fetches + if (history.length === 0) + return null; + const getTypeLabel = (type: string) => { + switch (type) { + case "track": + return "Track"; + case "album": + return "Album"; + case "playlist": + return "Playlist"; + case "artist": + return "Artist"; + default: + return type; + } + }; + const getTypeIcon = (type: string) => { + switch (type) { + case "track": + return Music2; + case "album": + return Disc3; + case "playlist": + return ListMusic; + case "artist": + return UserRound; + default: + return null; + } + }; + const getTypeBadgeClass = (type: string) => { + switch (type) { + case "track": + return "bg-blue-500/10 text-blue-600 dark:bg-blue-500/20 dark:text-blue-400"; + case "album": + return "bg-green-500/10 text-green-600 dark:bg-green-500/20 dark:text-green-400"; + case "playlist": + return "bg-purple-500/10 text-purple-600 dark:bg-purple-500/20 dark:text-purple-400"; + case "artist": + return "bg-orange-500/10 text-orange-600 dark:bg-orange-500/20 dark:text-orange-400"; + default: + return "bg-muted text-muted-foreground"; + } + }; + return (
+ {history.length === 1 ? "Recent Fetch" : "Recent Fetches"}
- {history.map((item) => ( -
onSelect(item)} - > -
- {item.image ? ( - {item.name} - ) : ( -
+ {item.image ? ({item.name}) : (
No Image -
- )} +
)}

{item.name}

-

+

{item.artist}

- - {getTypeLabel(item.type)} - + {(() => { + const IconComponent = getTypeIcon(item.type); + return ( + {IconComponent ? : null} + {getTypeLabel(item.type)} + ); + })()}
-
- ))} +
))}
-
- ); + ); } diff --git a/frontend/src/components/FileManagerPage.tsx b/frontend/src/components/FileManagerPage.tsx index 2613a9e..a808624 100644 --- a/frontend/src/components/FileManagerPage.tsx +++ b/frontend/src/components/FileManagerPage.tsx @@ -3,609 +3,591 @@ import { Button } from "@/components/ui/button"; import { Label } from "@/components/ui/label"; import { InputWithContext } from "@/components/ui/input-with-context"; 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, - FileText, - Image, - Copy, - Check, -} from "lucide-react"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; +import { FolderOpen, RefreshCw, FileMusic, ChevronRight, ChevronDown, Pencil, Eye, Folder, Info, RotateCcw, FileText, Image, Copy, Check, } from "lucide-react"; import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip"; import { Spinner } from "@/components/ui/spinner"; +import { Badge } from "@/components/ui/badge"; import { SelectFolder } from "../../wailsjs/go/main/App"; import { backend } from "../../wailsjs/go/models"; 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"; - -const ListDirectoryFiles = (path: string): Promise => - (window as any)['go']['main']['App']['ListDirectoryFiles'](path); -const PreviewRenameFiles = (files: string[], format: string): Promise => - (window as any)['go']['main']['App']['PreviewRenameFiles'](files, format); -const RenameFilesByMetadata = (files: string[], format: string): Promise => - (window as any)['go']['main']['App']['RenameFilesByMetadata'](files, format); -const ReadFileMetadata = (path: string): Promise => - (window as any)['go']['main']['App']['ReadFileMetadata'](path); -const IsFFprobeInstalled = (): Promise => - (window as any)['go']['main']['App']['IsFFprobeInstalled'](); -const DownloadFFmpeg = (): Promise<{ success: boolean; message: string; error?: string }> => - (window as any)['go']['main']['App']['DownloadFFmpeg'](); -const ReadTextFile = (path: string): Promise => - (window as any)['go']['main']['App']['ReadTextFile'](path); -const RenameFileTo = (oldPath: string, newName: string): Promise => - (window as any)['go']['main']['App']['RenameFileTo'](oldPath, newName); -const ReadImageAsBase64 = (path: string): Promise => - (window as any)['go']['main']['App']['ReadImageAsBase64'](path); - +import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; +const ListDirectoryFiles = (path: string): Promise => (window as any)['go']['main']['App']['ListDirectoryFiles'](path); +const PreviewRenameFiles = (files: string[], format: string): Promise => (window as any)['go']['main']['App']['PreviewRenameFiles'](files, format); +const RenameFilesByMetadata = (files: string[], format: string): Promise => (window as any)['go']['main']['App']['RenameFilesByMetadata'](files, format); +const ReadFileMetadata = (path: string): Promise => (window as any)['go']['main']['App']['ReadFileMetadata'](path); +const IsFFprobeInstalled = (): Promise => (window as any)['go']['main']['App']['IsFFprobeInstalled'](); +const DownloadFFmpeg = (): Promise<{ + success: boolean; + message: string; + error?: string; +}> => (window as any)['go']['main']['App']['DownloadFFmpeg'](); +const ReadTextFile = (path: string): Promise => (window as any)['go']['main']['App']['ReadTextFile'](path); +const RenameFileTo = (oldPath: string, newName: string): Promise => (window as any)['go']['main']['App']['RenameFileTo'](oldPath, newName); +const ReadImageAsBase64 = (path: string): Promise => (window as any)['go']['main']['App']['ReadImageAsBase64'](path); interface FileNode { - name: string; - path: string; - is_dir: boolean; - size: number; - children?: FileNode[]; - expanded?: boolean; + name: string; + path: string; + is_dir: boolean; + size: number; + children?: FileNode[]; + expanded?: boolean; } - interface FileMetadata { - title: string; - artist: string; - album: string; - album_artist: string; - track_number: number; - disc_number: number; - year: string; + title: string; + artist: string; + album: string; + album_artist: string; + track_number: number; + disc_number: number; + year: string; } - type TabType = "track" | "lyric" | "cover"; - -const FORMAT_PRESETS: Record = { - "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 FORMAT_PRESETS: Record = { + "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"; const DEFAULT_PRESET = "title-artist"; const DEFAULT_CUSTOM_FORMAT = "{title} - {artist}"; - 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]; + 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]; } - export function FileManagerPage() { - const [rootPath, setRootPath] = useState(() => { - const settings = getSettings(); - return settings.downloadPath || ""; - }); - const [allFiles, setAllFiles] = useState([]); - const [selectedFiles, setSelectedFiles] = useState>(new Set()); - const [loading, setLoading] = useState(false); - const [activeTab, setActiveTab] = useState("track"); - const [formatPreset, setFormatPreset] = useState(() => { - try { - const saved = localStorage.getItem(STORAGE_KEY); - if (saved) { - const parsed = JSON.parse(saved); - if (parsed.formatPreset && FORMAT_PRESETS[parsed.formatPreset]) { - return parsed.formatPreset; + const [rootPath, setRootPath] = useState(() => { + const settings = getSettings(); + return settings.downloadPath || ""; + }); + const [allFiles, setAllFiles] = useState([]); + const [selectedFiles, setSelectedFiles] = useState>(new Set()); + const [loading, setLoading] = useState(false); + const [activeTab, setActiveTab] = useState("track"); + const [formatPreset, setFormatPreset] = useState(() => { + try { + const saved = localStorage.getItem(STORAGE_KEY); + if (saved) { + const parsed = JSON.parse(saved); + if (parsed.formatPreset && FORMAT_PRESETS[parsed.formatPreset]) { + return parsed.formatPreset; + } + } } - } - } catch { /* ignore */ } - return DEFAULT_PRESET; - }); - const [customFormat, setCustomFormat] = useState(() => { - try { - const saved = localStorage.getItem(STORAGE_KEY); - if (saved) { - const parsed = JSON.parse(saved); - if (parsed.customFormat) return parsed.customFormat; - } - } catch { /* 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([]); - 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(""); - const [metadataInfo, setMetadataInfo] = useState(null); - const [loadingMetadata, setLoadingMetadata] = useState(false); - const [showFFprobeDialog, setShowFFprobeDialog] = useState(false); - const [installingFFprobe, setInstallingFFprobe] = useState(false); - const [showLyricsPreview, setShowLyricsPreview] = useState(false); - const [lyricsContent, setLyricsContent] = useState(""); - const [lyricsFile, setLyricsFile] = useState(""); - const [lyricsTab, setLyricsTab] = useState<"synced" | "plain">("synced"); - const [copySuccess, setCopySuccess] = useState(false); - const [showCoverPreview, setShowCoverPreview] = useState(false); - const [coverFile, setCoverFile] = useState(""); - const [coverData, setCoverData] = useState(""); - const [showManualRename, setShowManualRename] = useState(false); - const [manualRenameFile, setManualRenameFile] = useState(""); - const [manualRenameName, setManualRenameName] = useState(""); - const [manualRenaming, setManualRenaming] = useState(false); - - useEffect(() => { - try { - localStorage.setItem(STORAGE_KEY, JSON.stringify({ formatPreset, customFormat })); - } catch { /* ignore */ } - }, [formatPreset, customFormat]); - - 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 filterFilesByType = (nodes: FileNode[], type: TabType): FileNode[] => { - return nodes - .map((node) => { - if (node.is_dir && node.children) { - const filteredChildren = filterFilesByType(node.children, type); - if (filteredChildren.length > 0) { - return { ...node, children: filteredChildren }; - } - return null; + catch { } + return DEFAULT_PRESET; + }); + const [customFormat, setCustomFormat] = useState(() => { + try { + const saved = localStorage.getItem(STORAGE_KEY); + if (saved) { + const parsed = JSON.parse(saved); + if (parsed.customFormat) + return parsed.customFormat; + } } - const ext = node.name.toLowerCase(); - if (type === "track" && (ext.endsWith(".flac") || ext.endsWith(".mp3") || ext.endsWith(".m4a"))) return node; - if (type === "lyric" && ext.endsWith(".lrc")) return node; - if (type === "cover" && (ext.endsWith(".jpg") || ext.endsWith(".jpeg") || ext.endsWith(".png"))) return node; - return null; - }) - .filter((node): node is FileNode => node !== null); - }; - - const loadFiles = useCallback(async () => { - if (!rootPath) return; - setLoading(true); - try { - const result = await ListDirectoryFiles(rootPath); - if (!result || !Array.isArray(result)) { - setAllFiles([]); - setSelectedFiles(new Set()); - return; - } - setAllFiles(result as FileNode[]); - setSelectedFiles(new Set()); - } catch (err) { - 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" }); - } - setAllFiles([]); - setSelectedFiles(new Set()); - } finally { - setLoading(false); - } - }, [rootPath]); - - useEffect(() => { - if (rootPath) loadFiles(); - }, [rootPath, loadFiles]); - - const filteredFiles = filterFilesByType(allFiles, activeTab); - - const getAllFilesFlat = (nodes: FileNode[]): FileNode[] => { - const result: FileNode[] = []; - for (const node of nodes) { - if (!node.is_dir) result.push(node); - if (node.children) result.push(...getAllFilesFlat(node.children)); - } - return result; - }; - - const allAudioFiles = getAllFilesFlat(filterFilesByType(allFiles, "track")); - const allLyricFiles = getAllFilesFlat(filterFilesByType(allFiles, "lyric")); - const allCoverFiles = getAllFilesFlat(filterFilesByType(allFiles, "cover")); - - 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) => { - setAllFiles((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; + catch { } + return DEFAULT_CUSTOM_FORMAT; }); - }; - - const toggleSelect = (path: string) => { - 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 = getAllFilesFlat([node]); - const allSelected = folderFiles.every((f) => selectedFiles.has(f.path)); - setSelectedFiles((prev) => { - const newSet = new Set(prev); - if (allSelected) folderFiles.forEach((f) => newSet.delete(f.path)); - else folderFiles.forEach((f) => newSet.add(f.path)); - return newSet; - }); - }; - - const isFolderSelected = (node: FileNode): boolean | "indeterminate" => { - const folderFiles = getAllFilesFlat([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 = () => setSelectedFiles(new Set(allAudioFiles.map((f) => f.path))); - const deselectAll = () => setSelectedFiles(new Set()); - 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; } - 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(); - 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 handleShowLyrics = async (filePath: string, e: React.MouseEvent) => { - e.stopPropagation(); - setLyricsFile(filePath); - setLyricsTab("synced"); - try { - const content = await ReadTextFile(filePath); - setLyricsContent(content); - setShowLyricsPreview(true); - } catch (err) { - toast.error("Failed to read lyrics file", { description: err instanceof Error ? err.message : "Unknown error" }); - } - }; - - const handleShowCover = async (filePath: string, e: React.MouseEvent) => { - e.stopPropagation(); - setCoverFile(filePath); - try { - const data = await ReadImageAsBase64(filePath); - setCoverData(data); - setShowCoverPreview(true); - } catch (err) { - toast.error("Failed to load image", { description: err instanceof Error ? err.message : "Unknown error" }); - } - }; - - const getPlainLyrics = (content: string) => { - return content.split('\n').map(line => line.replace(/^\[[\d:.]+\]\s*/, '')).filter(line => !line.startsWith('[') || line.includes(']')).map(line => line.startsWith('[') ? '' : line).join('\n').trim(); - }; - - const handleCopyLyrics = async () => { - try { - const textToCopy = lyricsTab === "synced" ? lyricsContent : getPlainLyrics(lyricsContent); - await navigator.clipboard.writeText(textToCopy); - setCopySuccess(true); - setTimeout(() => setCopySuccess(false), 500); - } catch { toast.error("Failed to copy lyrics"); } - }; - - const handleManualRename = (filePath: string, e: React.MouseEvent) => { - e.stopPropagation(); - const fileName = filePath.split(/[/\\]/).pop() || ""; - const nameWithoutExt = fileName.replace(/\.[^.]+$/, ""); - setManualRenameFile(filePath); - setManualRenameName(nameWithoutExt); - setShowManualRename(true); - }; - - const handleConfirmManualRename = async () => { - if (!manualRenameFile || !manualRenameName.trim()) return; - setManualRenaming(true); - try { - await RenameFileTo(manualRenameFile, manualRenameName.trim()); - toast.success("File renamed successfully"); - setShowManualRename(false); - loadFiles(); - } catch (err) { - toast.error("Failed to rename file", { description: err instanceof Error ? err.message : "Unknown error" }); - } finally { - setManualRenaming(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 renderTrackTree = (nodes: FileNode[], depth = 0) => { - return nodes.map((node) => ( -
-
(node.is_dir ? toggleExpand(node.path) : toggleSelect(node.path))} - > - {node.is_dir ? ( - <> - { 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 ? : } - - - ) : ( - <> - toggleSelect(node.path)} onClick={(e) => e.stopPropagation()} className="shrink-0" /> - - - )} + const renameFormat = formatPreset === "custom" ? (customFormat || FORMAT_PRESETS["custom"].template) : FORMAT_PRESETS[formatPreset].template; + const [showPreview, setShowPreview] = useState(false); + const [previewData, setPreviewData] = useState([]); + 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(""); + const [metadataInfo, setMetadataInfo] = useState(null); + const [loadingMetadata, setLoadingMetadata] = useState(false); + const [showFFprobeDialog, setShowFFprobeDialog] = useState(false); + const [installingFFprobe, setInstallingFFprobe] = useState(false); + const [showLyricsPreview, setShowLyricsPreview] = useState(false); + const [lyricsContent, setLyricsContent] = useState(""); + const [lyricsFile, setLyricsFile] = useState(""); + const [lyricsTab, setLyricsTab] = useState<"synced" | "plain">("synced"); + const [copySuccess, setCopySuccess] = useState(false); + const [showCoverPreview, setShowCoverPreview] = useState(false); + const [coverFile, setCoverFile] = useState(""); + const [coverData, setCoverData] = useState(""); + const [showManualRename, setShowManualRename] = useState(false); + const [manualRenameFile, setManualRenameFile] = useState(""); + const [manualRenameName, setManualRenameName] = useState(""); + const [manualRenaming, setManualRenaming] = useState(false); + useEffect(() => { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify({ formatPreset, customFormat })); + } + catch { } + }, [formatPreset, customFormat]); + 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 filterFilesByType = (nodes: FileNode[], type: TabType): FileNode[] => { + return nodes + .map((node) => { + if (node.is_dir && node.children) { + const filteredChildren = filterFilesByType(node.children, type); + if (filteredChildren.length > 0) { + return { ...node, children: filteredChildren }; + } + return null; + } + const ext = node.name.toLowerCase(); + if (type === "track" && (ext.endsWith(".flac") || ext.endsWith(".mp3") || ext.endsWith(".m4a"))) + return node; + if (type === "lyric" && ext.endsWith(".lrc")) + return node; + if (type === "cover" && (ext.endsWith(".jpg") || ext.endsWith(".jpeg") || ext.endsWith(".png"))) + return node; + return null; + }) + .filter((node): node is FileNode => node !== null); + }; + const loadFiles = useCallback(async () => { + if (!rootPath) + return; + setLoading(true); + try { + const result = await ListDirectoryFiles(rootPath); + if (!result || !Array.isArray(result)) { + setAllFiles([]); + setSelectedFiles(new Set()); + return; + } + setAllFiles(result as FileNode[]); + setSelectedFiles(new Set()); + } + catch (err) { + 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" }); + } + setAllFiles([]); + setSelectedFiles(new Set()); + } + finally { + setLoading(false); + } + }, [rootPath]); + useEffect(() => { + if (rootPath) + loadFiles(); + }, [rootPath, loadFiles]); + const filteredFiles = filterFilesByType(allFiles, activeTab); + const getAllFilesFlat = (nodes: FileNode[]): FileNode[] => { + const result: FileNode[] = []; + for (const node of nodes) { + if (!node.is_dir) + result.push(node); + if (node.children) + result.push(...getAllFilesFlat(node.children)); + } + return result; + }; + const allAudioFiles = getAllFilesFlat(filterFilesByType(allFiles, "track")); + const allLyricFiles = getAllFilesFlat(filterFilesByType(allFiles, "lyric")); + const allCoverFiles = getAllFilesFlat(filterFilesByType(allFiles, "cover")); + 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) => { + setAllFiles((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) => { + 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 = getAllFilesFlat([node]); + const allSelected = folderFiles.every((f) => selectedFiles.has(f.path)); + setSelectedFiles((prev) => { + const newSet = new Set(prev); + if (allSelected) + folderFiles.forEach((f) => newSet.delete(f.path)); + else + folderFiles.forEach((f) => newSet.add(f.path)); + return newSet; + }); + }; + const isFolderSelected = (node: FileNode): boolean | "indeterminate" => { + const folderFiles = getAllFilesFlat([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 = () => setSelectedFiles(new Set(allAudioFiles.map((f) => f.path))); + const deselectAll = () => setSelectedFiles(new Set()); + 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; + } + 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(); + 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 handleShowLyrics = async (filePath: string, e: React.MouseEvent) => { + e.stopPropagation(); + setLyricsFile(filePath); + setLyricsTab("synced"); + try { + const content = await ReadTextFile(filePath); + setLyricsContent(content); + setShowLyricsPreview(true); + } + catch (err) { + toast.error("Failed to read lyrics file", { description: err instanceof Error ? err.message : "Unknown error" }); + } + }; + const handleShowCover = async (filePath: string, e: React.MouseEvent) => { + e.stopPropagation(); + setCoverFile(filePath); + try { + const data = await ReadImageAsBase64(filePath); + setCoverData(data); + setShowCoverPreview(true); + } + catch (err) { + toast.error("Failed to load image", { description: err instanceof Error ? err.message : "Unknown error" }); + } + }; + const getPlainLyrics = (content: string) => { + return content.split('\n').map(line => line.replace(/^\[[\d:.]+\]\s*/, '')).filter(line => !line.startsWith('[') || line.includes(']')).map(line => line.startsWith('[') ? '' : line).join('\n').trim(); + }; + const formatTimestamp = (timestamp: string): string => { + const match = timestamp.match(/\[(\d+):(\d+)(?:\.(\d+))?\]/); + if (!match) + return timestamp; + const minutes = parseInt(match[1], 10); + const seconds = match[2]; + return `${minutes}:${seconds}`; + }; + const renderSyncedLyrics = (content: string) => { + if (!content) + return
No lyrics content
; + const lines = content.split('\n'); + return lines.map((line, index) => { + if (line.match(/^\[(ti|ar|al|by|length|offset):/i)) + return null; + const match = line.match(/^(\[[\d:.]+\])(.*)$/); + if (match) { + const timestamp = match[1]; + const text = match[2].trim(); + if (!text) + return null; + return (
+ + {formatTimestamp(timestamp)} + + {text} +
); + } + if (!line.trim()) + return null; + return (
+ {line} +
); + }).filter(item => item !== null); + }; + const handleCopyLyrics = async () => { + try { + const textToCopy = lyricsTab === "synced" ? lyricsContent : getPlainLyrics(lyricsContent); + await navigator.clipboard.writeText(textToCopy); + setCopySuccess(true); + setTimeout(() => setCopySuccess(false), 500); + } + catch { + toast.error("Failed to copy lyrics"); + } + }; + const handleManualRename = (filePath: string, e: React.MouseEvent) => { + e.stopPropagation(); + const fileName = filePath.split(/[/\\]/).pop() || ""; + const nameWithoutExt = fileName.replace(/\.[^.]+$/, ""); + setManualRenameFile(filePath); + setManualRenameName(nameWithoutExt); + setShowManualRename(true); + }; + const handleConfirmManualRename = async () => { + if (!manualRenameFile || !manualRenameName.trim()) + return; + setManualRenaming(true); + try { + await RenameFileTo(manualRenameFile, manualRenameName.trim()); + toast.success("File renamed successfully"); + setShowManualRename(false); + loadFiles(); + } + catch (err) { + toast.error("Failed to rename file", { description: err instanceof Error ? err.message : "Unknown error" }); + } + finally { + setManualRenaming(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 renderTrackTree = (nodes: FileNode[], depth = 0) => { + return nodes.map((node) => (
+
(node.is_dir ? toggleExpand(node.path) : toggleSelect(node.path))}> + {node.is_dir ? (<> + { + 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 ? : } + + ) : (<> + toggleSelect(node.path)} onClick={(e) => e.stopPropagation()} className="shrink-0"/> + + )} {node.name} {node.is_dir && ({getAllFilesFlat([node]).length})} - {!node.is_dir && ( - <> + {!node.is_dir && (<> {formatFileSize(node.size)} View Metadata - - )} + )}
{node.is_dir && node.expanded && node.children &&
{renderTrackTree(node.children, depth + 1)}
} -
- )); - }; - - const renderLyricTree = (nodes: FileNode[], depth = 0) => { - return nodes.map((node) => ( -
-
node.is_dir ? toggleExpand(node.path) : handleShowLyrics(node.path, e)} - > - {node.is_dir ? ( - <> - {node.expanded ? : } - - - ) : ( - - )} +
)); + }; + const renderLyricTree = (nodes: FileNode[], depth = 0) => { + return nodes.map((node) => (
+
node.is_dir ? toggleExpand(node.path) : handleShowLyrics(node.path, e)}> + {node.is_dir ? (<> + {node.expanded ? : } + + ) : ()} {node.name} {node.is_dir && ({getAllFilesFlat([node]).length})} - {!node.is_dir && ( - <> + {!node.is_dir && (<> {formatFileSize(node.size)} Rename - - )} + )}
{node.is_dir && node.expanded && node.children &&
{renderLyricTree(node.children, depth + 1)}
} -
- )); - }; - - const renderCoverTree = (nodes: FileNode[], depth = 0) => { - return nodes.map((node) => ( -
-
node.is_dir ? toggleExpand(node.path) : handleShowCover(node.path, e)} - > - {node.is_dir ? ( - <> - {node.expanded ? : } - - - ) : ( - - )} +
)); + }; + const renderCoverTree = (nodes: FileNode[], depth = 0) => { + return nodes.map((node) => (
+
node.is_dir ? toggleExpand(node.path) : handleShowCover(node.path, e)}> + {node.is_dir ? (<> + {node.expanded ? : } + + ) : ()} {node.name} {node.is_dir && ({getAllFilesFlat([node]).length})} - {!node.is_dir && ( - <> + {!node.is_dir && (<> {formatFileSize(node.size)} Rename - - )} + )}
{node.is_dir && node.expanded && node.children &&
{renderCoverTree(node.children, depth + 1)}
} -
- )); - }; - - const allSelected = allAudioFiles.length > 0 && selectedFiles.size === allAudioFiles.length; - - return ( -
+
)); + }; + const allSelected = allAudioFiles.length > 0 && selectedFiles.size === allAudioFiles.length; + return (

File Manager

- {/* Path Selection */} +
- setRootPath(e.target.value)} placeholder="Select a folder..." className="flex-1" /> + setRootPath(e.target.value)} placeholder="Select a folder..." className="flex-1"/>
- {/* Tabs */} +
- {/* Rename Format - Only for Track tab */} - {activeTab === "track" && ( -
+ + {activeTab === "track" && (
- +

Variables: {"{title}"}, {"{artist}"}, {"{album}"}, {"{album_artist}"}, {"{track}"}, {"{disc}"}, {"{year}"}

@@ -616,18 +598,14 @@ export function FileManagerPage() { - {formatPreset === "custom" && ( - setCustomFormat(e.target.value)} placeholder="{artist} - {title}" className="flex-1" /> - )} + {formatPreset === "custom" && ( setCustomFormat(e.target.value)} placeholder="{artist} - {title}" className="flex-1"/>)} Reset to Default @@ -636,13 +614,11 @@ export function FileManagerPage() {

Preview: {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

-
- )} +
)} - {/* File Tree */} +
- {activeTab === "track" && ( -
+ {activeTab === "track" && (
-
- )} +
)}
- {loading ? ( -
- ) : filteredFiles.length === 0 ? ( -
+ {loading ? (
) : filteredFiles.length === 0 ? (
{rootPath ? `No ${activeTab} files found` : "Select a folder to browse"} -
- ) : ( - activeTab === "track" ? renderTrackTree(filteredFiles) : +
) : (activeTab === "track" ? renderTrackTree(filteredFiles) : activeTab === "lyric" ? renderLyricTree(filteredFiles) : - renderCoverTree(filteredFiles) - )} + renderCoverTree(filteredFiles))}
- {/* Reset Confirmation Dialog */} + @@ -691,7 +660,7 @@ export function FileManagerPage() { - {/* Preview Dialog */} + @@ -699,41 +668,32 @@ export function FileManagerPage() { Review the changes before renaming. Files with errors will be skipped.
- {previewData.map((item, index) => ( -
+ {previewData.map((item, index) => (
{item.old_name}
{item.error ?
{item.error}
:
→ {item.new_name}
}
-
- ))} +
))}
- {previewOnly ? ( - - ) : ( - <> + {previewOnly ? () : (<> - - )} + )}
- {/* Metadata Dialog */} + File Metadata {metadataFile.split(/[/\\]/).pop()} - {loadingMetadata ? ( -
- ) : metadataInfo ? ( -
+ {loadingMetadata ? (
) : metadataInfo ? (
Title{metadataInfo.title || "-"}
Artist{metadataInfo.artist || "-"}
Album{metadataInfo.album || "-"}
@@ -741,15 +701,12 @@ export function FileManagerPage() {
Track{metadataInfo.track_number || "-"}
Disc{metadataInfo.disc_number || "-"}
Year{metadataInfo.year ? metadataInfo.year.substring(0, 4) : "-"}
-
- ) : ( -
No metadata available
- )} +
) : (
No metadata available
)}
- {/* FFprobe Install Dialog */} + @@ -759,13 +716,13 @@ export function FileManagerPage() { - {/* Lyrics Preview Dialog */} + @@ -777,13 +734,15 @@ export function FileManagerPage() {
-
-              {lyricsTab === "synced" ? lyricsContent : getPlainLyrics(lyricsContent) || "No lyrics content"}
-            
+ {lyricsTab === "synced" ? (
+ {renderSyncedLyrics(lyricsContent)} +
) : (
+                {getPlainLyrics(lyricsContent) || "No lyrics content"}
+              
)}
@@ -791,7 +750,7 @@ export function FileManagerPage() { - {/* Cover Preview Dialog */} + @@ -799,13 +758,13 @@ export function FileManagerPage() { {coverFile.split(/[/\\]/).pop()}
- {coverData ? Cover :
Loading...
} + {coverData ? Cover :
Loading...
}
- {/* Manual Rename Dialog */} + @@ -815,18 +774,20 @@ export function FileManagerPage() {
- setManualRenameName(e.target.value)} placeholder="Enter new name" className="flex-1" onKeyDown={(e) => { if (e.key === "Enter" && !manualRenaming) handleConfirmManualRename(); }} /> + setManualRenameName(e.target.value)} placeholder="Enter new name" className="flex-1" onKeyDown={(e) => { + if (e.key === "Enter" && !manualRenaming) + handleConfirmManualRename(); + }}/> {manualRenameFile.match(/\.[^.]+$/)?.[0] || ""}
-
- ); +
); } diff --git a/frontend/src/components/Header.tsx b/frontend/src/components/Header.tsx index 090e1b2..6378151 100644 --- a/frontend/src/components/Header.tsx +++ b/frontend/src/components/Header.tsx @@ -1,66 +1,42 @@ import { Badge } from "@/components/ui/badge"; -import { - Tooltip, - TooltipContent, - TooltipTrigger, -} from "@/components/ui/tooltip"; +import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip"; import { openExternal } from "@/lib/utils"; import { formatRelativeTime } from "@/lib/relative-time"; - interface HeaderProps { - version: string; - hasUpdate: boolean; - releaseDate?: string | null; + version: string; + hasUpdate: boolean; + releaseDate?: string | null; } - export function Header({ version, hasUpdate, releaseDate }: HeaderProps) { - return ( -
+ return (
- SpotiFLAC window.location.reload()} - /> -

window.location.reload()} - > + SpotiFLAC window.location.reload()}/> +

window.location.reload()}> SpotiFLAC

- - {hasUpdate && releaseDate && ( - + {hasUpdate && releaseDate && (

{formatRelativeTime(releaseDate)}

-
- )} +
)}
- {hasUpdate && ( - + {hasUpdate && ( - - )} + )}

Get Spotify tracks in true FLAC from Tidal, Qobuz & Amazon Music — no account required.

-
- ); +
); } diff --git a/frontend/src/components/PlatformIcons.tsx b/frontend/src/components/PlatformIcons.tsx index 6b1dfc4..c508105 100644 --- a/frontend/src/components/PlatformIcons.tsx +++ b/frontend/src/components/PlatformIcons.tsx @@ -1,22 +1,18 @@ -// Platform Icons for streaming services - -export const TidalIcon = ({ className = "w-4 h-4" }: { className?: string }) => ( - +export const TidalIcon = ({ className = "w-4 h-4" }: { + className?: string; +}) => ( - -); - -export const QobuzIcon = ({ className = "w-4 h-4" }: { className?: string }) => ( - + ); +export const QobuzIcon = ({ className = "w-4 h-4" }: { + className?: string; +}) => ( - -); - -export const AmazonIcon = ({ className = "w-4 h-4" }: { className?: string }) => ( - + ); +export const AmazonIcon = ({ className = "w-4 h-4" }: { + className?: string; +}) => ( - -); + ); diff --git a/frontend/src/components/PlaylistInfo.tsx b/frontend/src/components/PlaylistInfo.tsx index dcf8d65..c291986 100644 --- a/frontend/src/components/PlaylistInfo.tsx +++ b/frontend/src/components/PlaylistInfo.tsx @@ -7,135 +7,94 @@ import { SearchAndSort } from "./SearchAndSort"; import { TrackList } from "./TrackList"; import { DownloadProgress } from "./DownloadProgress"; import type { TrackMetadata, TrackAvailability } from "@/types/api"; - interface PlaylistInfoProps { - playlistInfo: { - owner: { - name: string; - display_name: string; - images: string; + playlistInfo: { + owner: { + name: string; + display_name: string; + images: string; + }; + tracks: { + total: number; + }; + followers: { + total: number; + }; + cover?: string; + description?: string; }; - tracks: { - total: number; - }; - followers: { - total: number; - }; - }; - trackList: TrackMetadata[]; - searchQuery: string; - sortBy: string; - selectedTracks: string[]; - downloadedTracks: Set; - failedTracks: Set; - skippedTracks: Set; - downloadingTrack: string | null; - isDownloading: boolean; - bulkDownloadType: "all" | "selected" | null; - downloadProgress: number; - currentDownloadInfo: { name: string; artists: string } | null; - currentPage: number; - itemsPerPage: number; - // Lyrics props - downloadedLyrics?: Set; - failedLyrics?: Set; - skippedLyrics?: Set; - downloadingLyricsTrack?: string | null; - // Availability props - checkingAvailabilityTrack?: string | null; - availabilityMap?: Map; - // Cover props - downloadedCovers?: Set; - failedCovers?: Set; - skippedCovers?: Set; - 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, 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, albumArtist?: string, releaseDate?: string, discNumber?: number) => void; - onDownloadCover?: (coverUrl: string, trackName: string, artistName: string, albumName: string, folderName?: string, isArtistDiscography?: boolean, position?: number, trackId?: string, albumArtist?: string, releaseDate?: string, discNumber?: number) => void; - onCheckAvailability?: (spotifyId: string) => void; - onDownloadAllLyrics?: () => void; - onDownloadAllCovers?: () => void; - onDownloadAll: () => void; - onDownloadSelected: () => void; - onStopDownload: () => void; - onOpenFolder: () => void; - onPageChange: (page: number) => void; - onAlbumClick: (album: { id: string; name: string; external_urls: string }) => void; - onArtistClick: (artist: { id: string; name: string; external_urls: string }) => void; - onTrackClick: (track: TrackMetadata) => void; + trackList: TrackMetadata[]; + searchQuery: string; + sortBy: string; + selectedTracks: string[]; + downloadedTracks: Set; + failedTracks: Set; + skippedTracks: Set; + downloadingTrack: string | null; + isDownloading: boolean; + bulkDownloadType: "all" | "selected" | null; + downloadProgress: number; + currentDownloadInfo: { + name: string; + artists: string; + } | null; + currentPage: number; + itemsPerPage: number; + downloadedLyrics?: Set; + failedLyrics?: Set; + skippedLyrics?: Set; + downloadingLyricsTrack?: string | null; + checkingAvailabilityTrack?: string | null; + availabilityMap?: Map; + downloadedCovers?: Set; + failedCovers?: Set; + skippedCovers?: Set; + 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, folderName?: string, durationMs?: number, position?: number, albumArtist?: string, releaseDate?: string, coverUrl?: string, spotifyTrackNumber?: number, spotifyDiscNumber?: number, spotifyTotalTracks?: number, spotifyTotalDiscs?: number, copyright?: string, publisher?: string) => void; + onDownloadLyrics?: (spotifyId: string, name: string, artists: string, albumName: string, folderName?: string, isArtistDiscography?: boolean, position?: number, albumArtist?: string, releaseDate?: string, discNumber?: number) => void; + onDownloadCover?: (coverUrl: string, trackName: string, artistName: string, albumName: string, folderName?: string, isArtistDiscography?: boolean, position?: number, trackId?: string, albumArtist?: string, releaseDate?: string, discNumber?: number) => void; + onCheckAvailability?: (spotifyId: string) => void; + onDownloadAllLyrics?: () => void; + onDownloadAllCovers?: () => void; + onDownloadAll: () => void; + onDownloadSelected: () => void; + onStopDownload: () => void; + onOpenFolder: () => void; + onPageChange: (page: number) => void; + onAlbumClick: (album: { + id: string; + name: string; + external_urls: string; + }) => void; + onArtistClick: (artist: { + id: string; + name: string; + external_urls: string; + }) => void; + onTrackClick: (track: TrackMetadata) => void; } - -export function PlaylistInfo({ - playlistInfo, - trackList, - searchQuery, - sortBy, - selectedTracks, - downloadedTracks, - failedTracks, - skippedTracks, - downloadingTrack, - isDownloading, - bulkDownloadType, - downloadProgress, - currentDownloadInfo, - currentPage, - itemsPerPage, - downloadedLyrics, - failedLyrics, - skippedLyrics, - downloadingLyricsTrack, - checkingAvailabilityTrack, - availabilityMap, - downloadedCovers, - failedCovers, - skippedCovers, - downloadingCoverTrack, - isBulkDownloadingCovers, - isBulkDownloadingLyrics, - onSearchChange, - onSortChange, - onToggleTrack, - onToggleSelectAll, - onDownloadTrack, - onDownloadLyrics, - onDownloadCover, - onCheckAvailability, - onDownloadAllLyrics, - onDownloadAllCovers, - onDownloadAll, - onDownloadSelected, - onStopDownload, - onOpenFolder, - onPageChange, - onAlbumClick, - onArtistClick, - onTrackClick, -}: PlaylistInfoProps) { - return ( -
+export function PlaylistInfo({ playlistInfo, trackList, searchQuery, sortBy, selectedTracks, downloadedTracks, failedTracks, skippedTracks, downloadingTrack, isDownloading, bulkDownloadType, downloadProgress, currentDownloadInfo, currentPage, itemsPerPage, downloadedLyrics, failedLyrics, skippedLyrics, downloadingLyricsTrack, checkingAvailabilityTrack, availabilityMap, downloadedCovers, failedCovers, skippedCovers, downloadingCoverTrack, isBulkDownloadingCovers, isBulkDownloadingLyrics, onSearchChange, onSortChange, onToggleTrack, onToggleSelectAll, onDownloadTrack, onDownloadLyrics, onDownloadCover, onCheckAvailability, onDownloadAllLyrics, onDownloadAllCovers, onDownloadAll, onDownloadSelected, onStopDownload, onOpenFolder, onPageChange, onAlbumClick, onArtistClick, onTrackClick, }: PlaylistInfoProps) { + return (
- {playlistInfo.owner.images && ( - {playlistInfo.owner.name} - )} + {playlistInfo.cover && ({playlistInfo.owner.name})}

Playlist

{playlistInfo.owner.name}

+ {playlistInfo.description && (

{playlistInfo.description}

)}
- {playlistInfo.owner.display_name} +
+ {playlistInfo.owner.images && ({playlistInfo.owner.display_name})} + {playlistInfo.owner.display_name} +
{playlistInfo.tracks.total} {playlistInfo.tracks.total === 1 ? "song" : "songs"} @@ -146,121 +105,46 @@ export function PlaylistInfo({
- {selectedTracks.length > 0 && ( - - )} - {onDownloadAllLyrics && ( - + )} + {onDownloadAllLyrics && ( -

Download All Lyrics

-
- )} - {onDownloadAllCovers && ( - + )} + {onDownloadAllCovers && ( -

Download All Covers

-
- )} - {downloadedTracks.size > 0 && ( - - )} + )}
- {isDownloading && ( - - )} + {isDownloading && ()}
- - + +
-
- ); +
); } diff --git a/frontend/src/components/SearchAndSort.tsx b/frontend/src/components/SearchAndSort.tsx index 32c401c..a374b37 100644 --- a/frontend/src/components/SearchAndSort.tsx +++ b/frontend/src/components/SearchAndSort.tsx @@ -1,50 +1,25 @@ import { Input } from "@/components/ui/input"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; import { Search, ArrowUpDown, XCircle } from "lucide-react"; - interface SearchAndSortProps { - searchQuery: string; - sortBy: string; - onSearchChange: (value: string) => void; - onSortChange: (value: string) => void; + searchQuery: string; + sortBy: string; + onSearchChange: (value: string) => void; + onSortChange: (value: string) => void; } - -export function SearchAndSort({ - searchQuery, - sortBy, - onSearchChange, - onSortChange, -}: SearchAndSortProps) { - return ( -
+export function SearchAndSort({ searchQuery, sortBy, onSearchChange, onSortChange, }: SearchAndSortProps) { + return (
- - onSearchChange(e.target.value)} - className="pl-10 pr-8" - /> - {searchQuery && ( - - )} + + onSearchChange(e.target.value)} className="pl-10 pr-8"/> + {searchQuery && ()}
-
- ); +
); } diff --git a/frontend/src/components/SearchBar.tsx b/frontend/src/components/SearchBar.tsx index 1a2c12e..022b30a 100644 --- a/frontend/src/components/SearchBar.tsx +++ b/frontend/src/components/SearchBar.tsx @@ -3,447 +3,316 @@ import { Button } from "@/components/ui/button"; import { InputWithContext } from "@/components/ui/input-with-context"; import { CloudDownload, Info, XCircle, Link, Search, X, ChevronDown } from "lucide-react"; import { Spinner } from "@/components/ui/spinner"; -import { - Tooltip, - TooltipContent, - TooltipTrigger, -} from "@/components/ui/tooltip"; +import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip"; import { FetchHistory } from "@/components/FetchHistory"; import type { HistoryItem } from "@/components/FetchHistory"; import { SearchSpotify, SearchSpotifyByType } from "../../wailsjs/go/main/App"; import { backend } from "../../wailsjs/go/models"; import { cn } from "@/lib/utils"; - type ResultTab = "tracks" | "albums" | "artists" | "playlists"; - const RECENT_SEARCHES_KEY = "spotiflac_recent_searches"; const MAX_RECENT_SEARCHES = 8; const SEARCH_LIMIT = 50; - interface SearchBarProps { - url: string; - loading: boolean; - onUrlChange: (url: string) => void; - onFetch: () => void; - onFetchUrl: (url: string) => Promise; - history: HistoryItem[]; - onHistorySelect: (item: HistoryItem) => void; - onHistoryRemove: (id: string) => void; - hasResult: boolean; - searchMode: boolean; - onSearchModeChange: (isSearch: boolean) => void; + url: string; + loading: boolean; + onUrlChange: (url: string) => void; + onFetch: () => void; + onFetchUrl: (url: string) => Promise; + history: HistoryItem[]; + onHistorySelect: (item: HistoryItem) => void; + onHistoryRemove: (id: string) => void; + hasResult: boolean; + searchMode: boolean; + onSearchModeChange: (isSearch: boolean) => void; } - -export function SearchBar({ - url, - loading, - onUrlChange, - onFetch, - onFetchUrl, - history, - onHistorySelect, - onHistoryRemove, - hasResult, - searchMode, - onSearchModeChange, -}: SearchBarProps) { - const [searchQuery, setSearchQuery] = useState(""); - const [searchResults, setSearchResults] = useState(null); - const [isSearching, setIsSearching] = useState(false); - const [isLoadingMore, setIsLoadingMore] = useState(false); - const [lastSearchedQuery, setLastSearchedQuery] = useState(""); - const [activeTab, setActiveTab] = useState("tracks"); - const [recentSearches, setRecentSearches] = useState([]); - const [hasMore, setHasMore] = useState>({ - tracks: false, - albums: false, - artists: false, - playlists: false, - }); - const searchTimeoutRef = useRef | null>(null); - - // Load recent searches from localStorage - useEffect(() => { - try { - const saved = localStorage.getItem(RECENT_SEARCHES_KEY); - if (saved) { - setRecentSearches(JSON.parse(saved)); - } - } catch (error) { - console.error("Failed to load recent searches:", error); - } - }, []); - - const saveRecentSearch = (query: string) => { - const trimmed = query.trim(); - if (!trimmed) return; - - setRecentSearches((prev) => { - const filtered = prev.filter((s) => s.toLowerCase() !== trimmed.toLowerCase()); - const updated = [trimmed, ...filtered].slice(0, MAX_RECENT_SEARCHES); - try { - localStorage.setItem(RECENT_SEARCHES_KEY, JSON.stringify(updated)); - } catch (error) { - console.error("Failed to save recent searches:", error); - } - return updated; +export function SearchBar({ url, loading, onUrlChange, onFetch, onFetchUrl, history, onHistorySelect, onHistoryRemove, hasResult, searchMode, onSearchModeChange, }: SearchBarProps) { + const [searchQuery, setSearchQuery] = useState(""); + const [searchResults, setSearchResults] = useState(null); + const [isSearching, setIsSearching] = useState(false); + const [isLoadingMore, setIsLoadingMore] = useState(false); + const [lastSearchedQuery, setLastSearchedQuery] = useState(""); + const [activeTab, setActiveTab] = useState("tracks"); + const [recentSearches, setRecentSearches] = useState([]); + const [hasMore, setHasMore] = useState>({ + tracks: false, + albums: false, + artists: false, + playlists: false, }); - }; - - const removeRecentSearch = (query: string) => { - setRecentSearches((prev) => { - const updated = prev.filter((s) => s !== query); - try { - localStorage.setItem(RECENT_SEARCHES_KEY, JSON.stringify(updated)); - } catch (error) { - console.error("Failed to save recent searches:", error); - } - return updated; - }); - }; - - // Debounced search - only search if query changed - useEffect(() => { - if (!searchMode || !searchQuery.trim()) { - return; - } - - // Don't search again if query is the same - if (searchQuery.trim() === lastSearchedQuery) { - return; - } - - if (searchTimeoutRef.current) { - clearTimeout(searchTimeoutRef.current); - } - - searchTimeoutRef.current = setTimeout(async () => { - setIsSearching(true); - try { - const results = await SearchSpotify({ query: searchQuery, limit: SEARCH_LIMIT }); - setSearchResults(results); - setLastSearchedQuery(searchQuery.trim()); - saveRecentSearch(searchQuery.trim()); - - // Check if there might be more results - setHasMore({ - tracks: results.tracks.length === SEARCH_LIMIT, - albums: results.albums.length === SEARCH_LIMIT, - artists: results.artists.length === SEARCH_LIMIT, - playlists: results.playlists.length === SEARCH_LIMIT, + const searchTimeoutRef = useRef | null>(null); + useEffect(() => { + try { + const saved = localStorage.getItem(RECENT_SEARCHES_KEY); + if (saved) { + setRecentSearches(JSON.parse(saved)); + } + } + catch (error) { + console.error("Failed to load recent searches:", error); + } + }, []); + const saveRecentSearch = (query: string) => { + const trimmed = query.trim(); + if (!trimmed) + return; + setRecentSearches((prev) => { + const filtered = prev.filter((s) => s.toLowerCase() !== trimmed.toLowerCase()); + const updated = [trimmed, ...filtered].slice(0, MAX_RECENT_SEARCHES); + try { + localStorage.setItem(RECENT_SEARCHES_KEY, JSON.stringify(updated)); + } + catch (error) { + console.error("Failed to save recent searches:", error); + } + return updated; }); - - // Auto-select first tab with results - if (results.tracks.length > 0) setActiveTab("tracks"); - else if (results.albums.length > 0) setActiveTab("albums"); - else if (results.artists.length > 0) setActiveTab("artists"); - else if (results.playlists.length > 0) setActiveTab("playlists"); - } catch (error) { - console.error("Search failed:", error); - setSearchResults(null); - } finally { - setIsSearching(false); - } - }, 400); - - return () => { - if (searchTimeoutRef.current) { - clearTimeout(searchTimeoutRef.current); - } }; - }, [searchQuery, searchMode, lastSearchedQuery]); - - const handleLoadMore = async () => { - if (!searchResults || !lastSearchedQuery || isLoadingMore) return; - - const typeMap: Record = { - tracks: "track", - albums: "album", - artists: "artist", - playlists: "playlist", - }; - - const currentCount = getTabCount(activeTab); - - setIsLoadingMore(true); - try { - const moreResults = await SearchSpotifyByType({ - query: lastSearchedQuery, - search_type: typeMap[activeTab], - limit: SEARCH_LIMIT, - offset: currentCount, - }); - - if (moreResults.length > 0) { - setSearchResults((prev) => { - if (!prev) return prev; - // Create new SearchResponse with updated array for the active tab - const updated = new backend.SearchResponse({ - tracks: activeTab === "tracks" ? [...prev.tracks, ...moreResults] : prev.tracks, - albums: activeTab === "albums" ? [...prev.albums, ...moreResults] : prev.albums, - artists: activeTab === "artists" ? [...prev.artists, ...moreResults] : prev.artists, - playlists: activeTab === "playlists" ? [...prev.playlists, ...moreResults] : prev.playlists, - }); - return updated; + const removeRecentSearch = (query: string) => { + setRecentSearches((prev) => { + const updated = prev.filter((s) => s !== query); + try { + localStorage.setItem(RECENT_SEARCHES_KEY, JSON.stringify(updated)); + } + catch (error) { + console.error("Failed to save recent searches:", error); + } + return updated; }); - } - - // Update hasMore for this tab - setHasMore((prev) => ({ - ...prev, - [activeTab]: moreResults.length === SEARCH_LIMIT, - })); - } catch (error) { - console.error("Load more failed:", error); - } finally { - setIsLoadingMore(false); - } - }; - - const handleResultClick = (externalUrl: string) => { - onSearchModeChange(false); - onFetchUrl(externalUrl); - }; - - const formatDuration = (ms: number) => { - const minutes = Math.floor(ms / 60000); - const seconds = Math.floor((ms % 60000) / 1000); - return `${minutes}:${seconds.toString().padStart(2, "0")}`; - }; - - const hasAnyResults = searchResults && ( - searchResults.tracks.length > 0 || - searchResults.albums.length > 0 || - searchResults.artists.length > 0 || - searchResults.playlists.length > 0 - ); - - const getTabCount = (tab: ResultTab): number => { - if (!searchResults) return 0; - switch (tab) { - case "tracks": return searchResults.tracks.length; - case "albums": return searchResults.albums.length; - case "artists": return searchResults.artists.length; - case "playlists": return searchResults.playlists.length; - } - }; - - const tabs: { key: ResultTab; label: string }[] = [ - { key: "tracks", label: "Tracks" }, - { key: "albums", label: "Albums" }, - { key: "artists", label: "Artists" }, - { key: "playlists", label: "Playlists" }, - ]; - - return ( -
+ }; + useEffect(() => { + if (!searchMode || !searchQuery.trim()) { + return; + } + if (searchQuery.trim() === lastSearchedQuery) { + return; + } + if (searchTimeoutRef.current) { + clearTimeout(searchTimeoutRef.current); + } + searchTimeoutRef.current = setTimeout(async () => { + setIsSearching(true); + try { + const results = await SearchSpotify({ query: searchQuery, limit: SEARCH_LIMIT }); + setSearchResults(results); + setLastSearchedQuery(searchQuery.trim()); + saveRecentSearch(searchQuery.trim()); + setHasMore({ + tracks: results.tracks.length === SEARCH_LIMIT, + albums: results.albums.length === SEARCH_LIMIT, + artists: results.artists.length === SEARCH_LIMIT, + playlists: results.playlists.length === SEARCH_LIMIT, + }); + if (results.tracks.length > 0) + setActiveTab("tracks"); + else if (results.albums.length > 0) + setActiveTab("albums"); + else if (results.artists.length > 0) + setActiveTab("artists"); + else if (results.playlists.length > 0) + setActiveTab("playlists"); + } + catch (error) { + console.error("Search failed:", error); + setSearchResults(null); + } + finally { + setIsSearching(false); + } + }, 400); + return () => { + if (searchTimeoutRef.current) { + clearTimeout(searchTimeoutRef.current); + } + }; + }, [searchQuery, searchMode, lastSearchedQuery]); + const handleLoadMore = async () => { + if (!searchResults || !lastSearchedQuery || isLoadingMore) + return; + const typeMap: Record = { + tracks: "track", + albums: "album", + artists: "artist", + playlists: "playlist", + }; + const currentCount = getTabCount(activeTab); + setIsLoadingMore(true); + try { + const moreResults = await SearchSpotifyByType({ + query: lastSearchedQuery, + search_type: typeMap[activeTab], + limit: SEARCH_LIMIT, + offset: currentCount, + }); + if (moreResults.length > 0) { + setSearchResults((prev) => { + if (!prev) + return prev; + const updated = new backend.SearchResponse({ + tracks: activeTab === "tracks" ? [...prev.tracks, ...moreResults] : prev.tracks, + albums: activeTab === "albums" ? [...prev.albums, ...moreResults] : prev.albums, + artists: activeTab === "artists" ? [...prev.artists, ...moreResults] : prev.artists, + playlists: activeTab === "playlists" ? [...prev.playlists, ...moreResults] : prev.playlists, + }); + return updated; + }); + } + setHasMore((prev) => ({ + ...prev, + [activeTab]: moreResults.length === SEARCH_LIMIT, + })); + } + catch (error) { + console.error("Load more failed:", error); + } + finally { + setIsLoadingMore(false); + } + }; + const handleResultClick = (externalUrl: string) => { + onSearchModeChange(false); + onFetchUrl(externalUrl); + }; + const formatDuration = (ms: number) => { + const minutes = Math.floor(ms / 60000); + const seconds = Math.floor((ms % 60000) / 1000); + return `${minutes}:${seconds.toString().padStart(2, "0")}`; + }; + const hasAnyResults = searchResults && (searchResults.tracks.length > 0 || + searchResults.albums.length > 0 || + searchResults.artists.length > 0 || + searchResults.playlists.length > 0); + const getTabCount = (tab: ResultTab): number => { + if (!searchResults) + return 0; + switch (tab) { + case "tracks": return searchResults.tracks.length; + case "albums": return searchResults.albums.length; + case "artists": return searchResults.artists.length; + case "playlists": return searchResults.playlists.length; + } + }; + const tabs: { + key: ResultTab; + label: string; + }[] = [ + { key: "tracks", label: "Tracks" }, + { key: "albums", label: "Albums" }, + { key: "artists", label: "Artists" }, + { key: "playlists", label: "Playlists" }, + ]; + return (
- {/* Mode Toggle */} +
- -
- + - {!searchMode ? ( - <> + {!searchMode ? (<>

Supports track, album, playlist, and artist URLs

Note: Playlist must be public (not private)

- - ) : ( -

Search for tracks, albums, artists, or playlists

- )} + ) : (

Search for tracks, albums, artists, or playlists

)}
- {!searchMode ? ( - <> - onUrlChange(e.target.value)} - onKeyDown={(e) => e.key === "Enter" && onFetch()} - className="pr-8" - /> - {url && ( - - )} - - ) : ( - <> - setSearchQuery(e.target.value)} - className="pr-8" - /> - {searchQuery && ( - - )} - - )} + {!searchMode ? (<> + onUrlChange(e.target.value)} onKeyDown={(e) => e.key === "Enter" && onFetch()} className="pr-8"/> + {url && ()} + ) : (<> + setSearchQuery(e.target.value)} className="pr-8"/> + {searchQuery && ()} + )}
- {!searchMode && ( - - )} + )} + )}
- {!searchMode && !hasResult && ( - - )} + {!searchMode && !hasResult && ()} - {/* Search Results with Tabs */} - {searchMode && ( -
- {/* Recent Searches - show when no query or no results yet */} - {!searchQuery && !searchResults && recentSearches.length > 0 && ( -
+ + {searchMode && (
+ + {!searchQuery && !searchResults && recentSearches.length > 0 && (

Recent Searches

- {recentSearches.map((query) => ( -
setSearchQuery(query)} - > + {recentSearches.map((query) => (
setSearchQuery(query)}> {query} - -
- ))} +
))}
-
- )} +
)} - {isSearching && ( -
+ {isSearching && (
Searching... -
- )} +
)} - {!isSearching && searchQuery && !hasAnyResults && ( -
+ {!isSearching && searchQuery && !hasAnyResults && (
No results found for "{searchQuery}" -
- )} +
)} - {!isSearching && hasAnyResults && ( - <> - {/* Tabs */} + {!isSearching && hasAnyResults && (<> +
{tabs.map((tab) => { - const count = getTabCount(tab.key); - if (count === 0) return null; - return ( - - ); + ); })}
- {/* Tab Content */} +
- {/* Tracks */} - {activeTab === "tracks" && searchResults?.tracks.map((track) => ( - - ))} + ))} - {/* Albums */} - {activeTab === "albums" && searchResults?.albums.map((album) => ( - - ))} + ))} - {/* Artists */} - {activeTab === "artists" && searchResults?.artists.map((artist) => ( - - ))} + ))} - {/* Playlists */} - {activeTab === "playlists" && searchResults?.playlists.map((playlist) => ( - - ))} + ))}
- {/* Load More Button */} - {hasMore[activeTab] && ( -
- -
- )} - - )} -
- )} -
- ); +
)} + )} +
)} +
); } diff --git a/frontend/src/components/SettingsPage.tsx b/frontend/src/components/SettingsPage.tsx index 0dfef1d..02af7c0 100644 --- a/frontend/src/components/SettingsPage.tsx +++ b/frontend/src/components/SettingsPage.tsx @@ -1,159 +1,138 @@ -import { useState, useEffect } from "react"; +import { useState, useEffect, useCallback } from "react"; +import { flushSync } from "react-dom"; import { Button } from "@/components/ui/button"; import { InputWithContext } from "@/components/ui/input-with-context"; import { Label } from "@/components/ui/label"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; - +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { FolderOpen, Save, RotateCcw, Info } from "lucide-react"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog"; +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"; import { SelectFolder } from "../../wailsjs/go/main/App"; import { toastWithSound as toast } from "@/lib/toast-with-sound"; - -// Service Icons -const TidalIcon = () => ( - +const TidalIcon = () => ( - -); - -const QobuzIcon = () => ( - + ); +const QobuzIcon = () => ( - -); - -const AmazonIcon = () => ( - + ); +const AmazonIcon = () => ( - -); - -export function SettingsPage() { - const [savedSettings, setSavedSettings] = useState(getSettings()); - const [tempSettings, setTempSettings] = useState(savedSettings); - const [isDark, setIsDark] = useState(document.documentElement.classList.contains('dark')); - const [showResetConfirm, setShowResetConfirm] = useState(false); - - useEffect(() => { - applyThemeMode(savedSettings.themeMode); - applyTheme(savedSettings.theme); - - const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)"); - const handleChange = () => { - if (savedSettings.themeMode === "auto") { - applyThemeMode("auto"); + ); +interface SettingsPageProps { + onUnsavedChangesChange?: (hasUnsavedChanges: boolean) => void; + onResetRequest?: (resetFn: () => void) => void; +} +export function SettingsPage({ onUnsavedChangesChange, onResetRequest }: SettingsPageProps) { + const [savedSettings, setSavedSettings] = useState(getSettings()); + const [tempSettings, setTempSettings] = useState(savedSettings); + const [isDark, setIsDark] = useState(document.documentElement.classList.contains('dark')); + const [showResetConfirm, setShowResetConfirm] = useState(false); + const hasUnsavedChanges = JSON.stringify(savedSettings) !== JSON.stringify(tempSettings); + const resetToSaved = useCallback(() => { + const freshSavedSettings = getSettings(); + flushSync(() => { + setTempSettings(freshSavedSettings); + setIsDark(document.documentElement.classList.contains('dark')); + }); + }, []); + useEffect(() => { + if (onResetRequest) { + onResetRequest(resetToSaved); + } + }, [onResetRequest, resetToSaved]); + useEffect(() => { + onUnsavedChangesChange?.(hasUnsavedChanges); + }, [hasUnsavedChanges, onUnsavedChangesChange]); + useEffect(() => { + applyThemeMode(savedSettings.themeMode); applyTheme(savedSettings.theme); - } + 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]); + useEffect(() => { + applyThemeMode(tempSettings.themeMode); + applyTheme(tempSettings.theme); + applyFont(tempSettings.fontFamily); + setTimeout(() => { + setIsDark(document.documentElement.classList.contains('dark')); + }, 0); + }, [tempSettings.themeMode, tempSettings.theme, tempSettings.fontFamily]); + useEffect(() => { + const loadDefaults = async () => { + if (!savedSettings.downloadPath) { + const settingsWithDefaults = await getSettingsWithDefaults(); + setSavedSettings(settingsWithDefaults); + setTempSettings(settingsWithDefaults); + saveSettings(settingsWithDefaults); + } + }; + loadDefaults(); + }, []); + const handleSave = () => { + saveSettings(tempSettings); + setSavedSettings(tempSettings); + toast.success("Settings saved"); + onUnsavedChangesChange?.(false); }; - - mediaQuery.addEventListener("change", handleChange); - return () => mediaQuery.removeEventListener("change", handleChange); - }, [savedSettings.themeMode, savedSettings.theme]); - - useEffect(() => { - applyThemeMode(tempSettings.themeMode); - applyTheme(tempSettings.theme); - applyFont(tempSettings.fontFamily); - setTimeout(() => { - setIsDark(document.documentElement.classList.contains('dark')); - }, 0); - }, [tempSettings.themeMode, tempSettings.theme, tempSettings.fontFamily]); - - useEffect(() => { - const loadDefaults = async () => { - if (!savedSettings.downloadPath) { - const settingsWithDefaults = await getSettingsWithDefaults(); - setSavedSettings(settingsWithDefaults); - setTempSettings(settingsWithDefaults); - // Save to localStorage so it persists on reload - saveSettings(settingsWithDefaults); - } + const handleReset = async () => { + const defaultSettings = await resetToDefaultSettings(); + setTempSettings(defaultSettings); + setSavedSettings(defaultSettings); + applyThemeMode(defaultSettings.themeMode); + applyTheme(defaultSettings.theme); + applyFont(defaultSettings.fontFamily); + setShowResetConfirm(false); + toast.success("Settings reset to default"); }; - loadDefaults(); - }, []); - - const handleSave = () => { - saveSettings(tempSettings); - setSavedSettings(tempSettings); - toast.success("Settings saved"); - }; - - const handleReset = async () => { - const defaultSettings = await resetToDefaultSettings(); - setTempSettings(defaultSettings); - setSavedSettings(defaultSettings); - applyThemeMode(defaultSettings.themeMode); - applyTheme(defaultSettings.theme); - applyFont(defaultSettings.fontFamily); - setShowResetConfirm(false); - toast.success("Settings reset to default"); - }; - - const handleBrowseFolder = async () => { - try { - const selectedPath = await SelectFolder(tempSettings.downloadPath || ""); - if (selectedPath && selectedPath.trim() !== "") { - setTempSettings((prev) => ({ ...prev, downloadPath: selectedPath })); - } - } catch (error) { - console.error("Error selecting folder:", error); - toast.error(`Error selecting folder: ${error}`); - } - }; - - return ( -
+ const handleBrowseFolder = async () => { + try { + const selectedPath = await SelectFolder(tempSettings.downloadPath || ""); + if (selectedPath && selectedPath.trim() !== "") { + setTempSettings((prev) => ({ ...prev, downloadPath: selectedPath })); + } + } + catch (error) { + console.error("Error selecting folder:", error); + toast.error(`Error selecting folder: ${error}`); + } + }; + return (

Settings

- {/* Left Column */} +
- {/* Download Path */} +
- setTempSettings((prev) => ({ ...prev, downloadPath: e.target.value }))} - placeholder="C:\Users\YourUsername\Music" - /> + setTempSettings((prev) => ({ ...prev, downloadPath: e.target.value }))} placeholder="C:\Users\YourUsername\Music"/>
- {/* Theme Mode */} +
- setTempSettings((prev) => ({ ...prev, themeMode: value }))}> - + Auto @@ -163,77 +142,57 @@ export function SettingsPage() {
- {/* Accent */} +
- setTempSettings((prev) => ({ ...prev, theme: value }))}> - + - {themes.map((theme) => ( - + {themes.map((theme) => ( - + {theme.label} - - ))} + ))}
- {/* Font */} +
- setTempSettings((prev) => ({ ...prev, fontFamily: value }))}> - + - {FONT_OPTIONS.map((font) => ( - + {FONT_OPTIONS.map((font) => ( {font.label} - - ))} + ))}
- {/* Sound Effects */} +
- setTempSettings(prev => ({ ...prev, sfxEnabled: checked }))} - /> + setTempSettings(prev => ({ ...prev, sfxEnabled: checked }))}/>
- {/* Right Column */} +
- {/* Source Selection */} +
- setTempSettings((prev) => ({ ...prev, downloader: value }))}> - + Auto @@ -248,12 +207,8 @@ export function SettingsPage() { - {/* Quality dropdown for Tidal */} - {tempSettings.downloader === "tidal" && ( - setTempSettings((prev) => ({ ...prev, tidalQuality: value }))}> @@ -261,14 +216,9 @@ export function SettingsPage() { Lossless (16-bit/CD Quality) Hi-Res Lossless (24-bit/48kHz+) - - )} - {/* Quality dropdown for Qobuz */} - {tempSettings.downloader === "qobuz" && ( - )} + + {tempSettings.downloader === "qobuz" && ( - )} + )} + + {tempSettings.downloader === "amazon" && ()}
- {/* Embed Lyrics & Embed Max Quality Cover */} +
- setTempSettings(prev => ({ ...prev, embedLyrics: checked }))} - /> + setTempSettings(prev => ({ ...prev, embedLyrics: checked }))}/>
- setTempSettings(prev => ({ ...prev, embedMaxQualityCover: checked }))} - /> + setTempSettings(prev => ({ ...prev, embedMaxQualityCover: checked }))}/>
-
+
- {/* Folder Structure */} +
- +

Variables: {TEMPLATE_VARIABLES.map(v => v.key).join(", ")}

@@ -318,51 +268,37 @@ export function SettingsPage() {
- { + const preset = FOLDER_PRESETS[value]; + setTempSettings(prev => ({ + ...prev, + folderPreset: value, + folderTemplate: value === "custom" ? (prev.folderTemplate || preset.template) : preset.template + })); + }}> - {Object.entries(FOLDER_PRESETS).map(([key, { label }]) => ( - {label} - ))} + {Object.entries(FOLDER_PRESETS).map(([key, { label }]) => ({label}))} - {tempSettings.folderPreset === "custom" && ( - setTempSettings(prev => ({ ...prev, folderTemplate: e.target.value }))} - placeholder="{artist}/{album}" - className="h-9 text-sm flex-1" - /> - )} + {tempSettings.folderPreset === "custom" && ( setTempSettings(prev => ({ ...prev, folderTemplate: e.target.value }))} placeholder="{artist}/{album}" className="h-9 text-sm flex-1"/>)}
- {tempSettings.folderTemplate && ( -

+ {tempSettings.folderTemplate && (

Preview: {tempSettings.folderTemplate.replace(/\{artist\}/g, "Kendrick Lamar, SZA").replace(/\{album\}/g, "Black Panther").replace(/\{album_artist\}/g, "Kendrick Lamar").replace(/\{year\}/g, "2018")}/ -

- )} +

)}
-
+
- {/* Filename Format */} +
- +

Variables: {TEMPLATE_VARIABLES.map(v => v.key).join(", ")}

@@ -370,57 +306,43 @@ export function SettingsPage() {
- { + const preset = FILENAME_PRESETS[value]; + setTempSettings(prev => ({ + ...prev, + filenamePreset: value, + filenameTemplate: value === "custom" ? (prev.filenameTemplate || preset.template) : preset.template + })); + }}> - {Object.entries(FILENAME_PRESETS).map(([key, { label }]) => ( - {label} - ))} + {Object.entries(FILENAME_PRESETS).map(([key, { label }]) => ({label}))} - {tempSettings.filenamePreset === "custom" && ( - setTempSettings(prev => ({ ...prev, filenameTemplate: e.target.value }))} - placeholder="{track}. {title}" - className="h-9 text-sm flex-1" - /> - )} + {tempSettings.filenamePreset === "custom" && ( setTempSettings(prev => ({ ...prev, filenameTemplate: e.target.value }))} placeholder="{track}. {title}" className="h-9 text-sm flex-1"/>)}
- {tempSettings.filenameTemplate && ( -

+ {tempSettings.filenameTemplate && (

Preview: {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 -

- )} +

)}
- {/* Actions */} +
- {/* Reset Confirmation Dialog */} + @@ -435,6 +357,5 @@ export function SettingsPage() { -
- ); +
); } diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 0b9357d..1d3c42c 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -1,40 +1,28 @@ -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 { FileMusicIcon } from "@/components/ui/file-music"; +import { FilePenIcon } from "@/components/ui/file-pen"; import { GithubIcon } from "@/components/ui/github"; import { BlocksIcon } from "@/components/ui/blocks"; import { CoffeeIcon } from "@/components/ui/coffee"; -import { - Tooltip, - TooltipContent, - TooltipTrigger, -} from "@/components/ui/tooltip"; +import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip"; import { Button } from "@/components/ui/button"; import { openExternal } from "@/lib/utils"; - export type PageType = "main" | "settings" | "debug" | "audio-analysis" | "audio-converter" | "file-manager"; - interface SidebarProps { - currentPage: PageType; - onPageChange: (page: PageType) => void; + currentPage: PageType; + onPageChange: (page: PageType) => void; } - export function Sidebar({ currentPage, onPageChange }: SidebarProps) { - return ( -
+ return (
- {/* Home */} + - @@ -42,16 +30,11 @@ export function Sidebar({ currentPage, onPageChange }: SidebarProps) { - {/* Settings */} + - @@ -59,16 +42,11 @@ export function Sidebar({ currentPage, onPageChange }: SidebarProps) { - {/* Audio Analysis */} + - @@ -76,16 +54,11 @@ export function Sidebar({ currentPage, onPageChange }: SidebarProps) { - {/* Audio Converter - using lucide icon (no animated version) */} + - @@ -93,16 +66,11 @@ export function Sidebar({ currentPage, onPageChange }: SidebarProps) { - {/* File Manager - using lucide icon (no animated version) */} + - @@ -110,16 +78,11 @@ export function Sidebar({ currentPage, onPageChange }: SidebarProps) { - {/* Debug */} + - @@ -128,17 +91,12 @@ export function Sidebar({ currentPage, onPageChange }: SidebarProps) {
- {/* Bottom icons */} +
- @@ -147,13 +105,8 @@ export function Sidebar({ currentPage, onPageChange }: SidebarProps) { - @@ -162,20 +115,14 @@ export function Sidebar({ currentPage, onPageChange }: SidebarProps) { - -

Support me on Ko-fi

+

Every coffee helps me keep going

-
- ); +
); } diff --git a/frontend/src/components/SpectrumVisualization.tsx b/frontend/src/components/SpectrumVisualization.tsx index f0cfa41..5900ebb 100644 --- a/frontend/src/components/SpectrumVisualization.tsx +++ b/frontend/src/components/SpectrumVisualization.tsx @@ -1,289 +1,193 @@ import { useEffect, useRef } from "react"; import type { SpectrumData } from "@/types/api"; - interface SpectrumVisualizationProps { - sampleRate: number; - bitsPerSample: number; - duration: number; - spectrumData?: SpectrumData; + sampleRate: number; + bitsPerSample: number; + duration: number; + spectrumData?: SpectrumData; } - -export function SpectrumVisualization({ - sampleRate, - bitsPerSample, - duration, - spectrumData, -}: SpectrumVisualizationProps) { - const canvasRef = useRef(null); - - useEffect(() => { - const canvas = canvasRef.current; - if (!canvas) return; - - const ctx = canvas.getContext("2d"); - if (!ctx) return; - - const width = canvas.width; - const height = canvas.height; - - // Calculate margins for labels - const marginLeft = 70; // More space for Frequency label - const marginRight = 70; // Space for color bar - const marginTop = 30; // More space at top - const marginBottom = 65; // More space at bottom for Time label - - const plotWidth = width - marginLeft - marginRight; - const plotHeight = height - marginTop - marginBottom; - - // Black background - ctx.fillStyle = "#000000"; - ctx.fillRect(0, 0, width, height); - - // Calculate Nyquist frequency - const nyquistFreq = sampleRate / 2; - - if (spectrumData) { - drawRealSpectrum( - ctx, - marginLeft, - marginTop, - plotWidth, - plotHeight, - spectrumData - ); - } - - // Draw axes, labels, and color bar - drawAxesAndLabels(ctx, marginLeft, marginTop, plotWidth, plotHeight, nyquistFreq, duration, sampleRate); - drawColorBar(ctx, marginLeft + plotWidth + 15, marginTop, 20, plotHeight); - }, [sampleRate, bitsPerSample, duration, spectrumData]); - - const drawRealSpectrum = ( - ctx: CanvasRenderingContext2D, - x: number, - y: number, - width: number, - height: number, - spectrum: SpectrumData - ) => { - const timeSlices = spectrum.time_slices; - if (timeSlices.length === 0) return; - - const freqBins = timeSlices[0].magnitudes.length; - const nyquistFreq = spectrum.max_freq; - - // Find min/max dB values - let minDB = 0; - let maxDB = -200; - - timeSlices.forEach((slice) => { - slice.magnitudes.forEach((db) => { - if (db > maxDB) maxDB = db; - if (db < minDB && db > -200) minDB = db; - }); - }); - - // Clamp range for better visualization - minDB = Math.max(minDB, maxDB - 90); // 90dB dynamic range - const dbRange = maxDB - minDB; - - const sliceWidth = Math.ceil(width / timeSlices.length); - - for (let t = 0; t < timeSlices.length; t++) { - const slice = timeSlices[t]; - const xPos = x + (t / timeSlices.length) * width; - - for (let f = 0; f < freqBins && f < slice.magnitudes.length; f++) { - const db = slice.magnitudes[f]; - - // Linear frequency scale - const freq = (f / freqBins) * nyquistFreq; - const freqRatio = freq / nyquistFreq; - - const yPos = y + height - (freqRatio * height); - - // Calculate bin height - const nextFreq = ((f + 1) / freqBins) * nyquistFreq; - const nextFreqRatio = nextFreq / nyquistFreq; - const nextYPos = y + height - (nextFreqRatio * height); - - const binHeight = Math.max(1, Math.abs(yPos - nextYPos) + 1); - - // Normalize intensity - const intensity = Math.max(0, Math.min(1, (db - minDB) / dbRange)); - - const color = getSpekColor(intensity); - ctx.fillStyle = color; - ctx.fillRect(xPos, nextYPos, sliceWidth, binHeight); - } - } - }; - - // Vibrant color scheme like Spek - NGEJERENG! - const getSpekColor = (intensity: number): string => { - if (intensity < 0.08) { - // Black to deep blue - const t = intensity / 0.08; - return `rgb(0, 0, ${Math.floor(t * 80)})`; - } else if (intensity < 0.18) { - // Deep blue to bright blue - const t = (intensity - 0.08) / 0.10; - return `rgb(${Math.floor(t * 50)}, ${Math.floor(t * 30)}, ${Math.floor(80 + t * 175)})`; - } else if (intensity < 0.28) { - // Blue to magenta/purple - const t = (intensity - 0.18) / 0.10; - return `rgb(${Math.floor(50 + t * 150)}, ${Math.floor(30 - t * 30)}, ${Math.floor(255 - t * 55)})`; - } else if (intensity < 0.40) { - // Magenta to bright red - const t = (intensity - 0.28) / 0.12; - return `rgb(${Math.floor(200 + t * 55)}, 0, ${Math.floor(200 - t * 200)})`; - } else if (intensity < 0.52) { - // Red to orange-red - const t = (intensity - 0.40) / 0.12; - return `rgb(255, ${Math.floor(t * 100)}, 0)`; - } else if (intensity < 0.65) { - // Orange-red to bright orange - const t = (intensity - 0.52) / 0.13; - return `rgb(255, ${Math.floor(100 + t * 80)}, 0)`; - } else if (intensity < 0.78) { - // Orange to yellow-orange - const t = (intensity - 0.65) / 0.13; - return `rgb(255, ${Math.floor(180 + t * 55)}, ${Math.floor(t * 30)})`; - } else if (intensity < 0.90) { - // Yellow-orange to bright yellow - const t = (intensity - 0.78) / 0.12; - return `rgb(255, ${Math.floor(235 + t * 20)}, ${Math.floor(30 + t * 100)})`; - } else { - // Yellow to white (hottest) - const t = (intensity - 0.90) / 0.10; - return `rgb(255, 255, ${Math.floor(130 + t * 125)})`; - } - }; - - const drawAxesAndLabels = ( - ctx: CanvasRenderingContext2D, - x: number, - y: number, - width: number, - height: number, - nyquistFreq: number, - duration: number, - sampleRate: number - ) => { - // Frequency labels on Y-axis - ctx.fillStyle = "#CCCCCC"; - ctx.font = "12px Arial"; - ctx.textAlign = "right"; - ctx.textBaseline = "middle"; - - // Generate frequency labels based on Nyquist - const freqLabels = generateFreqLabels(nyquistFreq); - - freqLabels.forEach(freq => { - if (freq <= nyquistFreq) { - const freqRatio = freq / nyquistFreq; - const yPos = y + height - (freqRatio * height); - const label = freq >= 1000 ? `${freq / 1000}k` : `${freq}`; - ctx.fillText(label, x - 8, yPos); - } - }); - - // "0" at bottom - ctx.fillText("0", x - 8, y + height); - - // Time labels on X-axis - ctx.textAlign = "center"; - ctx.textBaseline = "top"; - - const timeStep = getTimeStep(duration); - for (let t = 0; t <= duration; t += timeStep) { - const xPos = x + (t / duration) * width; - ctx.fillText(`${Math.round(t)}s`, xPos, y + height + 8); - } - - // Axis titles - ctx.fillStyle = "#FFFFFF"; - ctx.font = "13px Arial"; - - // Y-axis title: "Frequency (Hz)" - ctx.save(); - ctx.translate(12, y + height / 2); - ctx.rotate(-Math.PI / 2); - ctx.textAlign = "center"; - ctx.fillText("Frequency (Hz)", 0, 0); - ctx.restore(); - - // X-axis title: "Time (seconds)" - ctx.textAlign = "center"; - ctx.fillText("Time (seconds)", x + width / 2, y + height + 35); - - // Sample rate info in top right - ctx.textAlign = "right"; - ctx.fillStyle = "#CCCCCC"; - ctx.font = "12px Arial"; - ctx.fillText(`Sample Rate: ${sampleRate} Hz`, x + width - 5, y - 3); - }; - - const generateFreqLabels = (nyquistFreq: number): number[] => { - if (nyquistFreq <= 24000) { - return [2000, 4000, 6000, 8000, 10000, 12000, 14000, 16000, 18000, 20000, 22000]; - } else if (nyquistFreq <= 48000) { - return [5000, 10000, 15000, 20000, 25000, 30000, 35000, 40000, 45000]; - } else if (nyquistFreq <= 96000) { - return [10000, 20000, 30000, 40000, 50000, 60000, 70000, 80000, 90000]; - } else { - return [20000, 40000, 60000, 80000, 100000, 120000, 140000, 160000, 180000]; - } - }; - - const getTimeStep = (duration: number): number => { - // Always use 30s intervals like the reference image - if (duration <= 60) return 15; - if (duration <= 120) return 30; - if (duration <= 300) return 30; - if (duration <= 600) return 60; - return 60; - }; - - const drawColorBar = ( - ctx: CanvasRenderingContext2D, - x: number, - y: number, - width: number, - height: number - ) => { - // Draw gradient color bar - for (let i = 0; i < height; i++) { - const intensity = 1 - (i / height); // Top is high, bottom is low - const color = getSpekColor(intensity); - ctx.fillStyle = color; - ctx.fillRect(x, y + i, width, 1); - } - - // Border around color bar - ctx.strokeStyle = "#666666"; - ctx.lineWidth = 1; - ctx.strokeRect(x, y, width, height); - - // Labels - ctx.fillStyle = "#FFFFFF"; - ctx.font = "11px Arial"; - ctx.textAlign = "left"; - ctx.textBaseline = "middle"; - - ctx.fillText("High", x + width + 5, y + 10); - ctx.fillText("Low", x + width + 5, y + height - 10); - }; - - return ( -
- -
- ); +export function SpectrumVisualization({ sampleRate, bitsPerSample, duration, spectrumData, }: SpectrumVisualizationProps) { + const canvasRef = useRef(null); + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) + return; + const ctx = canvas.getContext("2d"); + if (!ctx) + return; + const width = canvas.width; + const height = canvas.height; + const marginLeft = 70; + const marginRight = 70; + const marginTop = 30; + const marginBottom = 65; + const plotWidth = width - marginLeft - marginRight; + const plotHeight = height - marginTop - marginBottom; + ctx.fillStyle = "#000000"; + ctx.fillRect(0, 0, width, height); + const nyquistFreq = sampleRate / 2; + if (spectrumData) { + drawRealSpectrum(ctx, marginLeft, marginTop, plotWidth, plotHeight, spectrumData); + } + drawAxesAndLabels(ctx, marginLeft, marginTop, plotWidth, plotHeight, nyquistFreq, duration, sampleRate); + drawColorBar(ctx, marginLeft + plotWidth + 15, marginTop, 20, plotHeight); + }, [sampleRate, bitsPerSample, duration, spectrumData]); + const drawRealSpectrum = (ctx: CanvasRenderingContext2D, x: number, y: number, width: number, height: number, spectrum: SpectrumData) => { + const timeSlices = spectrum.time_slices; + if (timeSlices.length === 0) + return; + const freqBins = timeSlices[0].magnitudes.length; + const nyquistFreq = spectrum.max_freq; + let minDB = 0; + let maxDB = -200; + timeSlices.forEach((slice) => { + slice.magnitudes.forEach((db) => { + if (db > maxDB) + maxDB = db; + if (db < minDB && db > -200) + minDB = db; + }); + }); + minDB = Math.max(minDB, maxDB - 90); + const dbRange = maxDB - minDB; + const sliceWidth = Math.ceil(width / timeSlices.length); + for (let t = 0; t < timeSlices.length; t++) { + const slice = timeSlices[t]; + const xPos = x + (t / timeSlices.length) * width; + for (let f = 0; f < freqBins && f < slice.magnitudes.length; f++) { + const db = slice.magnitudes[f]; + const freq = (f / freqBins) * nyquistFreq; + const freqRatio = freq / nyquistFreq; + const yPos = y + height - (freqRatio * height); + const nextFreq = ((f + 1) / freqBins) * nyquistFreq; + const nextFreqRatio = nextFreq / nyquistFreq; + const nextYPos = y + height - (nextFreqRatio * height); + const binHeight = Math.max(1, Math.abs(yPos - nextYPos) + 1); + const intensity = Math.max(0, Math.min(1, (db - minDB) / dbRange)); + const color = getSpekColor(intensity); + ctx.fillStyle = color; + ctx.fillRect(xPos, nextYPos, sliceWidth, binHeight); + } + } + }; + const getSpekColor = (intensity: number): string => { + if (intensity < 0.08) { + const t = intensity / 0.08; + return `rgb(0, 0, ${Math.floor(t * 80)})`; + } + else if (intensity < 0.18) { + const t = (intensity - 0.08) / 0.10; + return `rgb(${Math.floor(t * 50)}, ${Math.floor(t * 30)}, ${Math.floor(80 + t * 175)})`; + } + else if (intensity < 0.28) { + const t = (intensity - 0.18) / 0.10; + return `rgb(${Math.floor(50 + t * 150)}, ${Math.floor(30 - t * 30)}, ${Math.floor(255 - t * 55)})`; + } + else if (intensity < 0.40) { + const t = (intensity - 0.28) / 0.12; + return `rgb(${Math.floor(200 + t * 55)}, 0, ${Math.floor(200 - t * 200)})`; + } + else if (intensity < 0.52) { + const t = (intensity - 0.40) / 0.12; + return `rgb(255, ${Math.floor(t * 100)}, 0)`; + } + else if (intensity < 0.65) { + const t = (intensity - 0.52) / 0.13; + return `rgb(255, ${Math.floor(100 + t * 80)}, 0)`; + } + else if (intensity < 0.78) { + const t = (intensity - 0.65) / 0.13; + return `rgb(255, ${Math.floor(180 + t * 55)}, ${Math.floor(t * 30)})`; + } + else if (intensity < 0.90) { + const t = (intensity - 0.78) / 0.12; + return `rgb(255, ${Math.floor(235 + t * 20)}, ${Math.floor(30 + t * 100)})`; + } + else { + const t = (intensity - 0.90) / 0.10; + return `rgb(255, 255, ${Math.floor(130 + t * 125)})`; + } + }; + const drawAxesAndLabels = (ctx: CanvasRenderingContext2D, x: number, y: number, width: number, height: number, nyquistFreq: number, duration: number, sampleRate: number) => { + ctx.fillStyle = "#CCCCCC"; + ctx.font = "12px Arial"; + ctx.textAlign = "right"; + ctx.textBaseline = "middle"; + const freqLabels = generateFreqLabels(nyquistFreq); + freqLabels.forEach(freq => { + if (freq <= nyquistFreq) { + const freqRatio = freq / nyquistFreq; + const yPos = y + height - (freqRatio * height); + const label = freq >= 1000 ? `${freq / 1000}k` : `${freq}`; + ctx.fillText(label, x - 8, yPos); + } + }); + ctx.fillText("0", x - 8, y + height); + ctx.textAlign = "center"; + ctx.textBaseline = "top"; + const timeStep = getTimeStep(duration); + for (let t = 0; t <= duration; t += timeStep) { + const xPos = x + (t / duration) * width; + ctx.fillText(`${Math.round(t)}s`, xPos, y + height + 8); + } + ctx.fillStyle = "#FFFFFF"; + ctx.font = "13px Arial"; + ctx.save(); + ctx.translate(12, y + height / 2); + ctx.rotate(-Math.PI / 2); + ctx.textAlign = "center"; + ctx.fillText("Frequency (Hz)", 0, 0); + ctx.restore(); + ctx.textAlign = "center"; + ctx.fillText("Time (seconds)", x + width / 2, y + height + 35); + ctx.textAlign = "right"; + ctx.fillStyle = "#CCCCCC"; + ctx.font = "12px Arial"; + ctx.fillText(`Sample Rate: ${sampleRate} Hz`, x + width - 5, y - 3); + }; + const generateFreqLabels = (nyquistFreq: number): number[] => { + if (nyquistFreq <= 24000) { + return [2000, 4000, 6000, 8000, 10000, 12000, 14000, 16000, 18000, 20000, 22000]; + } + else if (nyquistFreq <= 48000) { + return [5000, 10000, 15000, 20000, 25000, 30000, 35000, 40000, 45000]; + } + else if (nyquistFreq <= 96000) { + return [10000, 20000, 30000, 40000, 50000, 60000, 70000, 80000, 90000]; + } + else { + return [20000, 40000, 60000, 80000, 100000, 120000, 140000, 160000, 180000]; + } + }; + const getTimeStep = (duration: number): number => { + if (duration <= 60) + return 15; + if (duration <= 120) + return 30; + if (duration <= 300) + return 30; + if (duration <= 600) + return 60; + return 60; + }; + const drawColorBar = (ctx: CanvasRenderingContext2D, x: number, y: number, width: number, height: number) => { + for (let i = 0; i < height; i++) { + const intensity = 1 - (i / height); + const color = getSpekColor(intensity); + ctx.fillStyle = color; + ctx.fillRect(x, y + i, width, 1); + } + ctx.strokeStyle = "#666666"; + ctx.lineWidth = 1; + ctx.strokeRect(x, y, width, height); + ctx.fillStyle = "#FFFFFF"; + ctx.font = "11px Arial"; + ctx.textAlign = "left"; + ctx.textBaseline = "middle"; + ctx.fillText("High", x + width + 5, y + 10); + ctx.fillText("Low", x + width + 5, y + height - 10); + }; + return (
+ +
); } diff --git a/frontend/src/components/TitleBar.tsx b/frontend/src/components/TitleBar.tsx index dda3489..acc0471 100644 --- a/frontend/src/components/TitleBar.tsx +++ b/frontend/src/components/TitleBar.tsx @@ -1,55 +1,30 @@ import { X, Minus, Maximize } from "lucide-react"; import { WindowMinimise, WindowToggleMaximise, Quit } from "../../wailsjs/runtime/runtime"; - export function TitleBar() { - const handleMinimize = () => { - WindowMinimise(); - }; - - const handleMaximize = () => { - WindowToggleMaximise(); - }; - - const handleClose = () => { - Quit(); - }; - - return ( - <> - {/* Draggable area */} -
+ const handleMinimize = () => { + WindowMinimise(); + }; + const handleMaximize = () => { + WindowToggleMaximise(); + }; + const handleClose = () => { + Quit(); + }; + return (<> + +
+ - {/* Window control buttons - Windows style, right side */}
- - -
- - ); + ); } diff --git a/frontend/src/components/TrackInfo.tsx b/frontend/src/components/TrackInfo.tsx index be5570a..92a136a 100644 --- a/frontend/src/components/TrackInfo.tsx +++ b/frontend/src/components/TrackInfo.tsx @@ -2,206 +2,138 @@ import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; import { Download, FolderOpen, CheckCircle, XCircle, FileText, FileCheck, Globe, ImageDown } from "lucide-react"; import { Spinner } from "@/components/ui/spinner"; -import { - Tooltip, - TooltipContent, - TooltipTrigger, -} from "@/components/ui/tooltip"; +import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip"; import type { TrackMetadata, TrackAvailability } from "@/types/api"; import { TidalIcon, QobuzIcon, AmazonIcon } from "./PlatformIcons"; - interface TrackInfoProps { - track: TrackMetadata & { album_name: string; release_date: string }; - isDownloading: boolean; - downloadingTrack: string | null; - isDownloaded: boolean; - isFailed: boolean; - isSkipped: boolean; - downloadingLyricsTrack?: string | null; - downloadedLyrics?: boolean; - failedLyrics?: boolean; - skippedLyrics?: boolean; - checkingAvailability?: boolean; - availability?: TrackAvailability; - downloadingCover?: boolean; - downloadedCover?: boolean; - failedCover?: boolean; - skippedCover?: boolean; - 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, albumArtist?: string, releaseDate?: string, discNumber?: number) => void; - onCheckAvailability?: (spotifyId: string, isrc?: string) => void; - onDownloadCover?: (coverUrl: string, trackName: string, artistName: string, albumName?: string, playlistName?: string, position?: number, trackId?: string, albumArtist?: string, releaseDate?: string, discNumber?: number) => void; - onOpenFolder: () => void; + track: TrackMetadata & { + album_name: string; + release_date: string; + }; + isDownloading: boolean; + downloadingTrack: string | null; + isDownloaded: boolean; + isFailed: boolean; + isSkipped: boolean; + downloadingLyricsTrack?: string | null; + downloadedLyrics?: boolean; + failedLyrics?: boolean; + skippedLyrics?: boolean; + checkingAvailability?: boolean; + availability?: TrackAvailability; + downloadingCover?: boolean; + downloadedCover?: boolean; + failedCover?: boolean; + skippedCover?: boolean; + 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, spotifyTotalDiscs?: number, copyright?: string, publisher?: string) => void; + onDownloadLyrics?: (spotifyId: string, name: string, artists: string, albumName?: string, albumArtist?: string, releaseDate?: string, discNumber?: number) => void; + onCheckAvailability?: (spotifyId: string, isrc?: string) => void; + onDownloadCover?: (coverUrl: string, trackName: string, artistName: string, albumName?: string, playlistName?: string, position?: number, trackId?: string, albumArtist?: string, releaseDate?: string, discNumber?: number) => void; + onOpenFolder: () => void; } - -export function TrackInfo({ - track, - isDownloading, - downloadingTrack, - isDownloaded, - isFailed, - isSkipped, - downloadingLyricsTrack, - downloadedLyrics, - failedLyrics, - skippedLyrics, - checkingAvailability, - availability, - downloadingCover, - downloadedCover, - failedCover, - skippedCover, - onDownload, - onDownloadLyrics, - onCheckAvailability, - onDownloadCover, - onOpenFolder, -}: TrackInfoProps) { - return ( - +export function TrackInfo({ track, isDownloading, downloadingTrack, isDownloaded, isFailed, isSkipped, downloadingLyricsTrack, downloadedLyrics, failedLyrics, skippedLyrics, checkingAvailability, availability, downloadingCover, downloadedCover, failedCover, skippedCover, onDownload, onDownloadLyrics, onCheckAvailability, onDownloadCover, onOpenFolder, }: TrackInfoProps) { + const formatDuration = (ms: number) => { + const minutes = Math.floor(ms / 60000); + const seconds = Math.floor((ms % 60000) / 1000); + return `${minutes}:${seconds.toString().padStart(2, "0")}`; + }; + const formatPlays = (plays: string) => { + const num = parseInt(plays, 10); + if (isNaN(num)) + return plays; + return num.toLocaleString(); + }; + return (
- {track.images && ( - {track.name} - )} + {track.images && (
+ {track.name} +
+ {formatDuration(track.duration_ms)} +
+
)}

{track.name}

- {isSkipped ? ( - - ) : isDownloaded ? ( - - ) : isFailed ? ( - - ) : null} + {isSkipped ? () : isDownloaded ? () : isFailed ? () : null}

{track.artists}

-
-

Album

-

{track.album_name}

+
+
+

Album

+

{track.album_name}

+
+ {track.plays && (
+

Total Plays

+

{formatPlays(track.plays)}

+
)}
-
-

Release Date

-

{track.release_date}

+
+
+

Release Date

+

{track.release_date}

+
+ {track.copyright && (
+

Copyright

+

+ {track.copyright} +

+
)}
- {track.isrc && ( -
- - {track.spotify_id && onDownloadLyrics && ( - + {track.spotify_id && onDownloadLyrics && ( -

Download Lyric

-
- )} - {track.images && onDownloadCover && ( - + )} + {track.images && onDownloadCover && ( -

Download Cover

-
- )} - {track.spotify_id && onCheckAvailability && ( - + )} + {track.spotify_id && onCheckAvailability && ( - - {availability ? ( -
- - - -
- ) : ( -

Check Availability

- )} + {availability ? (
+ + + +
) : (

Check Availability

)}
-
- )} - {isDownloaded && ( - - )} -
- )} + )} +
)}
- - ); + ); } diff --git a/frontend/src/components/TrackList.tsx b/frontend/src/components/TrackList.tsx index 78a31d2..0f0bd07 100644 --- a/frontend/src/components/TrackList.tsx +++ b/frontend/src/components/TrackList.tsx @@ -2,486 +2,325 @@ import { Button } from "@/components/ui/button"; import { Checkbox } from "@/components/ui/checkbox"; import { Download, CheckCircle, XCircle, FileCheck, FileText, Globe, ImageDown } from "lucide-react"; import { Spinner } from "@/components/ui/spinner"; -import { - Tooltip, - TooltipContent, - TooltipTrigger, -} from "@/components/ui/tooltip"; -import { - Pagination, - PaginationContent, - PaginationItem, - PaginationLink, - PaginationNext, - PaginationPrevious, -} from "@/components/ui/pagination"; +import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip"; +import { Pagination, PaginationContent, PaginationItem, PaginationLink, PaginationNext, PaginationPrevious, } from "@/components/ui/pagination"; import type { TrackMetadata, TrackAvailability } from "@/types/api"; import { TidalIcon, QobuzIcon, AmazonIcon } from "./PlatformIcons"; - interface TrackListProps { - tracks: TrackMetadata[]; - searchQuery: string; - sortBy: string; - selectedTracks: string[]; - downloadedTracks: Set; - failedTracks: Set; - skippedTracks: Set; - downloadingTrack: string | null; - isDownloading: boolean; - currentPage: number; - itemsPerPage: number; - showCheckboxes?: boolean; - hideAlbumColumn?: boolean; - folderName?: string; - isArtistDiscography?: boolean; - // Lyrics props - downloadedLyrics?: Set; - failedLyrics?: Set; - skippedLyrics?: Set; - downloadingLyricsTrack?: string | null; - // Availability props - checkingAvailabilityTrack?: string | null; - availabilityMap?: Map; - // Cover props - downloadedCovers?: Set; - failedCovers?: Set; - skippedCovers?: Set; - 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, 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, albumArtist?: string, releaseDate?: string, discNumber?: 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, albumArtist?: string, releaseDate?: string, discNumber?: number) => void; - onPageChange: (page: number) => void; - onAlbumClick?: (album: { id: string; name: string; external_urls: string }) => void; - onArtistClick?: (artist: { id: string; name: string; external_urls: string }) => void; - onTrackClick?: (track: TrackMetadata) => void; + tracks: TrackMetadata[]; + searchQuery: string; + sortBy: string; + selectedTracks: string[]; + downloadedTracks: Set; + failedTracks: Set; + skippedTracks: Set; + downloadingTrack: string | null; + isDownloading: boolean; + currentPage: number; + itemsPerPage: number; + showCheckboxes?: boolean; + hideAlbumColumn?: boolean; + folderName?: string; + isArtistDiscography?: boolean; + downloadedLyrics?: Set; + failedLyrics?: Set; + skippedLyrics?: Set; + downloadingLyricsTrack?: string | null; + checkingAvailabilityTrack?: string | null; + availabilityMap?: Map; + downloadedCovers?: Set; + failedCovers?: Set; + skippedCovers?: Set; + 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, albumArtist?: string, releaseDate?: string, coverUrl?: string, spotifyTrackNumber?: number, spotifyDiscNumber?: number, spotifyTotalTracks?: number, spotifyTotalDiscs?: number, copyright?: string, publisher?: string) => void; + onDownloadLyrics?: (spotifyId: string, name: string, artists: string, albumName: string, folderName?: string, isArtistDiscography?: boolean, position?: number, albumArtist?: string, releaseDate?: string, discNumber?: 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, albumArtist?: string, releaseDate?: string, discNumber?: number) => void; + onPageChange: (page: number) => void; + onAlbumClick?: (album: { + id: string; + name: string; + external_urls: string; + }) => void; + onArtistClick?: (artist: { + id: string; + name: string; + external_urls: string; + }) => void; + onTrackClick?: (track: TrackMetadata) => void; } - -export function TrackList({ - tracks, - searchQuery, - sortBy, - selectedTracks, - downloadedTracks, - failedTracks, - skippedTracks, - downloadingTrack, - isDownloading, - currentPage, - itemsPerPage, - showCheckboxes = false, - hideAlbumColumn = false, - folderName, - isArtistDiscography = false, - downloadedLyrics, - failedLyrics, - skippedLyrics, - downloadingLyricsTrack, - checkingAvailabilityTrack, - availabilityMap, - downloadedCovers, - failedCovers, - skippedCovers, - downloadingCoverTrack, - onToggleTrack, - onToggleSelectAll, - onDownloadTrack, - onDownloadLyrics, - onCheckAvailability, - onDownloadCover, - onPageChange, - onAlbumClick, - onArtistClick, - onTrackClick, -}: TrackListProps) { - let filteredTracks = tracks.filter((track) => { - if (!searchQuery) return true; - const query = searchQuery.toLowerCase(); - return ( - track.name.toLowerCase().includes(query) || - track.artists.toLowerCase().includes(query) || - track.album_name.toLowerCase().includes(query) - ); - }); - - // Apply sorting - if (sortBy === "title-asc") { - filteredTracks = [...filteredTracks].sort((a, b) => a.name.localeCompare(b.name)); - } else if (sortBy === "title-desc") { - filteredTracks = [...filteredTracks].sort((a, b) => b.name.localeCompare(a.name)); - } else if (sortBy === "artist-asc") { - filteredTracks = [...filteredTracks].sort((a, b) => a.artists.localeCompare(b.artists)); - } else if (sortBy === "artist-desc") { - filteredTracks = [...filteredTracks].sort((a, b) => b.artists.localeCompare(a.artists)); - } else if (sortBy === "duration-asc") { - filteredTracks = [...filteredTracks].sort((a, b) => a.duration_ms - b.duration_ms); - } else if (sortBy === "duration-desc") { - filteredTracks = [...filteredTracks].sort((a, b) => b.duration_ms - a.duration_ms); - } else if (sortBy === "downloaded") { - filteredTracks = [...filteredTracks].sort((a, b) => { - const aDownloaded = downloadedTracks.has(a.isrc); - const bDownloaded = downloadedTracks.has(b.isrc); - return (bDownloaded ? 1 : 0) - (aDownloaded ? 1 : 0); +export function TrackList({ tracks, searchQuery, sortBy, selectedTracks, downloadedTracks, failedTracks, skippedTracks, downloadingTrack, isDownloading, currentPage, itemsPerPage, showCheckboxes = false, hideAlbumColumn = false, folderName, isArtistDiscography = false, downloadedLyrics, failedLyrics, skippedLyrics, downloadingLyricsTrack, checkingAvailabilityTrack, availabilityMap, downloadedCovers, failedCovers, skippedCovers, downloadingCoverTrack, onToggleTrack, onToggleSelectAll, onDownloadTrack, onDownloadLyrics, onCheckAvailability, onDownloadCover, onPageChange, onAlbumClick, onArtistClick, onTrackClick, }: TrackListProps) { + let filteredTracks = tracks.filter((track) => { + if (!searchQuery) + return true; + const query = searchQuery.toLowerCase(); + return (track.name.toLowerCase().includes(query) || + track.artists.toLowerCase().includes(query) || + track.album_name.toLowerCase().includes(query)); }); - } else if (sortBy === "not-downloaded") { - filteredTracks = [...filteredTracks].sort((a, b) => { - const aDownloaded = downloadedTracks.has(a.isrc); - const bDownloaded = downloadedTracks.has(b.isrc); - return (aDownloaded ? 1 : 0) - (bDownloaded ? 1 : 0); - }); - } - - const totalPages = Math.ceil(filteredTracks.length / itemsPerPage); - const startIndex = (currentPage - 1) * itemsPerPage; - const endIndex = startIndex + itemsPerPage; - const paginatedTracks = filteredTracks.slice(startIndex, endIndex); - - const tracksWithIsrc = filteredTracks.filter((track) => track.isrc); - const allSelected = - tracksWithIsrc.length > 0 && - tracksWithIsrc.every((track) => selectedTracks.includes(track.isrc)); - - const formatDuration = (ms: number) => { - const minutes = Math.floor(ms / 60000); - const seconds = Math.floor((ms % 60000) / 1000); - return `${minutes}:${seconds.toString().padStart(2, "0")}`; - }; - - return ( -
+ if (sortBy === "title-asc") { + filteredTracks = [...filteredTracks].sort((a, b) => a.name.localeCompare(b.name)); + } + else if (sortBy === "title-desc") { + filteredTracks = [...filteredTracks].sort((a, b) => b.name.localeCompare(a.name)); + } + else if (sortBy === "artist-asc") { + filteredTracks = [...filteredTracks].sort((a, b) => a.artists.localeCompare(b.artists)); + } + else if (sortBy === "artist-desc") { + filteredTracks = [...filteredTracks].sort((a, b) => b.artists.localeCompare(a.artists)); + } + else if (sortBy === "duration-asc") { + filteredTracks = [...filteredTracks].sort((a, b) => a.duration_ms - b.duration_ms); + } + else if (sortBy === "duration-desc") { + filteredTracks = [...filteredTracks].sort((a, b) => b.duration_ms - a.duration_ms); + } + else if (sortBy === "plays-asc") { + filteredTracks = [...filteredTracks].sort((a, b) => { + const aPlays = a.plays ? parseInt(a.plays, 10) : 0; + const bPlays = b.plays ? parseInt(b.plays, 10) : 0; + if (isNaN(aPlays)) + return 1; + if (isNaN(bPlays)) + return -1; + return aPlays - bPlays; + }); + } + else if (sortBy === "plays-desc") { + filteredTracks = [...filteredTracks].sort((a, b) => { + const aPlays = a.plays ? parseInt(a.plays, 10) : 0; + const bPlays = b.plays ? parseInt(b.plays, 10) : 0; + if (isNaN(aPlays)) + return 1; + if (isNaN(bPlays)) + return -1; + return bPlays - aPlays; + }); + } + else if (sortBy === "downloaded") { + filteredTracks = [...filteredTracks].sort((a, b) => { + const aDownloaded = downloadedTracks.has(a.isrc); + const bDownloaded = downloadedTracks.has(b.isrc); + return (bDownloaded ? 1 : 0) - (aDownloaded ? 1 : 0); + }); + } + else if (sortBy === "not-downloaded") { + filteredTracks = [...filteredTracks].sort((a, b) => { + const aDownloaded = downloadedTracks.has(a.isrc); + const bDownloaded = downloadedTracks.has(b.isrc); + return (aDownloaded ? 1 : 0) - (bDownloaded ? 1 : 0); + }); + } + const totalPages = Math.ceil(filteredTracks.length / itemsPerPage); + const startIndex = (currentPage - 1) * itemsPerPage; + const endIndex = startIndex + itemsPerPage; + const paginatedTracks = filteredTracks.slice(startIndex, endIndex); + const tracksWithIsrc = filteredTracks.filter((track) => track.isrc); + const allSelected = tracksWithIsrc.length > 0 && + tracksWithIsrc.every((track) => selectedTracks.includes(track.isrc)); + const formatDuration = (ms: number) => { + const minutes = Math.floor(ms / 60000); + const seconds = Math.floor((ms % 60000) / 1000); + return `${minutes}:${seconds.toString().padStart(2, "0")}`; + }; + const formatPlays = (plays: string | undefined) => { + if (!plays) + return ""; + const num = parseInt(plays, 10); + if (isNaN(num)) + return plays; + return num.toLocaleString(); + }; + return (
- {showCheckboxes && ( - - )} + {showCheckboxes && ()} - {!hideAlbumColumn && ( - - )} + )} + - {paginatedTracks.map((track, index) => ( - - {showCheckboxes && ( - - )} + {paginatedTracks.map((track, index) => ( + {showCheckboxes && ()} - {!hideAlbumColumn && ( - - )} + ) : (track.album_name)} + )} + - - ))} + ))}
- onToggleSelectAll(filteredTracks)} - /> - + onToggleSelectAll(filteredTracks)}/> + # Title + {!hideAlbumColumn && ( Album - Duration + Plays + Actions
- {track.isrc && ( - onToggleTrack(track.isrc)} - /> - )} -
+ {track.isrc && ( onToggleTrack(track.isrc)}/>)} + - {startIndex + index + 1} +
+ {startIndex + index + 1} + {track.status && (track.status === "UP" || track.status === "DOWN" || track.status === "NEW") && ( + {track.status === "NEW" ? "●" : track.status === "UP" ? "▲" : "▼"} + )} +
- {track.images && ( - {track.name} - )} + {track.images && ({track.name})}
- {onTrackClick ? ( - onTrackClick(track)} - > + {onTrackClick ? ( onTrackClick(track)}> {track.name} - - ) : ( - {track.name} - )} - {skippedTracks.has(track.isrc) ? ( - - ) : downloadedTracks.has(track.isrc) ? ( - - ) : failedTracks.has(track.isrc) ? ( - - ) : null} + ) : ({track.name})} + {skippedTracks.has(track.isrc) ? () : downloadedTracks.has(track.isrc) ? () : failedTracks.has(track.isrc) ? () : null}
- {track.artists_data && track.artists_data.length > 0 ? ( - track.artists_data.map((artist, i, arr) => ( - - {onArtistClick ? ( - - onArtistClick({ - id: artist.id, - name: artist.name, - external_urls: artist.external_urls, - }) - } - > - {artist.name} - - ) : ( - artist.name - )} - {i < arr.length - 1 && ", "} - - )) - ) : onArtistClick && track.artist_id && track.artist_url ? ( - - onArtistClick({ - id: track.artist_id!, - name: track.artists, - external_urls: track.artist_url!, - }) - } - > + {track.artists_data && track.artists_data.length > 0 ? ((() => { + const artistNames = track.artists.split(", ").map(name => name.trim()); + return artistNames.map((name, i) => { + const artistData = track.artists_data![i]; + const hasArtistData = artistData && artistData.id && artistData.external_urls; + return ( + {onArtistClick && hasArtistData ? ( onArtistClick({ + id: artistData.id, + name: name, + external_urls: artistData.external_urls, + })}> + {name} + ) : (name)} + {i < artistNames.length - 1 && ", "} + ); + }); + })()) : onArtistClick && track.artist_id && track.artist_url ? ( onArtistClick({ + id: track.artist_id!, + name: track.artists, + external_urls: track.artist_url!, + })}> {track.artists} - - ) : ( - track.artists - )} + ) : (track.artists)}
- {onAlbumClick && track.album_id && track.album_url ? ( - - onAlbumClick({ - id: track.album_id!, - name: track.album_name, - external_urls: track.album_url!, - }) - } - > + {!hideAlbumColumn && ( + {onAlbumClick && track.album_id && track.album_url ? ( onAlbumClick({ + id: track.album_id!, + name: track.album_name, + external_urls: track.album_url!, + })}> {track.album_name} - - ) : ( - track.album_name - )} - {formatDuration(track.duration_ms)} + {track.plays ? formatPlays(track.plays) : ""} +
- {track.isrc && ( - + {track.isrc && ( - - {downloadingTrack === track.isrc ? ( -

Downloading...

- ) : skippedTracks.has(track.isrc) ? ( -

Already exists

- ) : downloadedTracks.has(track.isrc) ? ( -

Downloaded

- ) : failedTracks.has(track.isrc) ? ( -

Failed

- ) : ( -

Download Track

- )} + {downloadingTrack === track.isrc ? (

Downloading...

) : skippedTracks.has(track.isrc) ? (

Already exists

) : downloadedTracks.has(track.isrc) ? (

Downloaded

) : failedTracks.has(track.isrc) ? (

Failed

) : (

Download Track

)}
-
- )} - {track.spotify_id && onDownloadLyrics && ( - + )} + {track.spotify_id && onDownloadLyrics && ( -

Download Lyric

-
- )} - {track.images && onDownloadCover && ( - + )} + {track.images && onDownloadCover && ( -

Download Cover

-
- )} - {track.spotify_id && onCheckAvailability && ( - + )} + {track.spotify_id && onCheckAvailability && ( - - {availabilityMap?.has(track.spotify_id) ? ( -
- - - -
- ) : ( -

Check Availability

- )} + {availabilityMap?.has(track.spotify_id) ? (
+ + + +
) : (

Check Availability

)}
-
- )} +
)}
- {totalPages > 1 && ( - + {totalPages > 1 && ( - { - e.preventDefault(); - if (currentPage > 1) onPageChange(currentPage - 1); - }} - className={ - currentPage === 1 ? "pointer-events-none opacity-50" : "cursor-pointer" - } - /> + { + e.preventDefault(); + if (currentPage > 1) + onPageChange(currentPage - 1); + }} className={currentPage === 1 ? "pointer-events-none opacity-50" : "cursor-pointer"}/> - {Array.from({ length: totalPages }, (_, i) => i + 1).map((page) => ( - - { + {Array.from({ length: totalPages }, (_, i) => i + 1).map((page) => ( + { e.preventDefault(); onPageChange(page); - }} - isActive={currentPage === page} - className="cursor-pointer" - > + }} isActive={currentPage === page} className="cursor-pointer"> {page} - - ))} + ))} - { - e.preventDefault(); - if (currentPage < totalPages) onPageChange(currentPage + 1); - }} - className={ - currentPage === totalPages - ? "pointer-events-none opacity-50" - : "cursor-pointer" - } - /> + { + e.preventDefault(); + if (currentPage < totalPages) + onPageChange(currentPage + 1); + }} className={currentPage === totalPages + ? "pointer-events-none opacity-50" + : "cursor-pointer"}/> - - )} -
- ); + )} +
); } diff --git a/frontend/src/components/ui/activity.tsx b/frontend/src/components/ui/activity.tsx index 40f8443..1cde669 100644 --- a/frontend/src/components/ui/activity.tsx +++ b/frontend/src/components/ui/activity.tsx @@ -1,104 +1,63 @@ '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; + startAnimation: () => void; + stopAnimation: () => void; } - interface ActivityIconProps extends HTMLAttributes { - size?: number; + 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', + 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( - ({ onMouseEnter, onMouseLeave, className, size = 28, ...props }, ref) => { +const ActivityIcon = forwardRef(({ 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'), - }; + isControlledRef.current = true; + return { + startAnimation: () => controls.start('animate'), + stopAnimation: () => controls.start('normal'), + }; }); - - const handleMouseEnter = useCallback( - (e: React.MouseEvent) => { + const handleMouseEnter = useCallback((e: React.MouseEvent) => { if (!isControlledRef.current) { - controls.start('animate'); - } else { - onMouseEnter?.(e); + controls.start('animate'); } - }, - [controls, onMouseEnter] - ); - - const handleMouseLeave = useCallback( - (e: React.MouseEvent) => { + else { + onMouseEnter?.(e); + } + }, [controls, onMouseEnter]); + const handleMouseLeave = useCallback((e: React.MouseEvent) => { if (!isControlledRef.current) { - controls.start('normal'); - } else { - onMouseLeave?.(e); + controls.start('normal'); } - }, - [controls, onMouseLeave] - ); - - return ( -
- - + else { + onMouseLeave?.(e); + } + }, [controls, onMouseLeave]); + return (
+ + -
- ); - } -); - +
); +}); ActivityIcon.displayName = 'ActivityIcon'; - export { ActivityIcon }; diff --git a/frontend/src/components/ui/badge.tsx b/frontend/src/components/ui/badge.tsx index fd3a406..569cbdd 100644 --- a/frontend/src/components/ui/badge.tsx +++ b/frontend/src/components/ui/badge.tsx @@ -1,46 +1,24 @@ -import * as React from "react" -import { Slot } from "@radix-ui/react-slot" -import { cva, type VariantProps } from "class-variance-authority" - -import { cn } from "@/lib/utils" - -const badgeVariants = cva( - "inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden", - { +import * as React from "react"; +import { Slot } from "@radix-ui/react-slot"; +import { cva, type VariantProps } from "class-variance-authority"; +import { cn } from "@/lib/utils"; +const badgeVariants = cva("inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden", { variants: { - variant: { - default: - "border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90", - secondary: - "border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90", - destructive: - "border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", - outline: - "text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground", - }, + variant: { + default: "border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90", + secondary: "border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90", + destructive: "border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", + outline: "text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground", + }, }, defaultVariants: { - variant: "default", + variant: "default", }, - } -) - -function Badge({ - className, - variant, - asChild = false, - ...props -}: React.ComponentProps<"span"> & - VariantProps & { asChild?: boolean }) { - const Comp = asChild ? Slot : "span" - - return ( - - ) +}); +function Badge({ className, variant, asChild = false, ...props }: React.ComponentProps<"span"> & VariantProps & { + asChild?: boolean; +}) { + const Comp = asChild ? Slot : "span"; + return (); } - -export { Badge, badgeVariants } +export { Badge, badgeVariants }; diff --git a/frontend/src/components/ui/blocks.tsx b/frontend/src/components/ui/blocks.tsx index 95d6ecb..a88281b 100644 --- a/frontend/src/components/ui/blocks.tsx +++ b/frontend/src/components/ui/blocks.tsx @@ -1,92 +1,52 @@ '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; + startAnimation: () => void; + stopAnimation: () => void; } - interface BlocksIconProps extends HTMLAttributes { - size?: number; + size?: number; } - const VARIANTS: Variants = { - normal: { translateX: 0, translateY: 0 }, - animate: { translateX: -4, translateY: 4 }, + normal: { translateX: 0, translateY: 0 }, + animate: { translateX: -4, translateY: 4 }, }; - -const BlocksIcon = forwardRef( - ({ onMouseEnter, onMouseLeave, className, size = 28, ...props }, ref) => { +const BlocksIcon = forwardRef(({ 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'), - }; + isControlledRef.current = true; + return { + startAnimation: () => controls.start('animate'), + stopAnimation: () => controls.start('normal'), + }; }); - - const handleMouseEnter = useCallback( - (e: React.MouseEvent) => { + const handleMouseEnter = useCallback((e: React.MouseEvent) => { if (!isControlledRef.current) { - controls.start('animate'); - } else { - onMouseEnter?.(e); + controls.start('animate'); } - }, - [controls, onMouseEnter] - ); - - const handleMouseLeave = useCallback( - (e: React.MouseEvent) => { + else { + onMouseEnter?.(e); + } + }, [controls, onMouseEnter]); + const handleMouseLeave = useCallback((e: React.MouseEvent) => { if (!isControlledRef.current) { - controls.start('normal'); - } else { - onMouseLeave?.(e); + controls.start('normal'); } - }, - [controls, onMouseLeave] - ); - - return ( -
- - - + else { + onMouseLeave?.(e); + } + }, [controls, onMouseLeave]); + return (
+ + + -
- ); - } -); - +
); +}); BlocksIcon.displayName = 'BlocksIcon'; - export { BlocksIcon }; diff --git a/frontend/src/components/ui/button.tsx b/frontend/src/components/ui/button.tsx index 4b7a125..03ad68a 100644 --- a/frontend/src/components/ui/button.tsx +++ b/frontend/src/components/ui/button.tsx @@ -1,60 +1,35 @@ -import * as React from "react" -import { Slot } from "@radix-ui/react-slot" -import { cva, type VariantProps } from "class-variance-authority" - -import { cn } from "@/lib/utils" - -const buttonVariants = cva( - "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all cursor-pointer disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", - { +import * as React from "react"; +import { Slot } from "@radix-ui/react-slot"; +import { cva, type VariantProps } from "class-variance-authority"; +import { cn } from "@/lib/utils"; +const buttonVariants = cva("inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all cursor-pointer disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", { variants: { - variant: { - default: "bg-primary text-primary-foreground hover:bg-primary/90", - destructive: - "bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", - outline: - "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50", - secondary: - "bg-secondary text-secondary-foreground hover:bg-secondary/80", - ghost: - "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50", - link: "text-primary underline-offset-4 hover:underline", - }, - size: { - default: "h-9 px-4 py-2 has-[>svg]:px-3", - sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5", - lg: "h-10 rounded-md px-6 has-[>svg]:px-4", - icon: "size-9", - "icon-sm": "size-8", - "icon-lg": "size-10", - }, + variant: { + default: "bg-primary text-primary-foreground hover:bg-primary/90", + destructive: "bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", + outline: "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50", + secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80", + ghost: "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50", + link: "text-primary underline-offset-4 hover:underline", + }, + size: { + default: "h-9 px-4 py-2 has-[>svg]:px-3", + sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5", + lg: "h-10 rounded-md px-6 has-[>svg]:px-4", + icon: "size-9", + "icon-sm": "size-8", + "icon-lg": "size-10", + }, }, defaultVariants: { - variant: "default", - size: "default", + variant: "default", + size: "default", }, - } -) - -function Button({ - className, - variant, - size, - asChild = false, - ...props -}: React.ComponentProps<"button"> & - VariantProps & { - asChild?: boolean - }) { - const Comp = asChild ? Slot : "button" - - return ( - - ) +}); +function Button({ className, variant, size, asChild = false, ...props }: React.ComponentProps<"button"> & VariantProps & { + asChild?: boolean; +}) { + const Comp = asChild ? Slot : "button"; + return (); } - -export { Button, buttonVariants } +export { Button, buttonVariants }; diff --git a/frontend/src/components/ui/card.tsx b/frontend/src/components/ui/card.tsx index 681ad98..9beb361 100644 --- a/frontend/src/components/ui/card.tsx +++ b/frontend/src/components/ui/card.tsx @@ -1,92 +1,24 @@ -import * as React from "react" - -import { cn } from "@/lib/utils" - +import * as React from "react"; +import { cn } from "@/lib/utils"; function Card({ className, ...props }: React.ComponentProps<"div">) { - return ( -
- ) + return (
); } - function CardHeader({ className, ...props }: React.ComponentProps<"div">) { - return ( -
- ) + return (
); } - function CardTitle({ className, ...props }: React.ComponentProps<"div">) { - return ( -
- ) + return (
); } - function CardDescription({ className, ...props }: React.ComponentProps<"div">) { - return ( -
- ) + return (
); } - function CardAction({ className, ...props }: React.ComponentProps<"div">) { - return ( -
- ) + return (
); } - function CardContent({ className, ...props }: React.ComponentProps<"div">) { - return ( -
- ) + return (
); } - function CardFooter({ className, ...props }: React.ComponentProps<"div">) { - return ( -
- ) -} - -export { - Card, - CardHeader, - CardFooter, - CardTitle, - CardAction, - CardDescription, - CardContent, + return (
); } +export { Card, CardHeader, CardFooter, CardTitle, CardAction, CardDescription, CardContent, }; diff --git a/frontend/src/components/ui/checkbox.tsx b/frontend/src/components/ui/checkbox.tsx index cb0b07b..4da2c71 100644 --- a/frontend/src/components/ui/checkbox.tsx +++ b/frontend/src/components/ui/checkbox.tsx @@ -1,32 +1,13 @@ -"use client" - -import * as React from "react" -import * as CheckboxPrimitive from "@radix-ui/react-checkbox" -import { CheckIcon } from "lucide-react" - -import { cn } from "@/lib/utils" - -function Checkbox({ - className, - ...props -}: React.ComponentProps) { - return ( - - - +"use client"; +import * as React from "react"; +import * as CheckboxPrimitive from "@radix-ui/react-checkbox"; +import { CheckIcon } from "lucide-react"; +import { cn } from "@/lib/utils"; +function Checkbox({ className, ...props }: React.ComponentProps) { + return ( + + - - ) + ); } - -export { Checkbox } +export { Checkbox }; diff --git a/frontend/src/components/ui/coffee.tsx b/frontend/src/components/ui/coffee.tsx index cc1ecc3..a1432d5 100644 --- a/frontend/src/components/ui/coffee.tsx +++ b/frontend/src/components/ui/coffee.tsx @@ -1,118 +1,66 @@ '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; + startAnimation: () => void; + stopAnimation: () => void; } - interface CoffeeIconProps extends HTMLAttributes { - size?: number; + 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, + 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( - ({ onMouseEnter, onMouseLeave, className, size = 28, ...props }, ref) => { +const CoffeeIcon = forwardRef(({ 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'), - }; + isControlledRef.current = true; + return { + startAnimation: () => controls.start('animate'), + stopAnimation: () => controls.start('normal'), + }; }); - - const handleMouseEnter = useCallback( - (e: React.MouseEvent) => { + const handleMouseEnter = useCallback((e: React.MouseEvent) => { if (!isControlledRef.current) { - controls.start('animate'); - } else { - onMouseEnter?.(e); + controls.start('animate'); } - }, - [controls, onMouseEnter] - ); - - const handleMouseLeave = useCallback( - (e: React.MouseEvent) => { + else { + onMouseEnter?.(e); + } + }, [controls, onMouseEnter]); + const handleMouseLeave = useCallback((e: React.MouseEvent) => { if (!isControlledRef.current) { - controls.start('normal'); - } else { - onMouseLeave?.(e); + controls.start('normal'); } - }, - [controls, onMouseLeave] - ); - - return ( -
- - - - - + else { + onMouseLeave?.(e); + } + }, [controls, onMouseLeave]); + return (
+ + + + + -
- ); - } -); - +
); +}); CoffeeIcon.displayName = 'CoffeeIcon'; - export { CoffeeIcon }; diff --git a/frontend/src/components/ui/context-menu.tsx b/frontend/src/components/ui/context-menu.tsx index 8c4234d..5a334e3 100644 --- a/frontend/src/components/ui/context-menu.tsx +++ b/frontend/src/components/ui/context-menu.tsx @@ -1,252 +1,77 @@ -"use client" - -import * as React from "react" -import * as ContextMenuPrimitive from "@radix-ui/react-context-menu" -import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react" - -import { cn } from "@/lib/utils" - -function ContextMenu({ - ...props -}: React.ComponentProps) { - return +"use client"; +import * as React from "react"; +import * as ContextMenuPrimitive from "@radix-ui/react-context-menu"; +import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"; +import { cn } from "@/lib/utils"; +function ContextMenu({ ...props }: React.ComponentProps) { + return ; } - -function ContextMenuTrigger({ - ...props -}: React.ComponentProps) { - return ( - - ) +function ContextMenuTrigger({ ...props }: React.ComponentProps) { + return (); } - -function ContextMenuGroup({ - ...props -}: React.ComponentProps) { - return ( - - ) +function ContextMenuGroup({ ...props }: React.ComponentProps) { + return (); } - -function ContextMenuPortal({ - ...props -}: React.ComponentProps) { - return ( - - ) +function ContextMenuPortal({ ...props }: React.ComponentProps) { + return (); } - -function ContextMenuSub({ - ...props -}: React.ComponentProps) { - return +function ContextMenuSub({ ...props }: React.ComponentProps) { + return ; } - -function ContextMenuRadioGroup({ - ...props -}: React.ComponentProps) { - return ( - - ) +function ContextMenuRadioGroup({ ...props }: React.ComponentProps) { + return (); } - -function ContextMenuSubTrigger({ - className, - inset, - children, - ...props -}: React.ComponentProps & { - inset?: boolean +function ContextMenuSubTrigger({ className, inset, children, ...props }: React.ComponentProps & { + inset?: boolean; }) { - return ( - + return ( {children} - - - ) + + ); } - -function ContextMenuSubContent({ - className, - ...props -}: React.ComponentProps) { - return ( - - ) +function ContextMenuSubContent({ className, ...props }: React.ComponentProps) { + return (); } - -function ContextMenuContent({ - className, - ...props -}: React.ComponentProps) { - return ( - - - - ) +function ContextMenuContent({ className, ...props }: React.ComponentProps) { + return ( + + ); } - -function ContextMenuItem({ - className, - inset, - variant = "default", - ...props -}: React.ComponentProps & { - inset?: boolean - variant?: "default" | "destructive" +function ContextMenuItem({ className, inset, variant = "default", ...props }: React.ComponentProps & { + inset?: boolean; + variant?: "default" | "destructive"; }) { - return ( - - ) + return (); } - -function ContextMenuCheckboxItem({ - className, - children, - checked, - ...props -}: React.ComponentProps) { - return ( - +function ContextMenuCheckboxItem({ className, children, checked, ...props }: React.ComponentProps) { + return ( - + {children} - - ) + ); } - -function ContextMenuRadioItem({ - className, - children, - ...props -}: React.ComponentProps) { - return ( - +function ContextMenuRadioItem({ className, children, ...props }: React.ComponentProps) { + return ( - + {children} - - ) + ); } - -function ContextMenuLabel({ - className, - inset, - ...props -}: React.ComponentProps & { - inset?: boolean +function ContextMenuLabel({ className, inset, ...props }: React.ComponentProps & { + inset?: boolean; }) { - return ( - - ) + return (); } - -function ContextMenuSeparator({ - className, - ...props -}: React.ComponentProps) { - return ( - - ) +function ContextMenuSeparator({ className, ...props }: React.ComponentProps) { + return (); } - -function ContextMenuShortcut({ - className, - ...props -}: React.ComponentProps<"span">) { - return ( - - ) -} - -export { - ContextMenu, - ContextMenuTrigger, - ContextMenuContent, - ContextMenuItem, - ContextMenuCheckboxItem, - ContextMenuRadioItem, - ContextMenuLabel, - ContextMenuSeparator, - ContextMenuShortcut, - ContextMenuGroup, - ContextMenuPortal, - ContextMenuSub, - ContextMenuSubContent, - ContextMenuSubTrigger, - ContextMenuRadioGroup, +function ContextMenuShortcut({ className, ...props }: React.ComponentProps<"span">) { + return (); } +export { ContextMenu, ContextMenuTrigger, ContextMenuContent, ContextMenuItem, ContextMenuCheckboxItem, ContextMenuRadioItem, ContextMenuLabel, ContextMenuSeparator, ContextMenuShortcut, ContextMenuGroup, ContextMenuPortal, ContextMenuSub, ContextMenuSubContent, ContextMenuSubTrigger, ContextMenuRadioGroup, }; diff --git a/frontend/src/components/ui/dialog.tsx b/frontend/src/components/ui/dialog.tsx index 16fa9b9..8c5ef4f 100644 --- a/frontend/src/components/ui/dialog.tsx +++ b/frontend/src/components/ui/dialog.tsx @@ -1,143 +1,47 @@ -"use client" - -import * as React from "react" -import * as DialogPrimitive from "@radix-ui/react-dialog" -import { XIcon } from "lucide-react" - -import { cn } from "@/lib/utils" - -function Dialog({ - ...props -}: React.ComponentProps) { - return +"use client"; +import * as React from "react"; +import * as DialogPrimitive from "@radix-ui/react-dialog"; +import { XIcon } from "lucide-react"; +import { cn } from "@/lib/utils"; +function Dialog({ ...props }: React.ComponentProps) { + return ; } - -function DialogTrigger({ - ...props -}: React.ComponentProps) { - return +function DialogTrigger({ ...props }: React.ComponentProps) { + return ; } - -function DialogPortal({ - ...props -}: React.ComponentProps) { - return +function DialogPortal({ ...props }: React.ComponentProps) { + return ; } - -function DialogClose({ - ...props -}: React.ComponentProps) { - return +function DialogClose({ ...props }: React.ComponentProps) { + return ; } - -function DialogOverlay({ - className, - ...props -}: React.ComponentProps) { - return ( - - ) +function DialogOverlay({ className, ...props }: React.ComponentProps) { + return (); } - -function DialogContent({ - className, - children, - showCloseButton = true, - ...props -}: React.ComponentProps & { - showCloseButton?: boolean +function DialogContent({ className, children, showCloseButton = true, ...props }: React.ComponentProps & { + showCloseButton?: boolean; }) { - return ( - + return ( - + {children} - {showCloseButton && ( - + {showCloseButton && ( Close - - )} + )} - - ) + ); } - function DialogHeader({ className, ...props }: React.ComponentProps<"div">) { - return ( -
- ) + return (
); } - function DialogFooter({ className, ...props }: React.ComponentProps<"div">) { - return ( -
- ) + return (
); } - -function DialogTitle({ - className, - ...props -}: React.ComponentProps) { - return ( - - ) +function DialogTitle({ className, ...props }: React.ComponentProps) { + return (); } - -function DialogDescription({ - className, - ...props -}: React.ComponentProps) { - return ( - - ) -} - -export { - Dialog, - DialogClose, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogOverlay, - DialogPortal, - DialogTitle, - DialogTrigger, +function DialogDescription({ className, ...props }: React.ComponentProps) { + return (); } +export { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogOverlay, DialogPortal, DialogTitle, DialogTrigger, }; diff --git a/frontend/src/components/ui/file-music.tsx b/frontend/src/components/ui/file-music.tsx new file mode 100644 index 0000000..64b2e40 --- /dev/null +++ b/frontend/src/components/ui/file-music.tsx @@ -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 FileMusicIconHandle { + startAnimation: () => void; + stopAnimation: () => void; +} + +interface FileMusicIconProps extends HTMLAttributes { + size?: number; +} + +const PATH_VARIANTS: Variants = { + normal: { + pathLength: 1, + opacity: 1, + }, + animate: { + pathLength: [0, 1], + opacity: [0, 1], + transition: { + duration: 0.6, + ease: 'easeInOut', + }, + }, +}; + +const FileMusicIcon = forwardRef( + ({ 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) => { + if (!isControlledRef.current) { + controls.start('animate'); + } else { + onMouseEnter?.(e); + } + }, + [controls, onMouseEnter] + ); + + const handleMouseLeave = useCallback( + (e: React.MouseEvent) => { + if (!isControlledRef.current) { + controls.start('normal'); + } else { + onMouseLeave?.(e); + } + }, + [controls, onMouseLeave] + ); + + return ( +
+ + + + + + +
+ ); + } +); + +FileMusicIcon.displayName = 'FileMusicIcon'; +export { FileMusicIcon }; diff --git a/frontend/src/components/ui/file-pen.tsx b/frontend/src/components/ui/file-pen.tsx new file mode 100644 index 0000000..7fae9f7 --- /dev/null +++ b/frontend/src/components/ui/file-pen.tsx @@ -0,0 +1,110 @@ +'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 FilePenIconHandle { + startAnimation: () => void; + stopAnimation: () => void; +} + +interface FilePenIconProps extends HTMLAttributes { + size?: number; +} + +const PATH_VARIANTS: Variants = { + normal: { + pathLength: 1, + opacity: 1, + }, + animate: { + pathLength: [0, 1], + opacity: [0, 1], + transition: { + duration: 0.6, + ease: 'easeInOut', + }, + }, +}; + +const FilePenIcon = forwardRef( + ({ 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) => { + if (!isControlledRef.current) { + controls.start('animate'); + } else { + onMouseEnter?.(e); + } + }, + [controls, onMouseEnter] + ); + + const handleMouseLeave = useCallback( + (e: React.MouseEvent) => { + if (!isControlledRef.current) { + controls.start('normal'); + } else { + onMouseLeave?.(e); + } + }, + [controls, onMouseLeave] + ); + + return ( +
+ + + + + +
+ ); + } +); + +FilePenIcon.displayName = 'FilePenIcon'; +export { FilePenIcon }; diff --git a/frontend/src/components/ui/github.tsx b/frontend/src/components/ui/github.tsx index 6936492..8eda9c9 100644 --- a/frontend/src/components/ui/github.tsx +++ b/frontend/src/components/ui/github.tsx @@ -1,149 +1,102 @@ '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; + startAnimation: () => void; + stopAnimation: () => void; } - interface GithubIconProps extends HTMLAttributes { - size?: number; + size?: number; } - const BODY_VARIANTS: Variants = { - normal: { - opacity: 1, - pathLength: 1, - scale: 1, - transition: { - duration: 0.3, + 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, + 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, + normal: { + pathLength: 1, + rotate: 0, + transition: { + duration: 0.3, + }, }, - }, - draw: { - pathLength: [0, 1], - rotate: 0, - transition: { - duration: 0.5, + 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, + wag: { + pathLength: 1, + rotate: [0, -15, 15, -10, 10, -5, 5], + transition: { + duration: 2.5, + ease: 'easeInOut', + repeat: Infinity, + }, }, - }, }; - -const GithubIcon = forwardRef( - ({ onMouseEnter, onMouseLeave, className, size = 28, ...props }, ref) => { +const GithubIcon = forwardRef(({ 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'); - }, - }; + 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) => { + const handleMouseEnter = useCallback(async (e: React.MouseEvent) => { if (!isControlledRef.current) { - bodyControls.start('animate'); - await tailControls.start('draw'); - tailControls.start('wag'); - } else { - onMouseEnter?.(e); + bodyControls.start('animate'); + await tailControls.start('draw'); + tailControls.start('wag'); } - }, - [bodyControls, onMouseEnter, tailControls] - ); - - const handleMouseLeave = useCallback( - (e: React.MouseEvent) => { + else { + onMouseEnter?.(e); + } + }, [bodyControls, onMouseEnter, tailControls]); + const handleMouseLeave = useCallback((e: React.MouseEvent) => { if (!isControlledRef.current) { - bodyControls.start('normal'); - tailControls.start('normal'); - } else { - onMouseLeave?.(e); + bodyControls.start('normal'); + tailControls.start('normal'); } - }, - [bodyControls, tailControls, onMouseLeave] - ); - - return ( -
- - - + else { + onMouseLeave?.(e); + } + }, [bodyControls, tailControls, onMouseLeave]); + return (
+ + + -
- ); - } -); - +
); +}); GithubIcon.displayName = 'GithubIcon'; - export { GithubIcon }; diff --git a/frontend/src/components/ui/home.tsx b/frontend/src/components/ui/home.tsx index 203ac9b..8b6eb25 100644 --- a/frontend/src/components/ui/home.tsx +++ b/frontend/src/components/ui/home.tsx @@ -1,103 +1,62 @@ '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; + startAnimation: () => void; + stopAnimation: () => void; } - interface HomeIconProps extends HTMLAttributes { - size?: number; + size?: number; } - const DEFAULT_TRANSITION: Transition = { - duration: 0.6, - opacity: { duration: 0.2 }, + duration: 0.6, + opacity: { duration: 0.2 }, }; - const PATH_VARIANTS: Variants = { - normal: { - pathLength: 1, - opacity: 1, - }, - animate: { - opacity: [0, 1], - pathLength: [0, 1], - }, + normal: { + pathLength: 1, + opacity: 1, + }, + animate: { + opacity: [0, 1], + pathLength: [0, 1], + }, }; - -const HomeIcon = forwardRef( - ({ onMouseEnter, onMouseLeave, className, size = 28, ...props }, ref) => { +const HomeIcon = forwardRef(({ 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'), - }; + isControlledRef.current = true; + return { + startAnimation: () => controls.start('animate'), + stopAnimation: () => controls.start('normal'), + }; }); - - const handleMouseEnter = useCallback( - (e: React.MouseEvent) => { + const handleMouseEnter = useCallback((e: React.MouseEvent) => { if (!isControlledRef.current) { - controls.start('animate'); - } else { - onMouseEnter?.(e); + controls.start('animate'); } - }, - [controls, onMouseEnter] - ); - - const handleMouseLeave = useCallback( - (e: React.MouseEvent) => { + else { + onMouseEnter?.(e); + } + }, [controls, onMouseEnter]); + const handleMouseLeave = useCallback((e: React.MouseEvent) => { if (!isControlledRef.current) { - controls.start('normal'); - } else { - onMouseLeave?.(e); + controls.start('normal'); } - }, - [controls, onMouseLeave] - ); - return ( -
- - - + else { + onMouseLeave?.(e); + } + }, [controls, onMouseLeave]); + return (
+ + + -
- ); - } -); - +
); +}); HomeIcon.displayName = 'HomeIcon'; - export { HomeIcon }; diff --git a/frontend/src/components/ui/input-with-context.tsx b/frontend/src/components/ui/input-with-context.tsx index d0246b7..1c3ee0e 100644 --- a/frontend/src/components/ui/input-with-context.tsx +++ b/frontend/src/components/ui/input-with-context.tsx @@ -1,216 +1,156 @@ import * as React from "react"; import { Input } from "@/components/ui/input"; -import { - ContextMenu, - ContextMenuContent, - ContextMenuItem, - ContextMenuSeparator, - ContextMenuTrigger, -} from "@/components/ui/context-menu"; +import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuSeparator, ContextMenuTrigger, } from "@/components/ui/context-menu"; import { Scissors, Copy, Clipboard, Type } from "lucide-react"; - -export interface InputWithContextProps - extends React.InputHTMLAttributes { - onValueChange?: (value: string) => void; +export interface InputWithContextProps extends React.InputHTMLAttributes { + onValueChange?: (value: string) => void; } - -const InputWithContext = React.forwardRef( - ({ className, type, onValueChange, onChange, ...props }, ref) => { +const InputWithContext = React.forwardRef(({ className, type, onValueChange, onChange, ...props }, ref) => { const inputRef = React.useRef(null); const [hasSelection, setHasSelection] = React.useState(false); const [canPaste, setCanPaste] = React.useState(false); - - // Combine refs React.useImperativeHandle(ref, () => inputRef.current as HTMLInputElement); - - // Check selection state const updateSelectionState = () => { - const input = inputRef.current; - if (!input) return; - const start = input.selectionStart ?? 0; - const end = input.selectionEnd ?? 0; - setHasSelection(start !== end); - }; - - // Check clipboard permission when user explicitly opens the context menu. - const checkClipboard = async () => { - try { - const text = await navigator.clipboard.readText(); - setCanPaste(text.length > 0); - } catch { - setCanPaste(false); - } - }; - - const handleCut = async () => { - const input = inputRef.current; - if (!input) return; - - const start = input.selectionStart ?? 0; - const end = input.selectionEnd ?? 0; - const selectedText = input.value.substring(start, end); - - if (selectedText) { - try { - await navigator.clipboard.writeText(selectedText); - const newValue = input.value.substring(0, start) + input.value.substring(end); - - // Update value and trigger change - input.value = newValue; - input.setSelectionRange(start, start); - - // Trigger React onChange - if (onChange) { - const event = { - target: input, - currentTarget: input, - } as React.ChangeEvent; - onChange(event); - } - - if (onValueChange) { - onValueChange(newValue); - } - - input.focus(); - } catch (err) { - console.error("Failed to cut:", err); - } - } - }; - - const handleCopy = async () => { - const input = inputRef.current; - if (!input) return; - - const start = input.selectionStart ?? 0; - const end = input.selectionEnd ?? 0; - const selectedText = input.value.substring(start, end); - - if (selectedText) { - try { - await navigator.clipboard.writeText(selectedText); - input.focus(); - } catch (err) { - console.error("Failed to copy:", err); - } - } - }; - - const handlePaste = async () => { - const input = inputRef.current; - if (!input) return; - - try { - const text = await navigator.clipboard.readText(); + const input = inputRef.current; + if (!input) + return; const start = input.selectionStart ?? 0; const end = input.selectionEnd ?? 0; - - const newValue = - input.value.substring(0, start) + text + input.value.substring(end); - - // Update value and trigger change - input.value = newValue; - const newPosition = start + text.length; - input.setSelectionRange(newPosition, newPosition); - - // Trigger React onChange - if (onChange) { - const event = { - target: input, - currentTarget: input, - } as React.ChangeEvent; - onChange(event); - } - - if (onValueChange) { - onValueChange(newValue); - } - - input.focus(); - await checkClipboard(); - } catch (err) { - console.error("Failed to paste:", err); - } + setHasSelection(start !== end); + }; + const checkClipboard = async () => { + try { + const text = await navigator.clipboard.readText(); + setCanPaste(text.length > 0); + } + catch { + setCanPaste(false); + } + }; + const handleCut = async () => { + const input = inputRef.current; + if (!input) + return; + const start = input.selectionStart ?? 0; + const end = input.selectionEnd ?? 0; + const selectedText = input.value.substring(start, end); + if (selectedText) { + try { + await navigator.clipboard.writeText(selectedText); + const newValue = input.value.substring(0, start) + input.value.substring(end); + input.value = newValue; + input.setSelectionRange(start, start); + if (onChange) { + const event = { + target: input, + currentTarget: input, + } as React.ChangeEvent; + onChange(event); + } + if (onValueChange) { + onValueChange(newValue); + } + input.focus(); + } + catch (err) { + console.error("Failed to cut:", err); + } + } + }; + const handleCopy = async () => { + const input = inputRef.current; + if (!input) + return; + const start = input.selectionStart ?? 0; + const end = input.selectionEnd ?? 0; + const selectedText = input.value.substring(start, end); + if (selectedText) { + try { + await navigator.clipboard.writeText(selectedText); + input.focus(); + } + catch (err) { + console.error("Failed to copy:", err); + } + } + }; + const handlePaste = async () => { + const input = inputRef.current; + if (!input) + return; + try { + const text = await navigator.clipboard.readText(); + const start = input.selectionStart ?? 0; + const end = input.selectionEnd ?? 0; + const newValue = input.value.substring(0, start) + text + input.value.substring(end); + input.value = newValue; + const newPosition = start + text.length; + input.setSelectionRange(newPosition, newPosition); + if (onChange) { + const event = { + target: input, + currentTarget: input, + } as React.ChangeEvent; + onChange(event); + } + if (onValueChange) { + onValueChange(newValue); + } + input.focus(); + await checkClipboard(); + } + catch (err) { + console.error("Failed to paste:", err); + } }; - const handleSelectAll = () => { - const input = inputRef.current; - if (!input) return; - input.select(); - input.focus(); - updateSelectionState(); + const input = inputRef.current; + if (!input) + return; + input.select(); + input.focus(); + updateSelectionState(); }; - const handleInputChange = (e: React.ChangeEvent) => { - if (onChange) { - onChange(e); - } - if (onValueChange) { - onValueChange(e.target.value); - } + if (onChange) { + onChange(e); + } + if (onValueChange) { + onValueChange(e.target.value); + } }; - - return ( - { - if (open) { - checkClipboard(); - } - }} - > + return ( { + if (open) { + checkClipboard(); + } + }}> - + - - + + Cut Ctrl+X - - + + Copy Ctrl+C - - + + Paste Ctrl+V - - + + Select All Ctrl+A - - ); - } -); - + ); +}); InputWithContext.displayName = "InputWithContext"; - export { InputWithContext }; diff --git a/frontend/src/components/ui/input.tsx b/frontend/src/components/ui/input.tsx index 8916905..3a6e9b1 100644 --- a/frontend/src/components/ui/input.tsx +++ b/frontend/src/components/ui/input.tsx @@ -1,21 +1,6 @@ -import * as React from "react" - -import { cn } from "@/lib/utils" - +import * as React from "react"; +import { cn } from "@/lib/utils"; function Input({ className, type, ...props }: React.ComponentProps<"input">) { - return ( - - ) + return (); } - -export { Input } +export { Input }; diff --git a/frontend/src/components/ui/label.tsx b/frontend/src/components/ui/label.tsx index fb5fbc3..e4ef7b3 100644 --- a/frontend/src/components/ui/label.tsx +++ b/frontend/src/components/ui/label.tsx @@ -1,24 +1,8 @@ -"use client" - -import * as React from "react" -import * as LabelPrimitive from "@radix-ui/react-label" - -import { cn } from "@/lib/utils" - -function Label({ - className, - ...props -}: React.ComponentProps) { - return ( - - ) +"use client"; +import * as React from "react"; +import * as LabelPrimitive from "@radix-ui/react-label"; +import { cn } from "@/lib/utils"; +function Label({ className, ...props }: React.ComponentProps) { + return (); } - -export { Label } +export { Label }; diff --git a/frontend/src/components/ui/pagination.tsx b/frontend/src/components/ui/pagination.tsx index 0d18541..0ac29c8 100644 --- a/frontend/src/components/ui/pagination.tsx +++ b/frontend/src/components/ui/pagination.tsx @@ -1,127 +1,41 @@ -import * as React from "react" -import { - ChevronLeftIcon, - ChevronRightIcon, - MoreHorizontalIcon, -} from "lucide-react" - -import { cn } from "@/lib/utils" -import { Button, buttonVariants } from "@/components/ui/button" - +import * as React from "react"; +import { ChevronLeftIcon, ChevronRightIcon, MoreHorizontalIcon, } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { Button, buttonVariants } from "@/components/ui/button"; function Pagination({ className, ...props }: React.ComponentProps<"nav">) { - return ( -
); +}); SettingsIcon.displayName = 'SettingsIcon'; - export { SettingsIcon }; diff --git a/frontend/src/components/ui/sonner.tsx b/frontend/src/components/ui/sonner.tsx index 732f077..dac4336 100644 --- a/frontend/src/components/ui/sonner.tsx +++ b/frontend/src/components/ui/sonner.tsx @@ -1,47 +1,27 @@ -import { - CircleCheckIcon, - InfoIcon, - Loader2Icon, - OctagonXIcon, - TriangleAlertIcon, -} from "lucide-react" -import { useTheme } from "next-themes" -import { Toaster as Sonner, type ToasterProps } from "sonner" - +import { CircleCheckIcon, InfoIcon, Loader2Icon, OctagonXIcon, TriangleAlertIcon, } from "lucide-react"; +import { useTheme } from "next-themes"; +import { Toaster as Sonner, type ToasterProps } from "sonner"; const Toaster = ({ ...props }: ToasterProps) => { - const { theme = "system" } = useTheme() - - return ( - , - info: , - warning: , - error: , - loading: , - }} - toastOptions={{ - classNames: { - success: "border-green-500 bg-green-50 text-green-900 dark:bg-green-950 dark:text-green-100 [&>svg]:text-green-500", - error: "border-red-500 bg-red-50 text-red-900 dark:bg-red-950 dark:text-red-100 [&>svg]:text-red-500", - warning: "border-yellow-500 bg-yellow-50 text-yellow-900 dark:bg-yellow-950 dark:text-yellow-100 [&>svg]:text-yellow-500", - info: "border-blue-500 bg-blue-50 text-blue-900 dark:bg-blue-950 dark:text-blue-100 [&>svg]:text-blue-500", - }, - }} - style={ - { - "--normal-bg": "var(--popover)", - "--normal-text": "var(--popover-foreground)", - "--normal-border": "var(--border)", - "--border-radius": "var(--radius)", - left: "calc(56px + 1rem)", - } as React.CSSProperties - } - {...props} - /> - ) -} - -export { Toaster } + const { theme = "system" } = useTheme(); + return (, + info: , + warning: , + error: , + loading: , + }} toastOptions={{ + classNames: { + success: "border-green-500 bg-green-50 text-green-900 dark:bg-green-950 dark:text-green-100 [&>svg]:text-green-500", + error: "border-red-500 bg-red-50 text-red-900 dark:bg-red-950 dark:text-red-100 [&>svg]:text-red-500", + warning: "border-yellow-500 bg-yellow-50 text-yellow-900 dark:bg-yellow-950 dark:text-yellow-100 [&>svg]:text-yellow-500", + info: "border-blue-500 bg-blue-50 text-blue-900 dark:bg-blue-950 dark:text-blue-100 [&>svg]:text-blue-500", + }, + }} style={{ + "--normal-bg": "var(--popover)", + "--normal-text": "var(--popover-foreground)", + "--normal-border": "var(--border)", + "--border-radius": "var(--radius)", + left: "calc(56px + 1rem)", + } as React.CSSProperties} {...props}/>); +}; +export { Toaster }; diff --git a/frontend/src/components/ui/spinner.tsx b/frontend/src/components/ui/spinner.tsx index b0e62a5..333de6b 100644 --- a/frontend/src/components/ui/spinner.tsx +++ b/frontend/src/components/ui/spinner.tsx @@ -1,15 +1,6 @@ -import { Loader2 } from "lucide-react" -import { cn } from "@/lib/utils" - +import { Loader2 } from "lucide-react"; +import { cn } from "@/lib/utils"; function Spinner({ className, ...props }: React.ComponentProps<"svg">) { - return ( - - ) + return (); } - -export { Spinner } +export { Spinner }; diff --git a/frontend/src/components/ui/switch.tsx b/frontend/src/components/ui/switch.tsx index 8ac5f16..191b7dd 100644 --- a/frontend/src/components/ui/switch.tsx +++ b/frontend/src/components/ui/switch.tsx @@ -1,31 +1,10 @@ -"use client" - -import * as React from "react" -import * as SwitchPrimitive from "@radix-ui/react-switch" - -import { cn } from "@/lib/utils" - -function Switch({ - className, - ...props -}: React.ComponentProps) { - return ( - - - - ) +"use client"; +import * as React from "react"; +import * as SwitchPrimitive from "@radix-ui/react-switch"; +import { cn } from "@/lib/utils"; +function Switch({ className, ...props }: React.ComponentProps) { + return ( + + ); } - -export { Switch } +export { Switch }; diff --git a/frontend/src/components/ui/terminal.tsx b/frontend/src/components/ui/terminal.tsx index 23a0bc2..f55b59a 100644 --- a/frontend/src/components/ui/terminal.tsx +++ b/frontend/src/components/ui/terminal.tsx @@ -1,103 +1,59 @@ '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; + startAnimation: () => void; + stopAnimation: () => void; } - interface TerminalIconProps extends HTMLAttributes { - size?: number; + size?: number; } - const LINE_VARIANTS: Variants = { - normal: { opacity: 1 }, - animate: { - opacity: [1, 0, 1], - transition: { - duration: 0.8, - repeat: Infinity, - ease: 'linear', + normal: { opacity: 1 }, + animate: { + opacity: [1, 0, 1], + transition: { + duration: 0.8, + repeat: Infinity, + ease: 'linear', + }, }, - }, }; - -const TerminalIcon = forwardRef( - ({ onMouseEnter, onMouseLeave, className, size = 28, ...props }, ref) => { +const TerminalIcon = forwardRef(({ 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'), - }; + isControlledRef.current = true; + return { + startAnimation: () => controls.start('animate'), + stopAnimation: () => controls.start('normal'), + }; }); - - const handleMouseEnter = useCallback( - (e: React.MouseEvent) => { + const handleMouseEnter = useCallback((e: React.MouseEvent) => { if (!isControlledRef.current) { - controls.start('animate'); - } else { - onMouseEnter?.(e); + controls.start('animate'); } - }, - [controls, onMouseEnter] - ); - - const handleMouseLeave = useCallback( - (e: React.MouseEvent) => { + else { + onMouseEnter?.(e); + } + }, [controls, onMouseEnter]); + const handleMouseLeave = useCallback((e: React.MouseEvent) => { if (!isControlledRef.current) { - controls.start('normal'); - } else { - onMouseLeave?.(e); + controls.start('normal'); } - }, - [controls, onMouseLeave] - ); - - return ( -
- - - + else { + onMouseLeave?.(e); + } + }, [controls, onMouseLeave]); + return (
+ + + -
- ); - } -); - +
); +}); TerminalIcon.displayName = 'TerminalIcon'; - export { TerminalIcon }; diff --git a/frontend/src/components/ui/toggle-group.tsx b/frontend/src/components/ui/toggle-group.tsx index 24a4850..ae236e6 100644 --- a/frontend/src/components/ui/toggle-group.tsx +++ b/frontend/src/components/ui/toggle-group.tsx @@ -1,83 +1,32 @@ -"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 & { - spacing?: number - } ->({ - size: "default", - variant: "default", - spacing: 0, -}) - -function ToggleGroup({ - className, - variant, - size, - spacing = 0, - children, - ...props -}: React.ComponentProps & - VariantProps & { - spacing?: number - }) { - return ( - +"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 & { + spacing?: number; +}>({ + size: "default", + variant: "default", + spacing: 0, +}); +function ToggleGroup({ className, variant, size, spacing = 0, children, ...props }: React.ComponentProps & VariantProps & { + spacing?: number; +}) { + return ( {children} - - ) + ); } - -function ToggleGroupItem({ - className, - children, - variant, - size, - ...props -}: React.ComponentProps & - VariantProps) { - const context = React.useContext(ToggleGroupContext) - - return ( - +function ToggleGroupItem({ className, children, variant, size, ...props }: React.ComponentProps & VariantProps) { + const context = React.useContext(ToggleGroupContext); + return ( {children} - - ) + ); } - -export { ToggleGroup, ToggleGroupItem } +export { ToggleGroup, ToggleGroupItem }; diff --git a/frontend/src/components/ui/toggle.tsx b/frontend/src/components/ui/toggle.tsx index 94ec8f5..023d8df 100644 --- a/frontend/src/components/ui/toggle.tsx +++ b/frontend/src/components/ui/toggle.tsx @@ -1,47 +1,26 @@ -"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", - { +"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", - }, + 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", + variant: "default", + size: "default", }, - } -) - -function Toggle({ - className, - variant, - size, - ...props -}: React.ComponentProps & - VariantProps) { - return ( - - ) +}); +function Toggle({ className, variant, size, ...props }: React.ComponentProps & VariantProps) { + return (); } - -export { Toggle, toggleVariants } +export { Toggle, toggleVariants }; diff --git a/frontend/src/components/ui/tooltip.tsx b/frontend/src/components/ui/tooltip.tsx index 1449244..dd1e7b8 100644 --- a/frontend/src/components/ui/tooltip.tsx +++ b/frontend/src/components/ui/tooltip.tsx @@ -1,61 +1,24 @@ -"use client" - -import * as React from "react" -import * as TooltipPrimitive from "@radix-ui/react-tooltip" - -import { cn } from "@/lib/utils" - -function TooltipProvider({ - delayDuration = 0, - ...props -}: React.ComponentProps) { - return ( - - ) +"use client"; +import * as React from "react"; +import * as TooltipPrimitive from "@radix-ui/react-tooltip"; +import { cn } from "@/lib/utils"; +function TooltipProvider({ delayDuration = 0, ...props }: React.ComponentProps) { + return (); } - -function Tooltip({ - ...props -}: React.ComponentProps) { - return ( - - - - ) +function Tooltip({ ...props }: React.ComponentProps) { + return ( + + ); } - -function TooltipTrigger({ - ...props -}: React.ComponentProps) { - return +function TooltipTrigger({ ...props }: React.ComponentProps) { + return ; } - -function TooltipContent({ - className, - sideOffset = 0, - children, - ...props -}: React.ComponentProps) { - return ( - - +function TooltipContent({ className, sideOffset = 0, children, ...props }: React.ComponentProps) { + return ( + {children} - + - - ) + ); } - -export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } +export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }; diff --git a/frontend/src/hooks/useAudioAnalysis.ts b/frontend/src/hooks/useAudioAnalysis.ts index 44fce29..351f134 100644 --- a/frontend/src/hooks/useAudioAnalysis.ts +++ b/frontend/src/hooks/useAudioAnalysis.ts @@ -4,169 +4,144 @@ 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(() => { - // 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, - }; + const [analyzing, setAnalyzing] = useState(false); + const [result, setResult] = useState(() => { + try { + const saved = sessionStorage.getItem(STORAGE_KEY); + if (saved) { + const parsed = JSON.parse(saved); + if (parsed.filePath && parsed.result) { + return { + ...parsed.result, + spectrum: undefined, + }; + } + } } - } - } catch (err) { - console.error("Failed to load saved analysis state:", err); - } - return null; - }); - const [selectedFilePath, setSelectedFilePath] = useState(() => { - // 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(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) { + console.error("Failed to load saved analysis state:", err); } - } - } catch (err) { - // Ignore - } - return false; - }); - - const analyzeFile = useCallback(async (filePath: string) => { - if (!filePath) { - setError("No file path provided"); - return null; - } - - setAnalyzing(true); - setError(null); - setResult(null); - setSelectedFilePath(filePath); - - try { - logger.info(`Analyzing audio file: ${filePath}`); - const startTime = Date.now(); - - const response = await AnalyzeTrack(filePath); - const analysisResult: AnalysisResult = JSON.parse(response); - - const elapsed = ((Date.now() - startTime) / 1000).toFixed(2); - logger.success(`Audio analysis completed in ${elapsed}s`); - - // 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) { - const errorMessage = err instanceof Error ? err.message : "Failed to analyze audio file"; - logger.error(`Analysis error: ${errorMessage}`); - setError(errorMessage); - toast.error("Audio Analysis Failed", { - description: errorMessage, - }); - return null; - } finally { - setAnalyzing(false); - } - }, []); - - const clearResult = useCallback(() => { - setResult(null); - setError(null); - 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 null; }); - - return () => { - if (rafId) { - cancelAnimationFrame(rafId); - } + const [selectedFilePath, setSelectedFilePath] = useState(() => { + try { + const saved = sessionStorage.getItem(STORAGE_KEY); + if (saved) { + const parsed = JSON.parse(saved); + return parsed.filePath || ""; + } + } + catch (err) { + } + return ""; + }); + const [error, setError] = useState(null); + const [spectrumLoading, setSpectrumLoading] = useState(() => { + try { + const saved = sessionStorage.getItem(STORAGE_KEY); + if (saved) { + const parsed = JSON.parse(saved); + if (parsed.filePath && parsed.result) { + return true; + } + } + } + catch (err) { + } + return false; + }); + const analyzeFile = useCallback(async (filePath: string) => { + if (!filePath) { + setError("No file path provided"); + return null; + } + setAnalyzing(true); + setError(null); + setResult(null); + setSelectedFilePath(filePath); + try { + logger.info(`Analyzing audio file: ${filePath}`); + const startTime = Date.now(); + const response = await AnalyzeTrack(filePath); + const analysisResult: AnalysisResult = JSON.parse(response); + const elapsed = ((Date.now() - startTime) / 1000).toFixed(2); + logger.success(`Audio analysis completed in ${elapsed}s`); + if (analysisResult.spectrum) { + setSpectrumCache(filePath, analysisResult.spectrum); + } + 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); + return analysisResult; + } + catch (err) { + const errorMessage = err instanceof Error ? err.message : "Failed to analyze audio file"; + logger.error(`Analysis error: ${errorMessage}`); + setError(errorMessage); + toast.error("Audio Analysis Failed", { + description: errorMessage, + }); + return null; + } + finally { + setAnalyzing(false); + } + }, []); + const clearResult = useCallback(() => { + setResult(null); + setError(null); + setSelectedFilePath(""); + try { + sessionStorage.removeItem(STORAGE_KEY); + } + catch (err) { + } + clearSpectrumCache(); + }, []); + useEffect(() => { + if (!result || !selectedFilePath || result.spectrum || !spectrumLoading) { + return; + } + let rafId: number; + const loadSpectrum = () => { + rafId = requestAnimationFrame(() => { + const cachedSpectrum = getSpectrumCache(selectedFilePath); + if (cachedSpectrum) { + setResult(prev => prev ? { ...prev, spectrum: cachedSpectrum } : null); + setSpectrumLoading(false); + } + else { + setSpectrumLoading(false); + } + }); + }; + requestAnimationFrame(() => { + requestAnimationFrame(loadSpectrum); + }); + return () => { + if (rafId) { + cancelAnimationFrame(rafId); + } + }; + }, [result, selectedFilePath, spectrumLoading]); + return { + analyzing, + result, + error, + selectedFilePath, + spectrumLoading, + analyzeFile, + clearResult, }; - }, [result, selectedFilePath, spectrumLoading]); - - return { - analyzing, - result, - error, - selectedFilePath, - spectrumLoading, - analyzeFile, - clearResult, - }; } diff --git a/frontend/src/hooks/useAvailability.ts b/frontend/src/hooks/useAvailability.ts index e691468..32143f1 100644 --- a/frontend/src/hooks/useAvailability.ts +++ b/frontend/src/hooks/useAvailability.ts @@ -2,68 +2,59 @@ import { useState, useCallback } from "react"; import { CheckTrackAvailability } from "../../wailsjs/go/main/App"; import type { TrackAvailability } from "@/types/api"; import { logger } from "@/lib/logger"; - export function useAvailability() { - const [checking, setChecking] = useState(false); - const [checkingTrackId, setCheckingTrackId] = useState(null); - const [availabilityMap, setAvailabilityMap] = useState>(new Map()); - const [error, setError] = useState(null); - - const checkAvailability = useCallback(async (spotifyId: string, isrc?: string) => { - if (!spotifyId) { - setError("No Spotify ID provided"); - return null; - } - - // Check if already cached - if (availabilityMap.has(spotifyId)) { - return availabilityMap.get(spotifyId)!; - } - - setChecking(true); - setCheckingTrackId(spotifyId); - setError(null); - - try { - logger.info(`Checking availability for track: ${spotifyId}`); - const response = await CheckTrackAvailability(spotifyId, isrc || ""); - const availability: TrackAvailability = JSON.parse(response); - - setAvailabilityMap((prev) => { - const newMap = new Map(prev); - newMap.set(spotifyId, availability); - return newMap; - }); - - logger.success(`Availability check completed for ${spotifyId}`); - return availability; - } catch (err) { - const errorMessage = err instanceof Error ? err.message : "Failed to check availability"; - logger.error(`Availability check error: ${errorMessage}`); - setError(errorMessage); - return null; - } finally { - setChecking(false); - setCheckingTrackId(null); - } - }, [availabilityMap]); - - const getAvailability = useCallback((spotifyId: string) => { - return availabilityMap.get(spotifyId); - }, [availabilityMap]); - - const clearAvailability = useCallback(() => { - setAvailabilityMap(new Map()); - setError(null); - }, []); - - return { - checking, - checkingTrackId, - availabilityMap, - error, - checkAvailability, - getAvailability, - clearAvailability, - }; + const [checking, setChecking] = useState(false); + const [checkingTrackId, setCheckingTrackId] = useState(null); + const [availabilityMap, setAvailabilityMap] = useState>(new Map()); + const [error, setError] = useState(null); + const checkAvailability = useCallback(async (spotifyId: string, isrc?: string) => { + if (!spotifyId) { + setError("No Spotify ID provided"); + return null; + } + if (availabilityMap.has(spotifyId)) { + return availabilityMap.get(spotifyId)!; + } + setChecking(true); + setCheckingTrackId(spotifyId); + setError(null); + try { + logger.info(`Checking availability for track: ${spotifyId}`); + const response = await CheckTrackAvailability(spotifyId, isrc || ""); + const availability: TrackAvailability = JSON.parse(response); + setAvailabilityMap((prev) => { + const newMap = new Map(prev); + newMap.set(spotifyId, availability); + return newMap; + }); + logger.success(`Availability check completed for ${spotifyId}`); + return availability; + } + catch (err) { + const errorMessage = err instanceof Error ? err.message : "Failed to check availability"; + logger.error(`Availability check error: ${errorMessage}`); + setError(errorMessage); + return null; + } + finally { + setChecking(false); + setCheckingTrackId(null); + } + }, [availabilityMap]); + const getAvailability = useCallback((spotifyId: string) => { + return availabilityMap.get(spotifyId); + }, [availabilityMap]); + const clearAvailability = useCallback(() => { + setAvailabilityMap(new Map()); + setError(null); + }, []); + return { + checking, + checkingTrackId, + availabilityMap, + error, + checkAvailability, + getAvailability, + clearAvailability, + }; } diff --git a/frontend/src/hooks/useCover.ts b/frontend/src/hooks/useCover.ts index 93fa12f..af929e8 100644 --- a/frontend/src/hooks/useCover.ts +++ b/frontend/src/hooks/useCover.ts @@ -5,253 +5,204 @@ 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 useCover() { - const [downloadingCover, setDownloadingCover] = useState(false); - const [downloadingCoverTrack, setDownloadingCoverTrack] = useState(null); - const [downloadedCovers, setDownloadedCovers] = useState>(new Set()); - const [failedCovers, setFailedCovers] = useState>(new Set()); - const [skippedCovers, setSkippedCovers] = useState>(new Set()); - const [isBulkDownloadingCovers, setIsBulkDownloadingCovers] = useState(false); - const [coverDownloadProgress, setCoverDownloadProgress] = useState(0); - const stopBulkDownloadRef = useRef(false); - - const handleDownloadCover = async ( - coverUrl: string, - trackName: string, - artistName: string, - albumName?: string, - playlistName?: string, - position?: number, - trackId?: string, - albumArtist?: string, - releaseDate?: string, - discNumber?: number, - isAlbum?: boolean - ) => { - if (!coverUrl) { - toast.error("No cover URL found for this track"); - return; - } - - const id = trackId || `${trackName}-${artistName}`; - logger.info(`downloading cover: ${trackName} - ${artistName}`); - const settings = getSettings(); - setDownloadingCover(true); - setDownloadingCoverTrack(id); - - 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__"; - const templateData: TemplateData = { - artist: artistName?.replace(/\//g, placeholder), - album: albumName?.replace(/\//g, placeholder), - title: trackName?.replace(/\//g, placeholder), - track: position, - playlist: playlistName?.replace(/\//g, placeholder), - }; - - // For playlist/discography, prepend the folder name - // Only do this if it's NOT an album download, to avoid double nesting (AlbumName/Artist/AlbumName) - if (playlistName && !isAlbum) { - 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 [downloadingCover, setDownloadingCover] = useState(false); + const [downloadingCoverTrack, setDownloadingCoverTrack] = useState(null); + const [downloadedCovers, setDownloadedCovers] = useState>(new Set()); + const [failedCovers, setFailedCovers] = useState>(new Set()); + const [skippedCovers, setSkippedCovers] = useState>(new Set()); + const [isBulkDownloadingCovers, setIsBulkDownloadingCovers] = useState(false); + const [coverDownloadProgress, setCoverDownloadProgress] = useState(0); + const stopBulkDownloadRef = useRef(false); + const handleDownloadCover = async (coverUrl: string, trackName: string, artistName: string, albumName?: string, playlistName?: string, position?: number, trackId?: string, albumArtist?: string, releaseDate?: string, discNumber?: number, isAlbum?: boolean) => { + if (!coverUrl) { + toast.error("No cover URL found for this track"); + return; } - } - - const response = await downloadCover({ - cover_url: coverUrl, - track_name: trackName, - artist_name: artistName, - album_name: albumName || "", - album_artist: albumArtist || "", - release_date: releaseDate || "", - output_dir: outputDir, - filename_format: settings.filenameTemplate || "{title}", - track_number: settings.trackNumber, - position: position || 0, - disc_number: discNumber || 0, - }); - - if (response.success) { - if (response.already_exists) { - toast.info("Cover file already exists"); - setSkippedCovers((prev) => new Set(prev).add(id)); - } else { - toast.success("Cover downloaded successfully"); - setDownloadedCovers((prev) => new Set(prev).add(id)); - } - setFailedCovers((prev) => { - const newSet = new Set(prev); - newSet.delete(id); - return newSet; - }); - } else { - toast.error(response.error || "Failed to download cover"); - setFailedCovers((prev) => new Set(prev).add(id)); - } - } catch (err) { - toast.error(err instanceof Error ? err.message : "Failed to download cover"); - setFailedCovers((prev) => new Set(prev).add(id)); - } finally { - setDownloadingCover(false); - setDownloadingCoverTrack(null); - } - }; - - const handleDownloadAllCovers = async ( - tracks: TrackMetadata[], - playlistName?: string, - isAlbum?: boolean // Add isAlbum parameter - ) => { - if (tracks.length === 0) { - toast.error("No tracks to download covers"); - return; - } - - const settings = getSettings(); - setIsBulkDownloadingCovers(true); - setCoverDownloadProgress(0); - stopBulkDownloadRef.current = false; - - let completed = 0; - let success = 0; - let skipped = 0; - let failed = 0; - - for (let i = 0; i < tracks.length; i++) { - if (stopBulkDownloadRef.current) { - toast.info("Cover download stopped"); - break; - } - - const track = tracks[i]; - if (!track.images) { - completed++; - setCoverDownloadProgress(Math.round((completed / tracks.length) * 100)); - continue; - } - - const id = track.spotify_id || `${track.name}-${track.artists}`; - setDownloadingCoverTrack(id); - - 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__"; - // Determine if we should use album track number or sequential position - const useAlbumTrackNumber = settings.folderTemplate?.includes("{album}") || false; - // Use track.track_number for album context, otherwise use sequential position (consistent with track download) - const trackPosition = useAlbumTrackNumber ? (track.track_number || i + 1) : (i + 1); - // 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: trackPosition, - playlist: playlistName?.replace(/\//g, placeholder), - }; - - // For playlist/discography, prepend the folder name - // Only do this if it's NOT an album download - if (playlistName && !isAlbum) { - 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 id = trackId || `${trackName}-${artistName}`; + logger.info(`downloading cover: ${trackName} - ${artistName}`); + const settings = getSettings(); + setDownloadingCover(true); + setDownloadingCoverTrack(id); + try { + const os = settings.operatingSystem; + let outputDir = settings.downloadPath; + const placeholder = "__SLASH_PLACEHOLDER__"; + const templateData: TemplateData = { + artist: artistName?.replace(/\//g, placeholder), + album: albumName?.replace(/\//g, placeholder), + title: trackName?.replace(/\//g, placeholder), + track: position, + playlist: playlistName?.replace(/\//g, placeholder), + }; + if (playlistName && !isAlbum) { + outputDir = joinPath(os, outputDir, sanitizePath(playlistName.replace(/\//g, " "), os)); + } + 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) { + const sanitizedPart = part.replace(new RegExp(placeholder, "g"), " "); + outputDir = joinPath(os, outputDir, sanitizePath(sanitizedPart, os)); + } + } + } + const response = await downloadCover({ + cover_url: coverUrl, + track_name: trackName, + artist_name: artistName, + album_name: albumName || "", + album_artist: albumArtist || "", + release_date: releaseDate || "", + output_dir: outputDir, + filename_format: settings.filenameTemplate || "{title}", + track_number: settings.trackNumber, + position: position || 0, + disc_number: discNumber || 0, + }); + if (response.success) { + if (response.already_exists) { + toast.info("Cover file already exists"); + setSkippedCovers((prev) => new Set(prev).add(id)); + } + else { + toast.success("Cover downloaded successfully"); + setDownloadedCovers((prev) => new Set(prev).add(id)); + } + setFailedCovers((prev) => { + const newSet = new Set(prev); + newSet.delete(id); + return newSet; + }); + } + else { + toast.error(response.error || "Failed to download cover"); + setFailedCovers((prev) => new Set(prev).add(id)); } - } } - - const response = await downloadCover({ - cover_url: track.images, - track_name: track.name, - artist_name: track.artists, - album_name: track.album_name, - album_artist: track.album_artist, - release_date: track.release_date, - output_dir: outputDir, - filename_format: settings.filenameTemplate || "{title}", - track_number: settings.trackNumber, - position: trackPosition, - disc_number: track.disc_number, - }); - - if (response.success) { - if (response.already_exists) { - skipped++; - setSkippedCovers((prev) => new Set(prev).add(id)); - } else { - success++; - setDownloadedCovers((prev) => new Set(prev).add(id)); - } - } else { - failed++; - setFailedCovers((prev) => new Set(prev).add(id)); + catch (err) { + toast.error(err instanceof Error ? err.message : "Failed to download cover"); + setFailedCovers((prev) => new Set(prev).add(id)); } - } catch { - failed++; - setFailedCovers((prev) => new Set(prev).add(id)); - } - - completed++; - setCoverDownloadProgress(Math.round((completed / tracks.length) * 100)); - } - - setDownloadingCoverTrack(null); - setIsBulkDownloadingCovers(false); - setCoverDownloadProgress(0); - - if (!stopBulkDownloadRef.current) { - toast.success(`Covers: ${success} downloaded, ${skipped} skipped, ${failed} failed`); - } - }; - - const handleStopCoverDownload = () => { - stopBulkDownloadRef.current = true; - }; - - const resetCoverState = () => { - setDownloadedCovers(new Set()); - setFailedCovers(new Set()); - setSkippedCovers(new Set()); - }; - - return { - downloadingCover, - downloadingCoverTrack, - downloadedCovers, - failedCovers, - skippedCovers, - isBulkDownloadingCovers, - coverDownloadProgress, - handleDownloadCover, - handleDownloadAllCovers, - handleStopCoverDownload, - resetCoverState, - }; + finally { + setDownloadingCover(false); + setDownloadingCoverTrack(null); + } + }; + const handleDownloadAllCovers = async (tracks: TrackMetadata[], playlistName?: string, isAlbum?: boolean) => { + if (tracks.length === 0) { + toast.error("No tracks to download covers"); + return; + } + const settings = getSettings(); + setIsBulkDownloadingCovers(true); + setCoverDownloadProgress(0); + stopBulkDownloadRef.current = false; + let completed = 0; + let success = 0; + let skipped = 0; + let failed = 0; + for (let i = 0; i < tracks.length; i++) { + if (stopBulkDownloadRef.current) { + toast.info("Cover download stopped"); + break; + } + const track = tracks[i]; + if (!track.images) { + completed++; + setCoverDownloadProgress(Math.round((completed / tracks.length) * 100)); + continue; + } + const id = track.spotify_id || `${track.name}-${track.artists}`; + setDownloadingCoverTrack(id); + try { + const os = settings.operatingSystem; + let outputDir = settings.downloadPath; + const placeholder = "__SLASH_PLACEHOLDER__"; + const useAlbumTrackNumber = settings.folderTemplate?.includes("{album}") || false; + const trackPosition = useAlbumTrackNumber ? (track.track_number || i + 1) : (i + 1); + const templateData: TemplateData = { + artist: track.artists?.replace(/\//g, placeholder), + album: track.album_name?.replace(/\//g, placeholder), + title: track.name?.replace(/\//g, placeholder), + track: trackPosition, + playlist: playlistName?.replace(/\//g, placeholder), + }; + if (playlistName && !isAlbum) { + outputDir = joinPath(os, outputDir, sanitizePath(playlistName.replace(/\//g, " "), os)); + } + 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) { + const sanitizedPart = part.replace(new RegExp(placeholder, "g"), " "); + outputDir = joinPath(os, outputDir, sanitizePath(sanitizedPart, os)); + } + } + } + const response = await downloadCover({ + cover_url: track.images, + track_name: track.name, + artist_name: track.artists, + album_name: track.album_name, + album_artist: track.album_artist, + release_date: track.release_date, + output_dir: outputDir, + filename_format: settings.filenameTemplate || "{title}", + track_number: settings.trackNumber, + position: trackPosition, + disc_number: track.disc_number, + }); + if (response.success) { + if (response.already_exists) { + skipped++; + setSkippedCovers((prev) => new Set(prev).add(id)); + } + else { + success++; + setDownloadedCovers((prev) => new Set(prev).add(id)); + } + } + else { + failed++; + setFailedCovers((prev) => new Set(prev).add(id)); + } + } + catch { + failed++; + setFailedCovers((prev) => new Set(prev).add(id)); + } + completed++; + setCoverDownloadProgress(Math.round((completed / tracks.length) * 100)); + } + setDownloadingCoverTrack(null); + setIsBulkDownloadingCovers(false); + setCoverDownloadProgress(0); + if (!stopBulkDownloadRef.current) { + toast.success(`Covers: ${success} downloaded, ${skipped} skipped, ${failed} failed`); + } + }; + const handleStopCoverDownload = () => { + stopBulkDownloadRef.current = true; + }; + const resetCoverState = () => { + setDownloadedCovers(new Set()); + setFailedCovers(new Set()); + setSkippedCovers(new Set()); + }; + return { + downloadingCover, + downloadingCoverTrack, + downloadedCovers, + failedCovers, + skippedCovers, + isBulkDownloadingCovers, + coverDownloadProgress, + handleDownloadCover, + handleDownloadAllCovers, + handleStopCoverDownload, + resetCoverState, + }; } diff --git a/frontend/src/hooks/useDownload.ts b/frontend/src/hooks/useDownload.ts index c7a0672..aa6f6f1 100644 --- a/frontend/src/hooks/useDownload.ts +++ b/frontend/src/hooks/useDownload.ts @@ -1,1003 +1,879 @@ import { useState, useRef } from "react"; -import { downloadTrack } from "@/lib/api"; +import { downloadTrack, fetchSpotifyMetadata } 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"; - -// Type definitions for new backend functions interface CheckFileExistenceRequest { - isrc: string; - track_name: string; - artist_name: string; + spotify_id: string; + track_name: string; + artist_name: string; + album_name?: string; + album_artist?: string; + release_date?: string; + track_number?: number; + disc_number?: number; + position?: number; + use_album_track_number?: boolean; + filename_format?: string; + include_track_number?: boolean; + audio_format?: string; } - interface FileExistenceResult { - isrc: string; - exists: boolean; - file_path?: string; - track_name?: string; - artist_name?: string; + spotify_id: 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 => - (window as any)["go"]["main"]["App"]["CheckFilesExistence"](outputDir, tracks); -const SkipDownloadItem = (itemID: string, filePath: string): Promise => - (window as any)["go"]["main"]["App"]["SkipDownloadItem"](itemID, filePath); - +const CheckFilesExistence = (outputDir: string, tracks: CheckFileExistenceRequest[]): Promise => (window as any)["go"]["main"]["App"]["CheckFilesExistence"](outputDir, tracks); +const SkipDownloadItem = (itemID: string, filePath: string): Promise => (window as any)["go"]["main"]["App"]["SkipDownloadItem"](itemID, filePath); export function useDownload() { - const [downloadProgress, setDownloadProgress] = useState(0); - const [isDownloading, setIsDownloading] = useState(false); - const [downloadingTrack, setDownloadingTrack] = useState(null); - const [bulkDownloadType, setBulkDownloadType] = useState<"all" | "selected" | null>(null); - const [downloadedTracks, setDownloadedTracks] = useState>(new Set()); - const [failedTracks, setFailedTracks] = useState>(new Set()); - const [skippedTracks, setSkippedTracks] = useState>(new Set()); - const [currentDownloadInfo, setCurrentDownloadInfo] = useState<{ - name: string; - artists: string; - } | null>(null); - const shouldStopDownloadRef = useRef(false); - - const downloadWithAutoFallback = async ( - isrc: string, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - settings: any, - trackName?: string, - artistName?: string, - albumName?: string, - playlistName?: string, - position?: number, - spotifyId?: string, - durationMs?: number, - releaseYear?: string, - albumArtist?: string, - releaseDate?: string, - coverUrl?: string, - spotifyTrackNumber?: number, - spotifyDiscNumber?: number, - spotifyTotalTracks?: number - ) => { - const service = settings.downloader; - - const query = trackName && artistName ? `${trackName} ${artistName}` : undefined; - const os = settings.operatingSystem; - - 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?.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?.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.replace(/\//g, " "), os)); - } - - // Apply folder template if available - 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 [downloadProgress, setDownloadProgress] = useState(0); + const [isDownloading, setIsDownloading] = useState(false); + const [downloadingTrack, setDownloadingTrack] = useState(null); + const [bulkDownloadType, setBulkDownloadType] = useState<"all" | "selected" | null>(null); + const [downloadedTracks, setDownloadedTracks] = useState>(new Set()); + const [failedTracks, setFailedTracks] = useState>(new Set()); + const [skippedTracks, setSkippedTracks] = useState>(new Set()); + const [currentDownloadInfo, setCurrentDownloadInfo] = useState<{ + name: string; + artists: string; + } | null>(null); + const shouldStopDownloadRef = useRef(false); + const downloadWithAutoFallback = async (isrc: string, settings: any, trackName?: string, artistName?: string, albumName?: string, playlistName?: string, position?: number, spotifyId?: string, durationMs?: number, releaseYear?: string, albumArtist?: string, releaseDate?: string, coverUrl?: string, spotifyTrackNumber?: number, spotifyDiscNumber?: number, spotifyTotalTracks?: number, spotifyTotalDiscs?: number, copyright?: string, publisher?: string) => { + const service = settings.downloader; + const query = trackName && artistName ? `${trackName} ${artistName}` : undefined; + const os = settings.operatingSystem; + let outputDir = settings.downloadPath; + let useAlbumTrackNumber = false; + const placeholder = "__SLASH_PLACEHOLDER__"; + let finalReleaseDate = releaseDate; + let finalTrackNumber = spotifyTrackNumber || 0; + if (spotifyId) { + try { + const trackURL = `https://open.spotify.com/track/${spotifyId}`; + const trackMetadata = await fetchSpotifyMetadata(trackURL, false, 0, 10); + if ("track" in trackMetadata && trackMetadata.track) { + if (trackMetadata.track.release_date) { + finalReleaseDate = trackMetadata.track.release_date; + } + if (trackMetadata.track.track_number > 0) { + finalTrackNumber = trackMetadata.track.track_number; + } + } + } + catch (err) { + } } - } - - // Use album track number if template contains {album} - if (settings.folderTemplate.includes("{album}")) { - useAlbumTrackNumber = true; - } - } - - // Always add item to queue before downloading - const { AddToDownloadQueue } = await import("../../wailsjs/go/main/App"); - const itemID = await AddToDownloadQueue(isrc, trackName || "", artistName || "", albumName || ""); - - 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 { - const { GetStreamingURLs } = await import("../../wailsjs/go/main/App"); - const urlsJson = await GetStreamingURLs(spotifyId); - streamingURLs = JSON.parse(urlsJson); - } catch (err) { - console.error("Failed to get streaming URLs:", err); + const yearValue = releaseYear || finalReleaseDate?.substring(0, 4); + const hasSubfolder = settings.folderTemplate && settings.folderTemplate.trim() !== ""; + const trackNumberForTemplate = (hasSubfolder && finalTrackNumber > 0) ? finalTrackNumber : (position || 0); + if (hasSubfolder) { + useAlbumTrackNumber = true; } - } - - // Convert duration from ms to seconds for backend - const durationSeconds = durationMs ? Math.round(durationMs / 1000) : undefined; - - // Try Tidal first - if (streamingURLs?.tidal_url) { - try { - logger.debug(`trying tidal for: ${trackName} - ${artistName}`); - const tidalResponse = await downloadTrack({ + const templateData: TemplateData = { + 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: trackNumberForTemplate, + year: yearValue, + playlist: playlistName?.replace(/\//g, placeholder), + }; + if (playlistName) { + outputDir = joinPath(os, outputDir, sanitizePath(playlistName.replace(/\//g, " "), os)); + } + 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) { + const sanitizedPart = part.replace(new RegExp(placeholder, "g"), " "); + outputDir = joinPath(os, outputDir, sanitizePath(sanitizedPart, os)); + } + } + } + const serviceForCheck = service === "auto" ? "flac" : (service === "tidal" ? "flac" : (service === "qobuz" ? "flac" : "flac")); + let fileExists = false; + if (trackName && artistName) { + try { + const checkRequest: CheckFileExistenceRequest = { + spotify_id: spotifyId || isrc, + track_name: trackName, + artist_name: artistName, + album_name: albumName, + album_artist: albumArtist, + release_date: finalReleaseDate || releaseDate, + track_number: finalTrackNumber || spotifyTrackNumber || 0, + disc_number: spotifyDiscNumber || 0, + position: trackNumberForTemplate, + use_album_track_number: useAlbumTrackNumber, + filename_format: settings.filenameTemplate || "", + include_track_number: settings.trackNumber || false, + audio_format: serviceForCheck, + }; + const existenceResults = await CheckFilesExistence(outputDir, [checkRequest]); + if (existenceResults.length > 0 && existenceResults[0].exists) { + fileExists = true; + return { + success: true, + message: "File already exists", + file: existenceResults[0].file_path || "", + already_exists: true, + }; + } + } + catch (err) { + console.warn("File existence check failed:", err); + } + } + const { AddToDownloadQueue } = await import("../../wailsjs/go/main/App"); + let itemID: string | undefined; + if (!fileExists) { + itemID = await AddToDownloadQueue(isrc, trackName || "", artistName || "", albumName || ""); + } + if (service === "auto") { + let streamingURLs: any = null; + if (spotifyId) { + try { + const { GetStreamingURLs } = await import("../../wailsjs/go/main/App"); + const urlsJson = await GetStreamingURLs(spotifyId); + streamingURLs = JSON.parse(urlsJson); + } + catch (err) { + console.error("Failed to get streaming URLs:", err); + } + } + const durationSeconds = durationMs ? Math.round(durationMs / 1000) : undefined; + if (streamingURLs?.tidal_url) { + try { + logger.debug(`trying tidal for: ${trackName} - ${artistName}`); + const tidalResponse = await downloadTrack({ + isrc, + service: "tidal", + query, + track_name: trackName, + artist_name: artistName, + album_name: albumName, + album_artist: albumArtist, + release_date: finalReleaseDate || 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", + spotify_track_number: spotifyTrackNumber, + spotify_disc_number: spotifyDiscNumber, + spotify_total_tracks: spotifyTotalTracks, + spotify_total_discs: spotifyTotalDiscs, + copyright: copyright, + publisher: publisher, + }); + if (tidalResponse.success) { + logger.success(`tidal: ${trackName} - ${artistName}`); + return tidalResponse; + } + logger.warning(`tidal failed, trying amazon...`); + } + catch (tidalErr) { + logger.error(`tidal error: ${tidalErr}`); + } + } + if (streamingURLs?.amazon_url) { + try { + logger.debug(`trying amazon for: ${trackName} - ${artistName}`); + const amazonResponse = await downloadTrack({ + isrc, + service: "amazon", + query, + track_name: trackName, + artist_name: artistName, + album_name: albumName, + album_artist: albumArtist, + release_date: finalReleaseDate || 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, + spotify_total_discs: spotifyTotalDiscs, + copyright: copyright, + publisher: publisher, + }); + if (amazonResponse.success) { + logger.success(`amazon: ${trackName} - ${artistName}`); + return amazonResponse; + } + logger.warning(`amazon failed, trying qobuz...`); + } + catch (amazonErr) { + logger.error(`amazon error: ${amazonErr}`); + } + } + logger.debug(`trying qobuz (fallback) for: ${trackName} - ${artistName}`); + const qobuzResponse = await downloadTrack({ + isrc, + service: "qobuz", + query, + track_name: trackName, + artist_name: artistName, + album_name: albumName, + album_artist: albumArtist, + release_date: finalReleaseDate || releaseDate, + cover_url: coverUrl, + output_dir: outputDir, + filename_format: settings.filenameTemplate, + track_number: settings.trackNumber, + position: trackNumberForTemplate, + 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", + spotify_track_number: spotifyTrackNumber, + spotify_disc_number: spotifyDiscNumber, + spotify_total_tracks: spotifyTotalTracks, + spotify_total_discs: spotifyTotalDiscs, + copyright: copyright, + publisher: publisher, + }); + if (!qobuzResponse.success && itemID) { + const { MarkDownloadItemFailed } = await import("../../wailsjs/go/main/App"); + await MarkDownloadItemFailed(itemID, qobuzResponse.error || "All services failed"); + } + return qobuzResponse; + } + const durationSecondsForFallback = durationMs ? Math.round(durationMs / 1000) : undefined; + 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: "tidal", + service: service as "tidal" | "qobuz" | "amazon", query, track_name: trackName, artist_name: artistName, album_name: albumName, album_artist: albumArtist, - release_date: releaseDate, + release_date: finalReleaseDate || releaseDate, cover_url: coverUrl, output_dir: outputDir, filename_format: settings.filenameTemplate, track_number: settings.trackNumber, - position, + position: trackNumberForTemplate, 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 amazon...`); - } catch (tidalErr) { - logger.error(`tidal error: ${tidalErr}`); - } - } - - // Try Amazon second - if (streamingURLs?.amazon_url) { - try { - logger.debug(`trying amazon for: ${trackName} - ${artistName}`); - const amazonResponse = await downloadTrack({ - isrc, - service: "amazon", - query, - track_name: trackName, - artist_name: artistName, - album_name: albumName, - 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, + duration: durationSecondsForFallback, item_id: itemID, + audio_format: audioFormat, spotify_track_number: spotifyTrackNumber, spotify_disc_number: spotifyDiscNumber, spotify_total_tracks: spotifyTotalTracks, - }); - - if (amazonResponse.success) { - logger.success(`amazon: ${trackName} - ${artistName}`); - return amazonResponse; - } - logger.warning(`amazon failed, trying qobuz...`); - } catch (amazonErr) { - logger.error(`amazon error: ${amazonErr}`); - } - } - - // Try Qobuz as last fallback - logger.debug(`trying qobuz (fallback) for: ${trackName} - ${artistName}`); - const qobuzResponse = await downloadTrack({ - isrc, - service: "qobuz", - 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: 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 - if (!qobuzResponse.success) { - const { MarkDownloadItemFailed } = await import("../../wailsjs/go/main/App"); - await MarkDownloadItemFailed(itemID, qobuzResponse.error || "All services failed"); - } - - return qobuzResponse; - } - - // Single service download (not auto-fallback) - // 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 "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 - if (!singleServiceResponse.success) { - const { MarkDownloadItemFailed } = await import("../../wailsjs/go/main/App"); - await MarkDownloadItemFailed(itemID, singleServiceResponse.error || "Download failed"); - } - - return singleServiceResponse; - }; - - const downloadWithItemID = async ( - isrc: string, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - settings: any, - itemID: string, - trackName?: string, - artistName?: string, - albumName?: string, - folderName?: string, - position?: number, - spotifyId?: string, - durationMs?: number, - isAlbum?: boolean, - releaseYear?: string, - albumArtist?: string, - releaseDate?: string, - coverUrl?: string, - spotifyTrackNumber?: number, - spotifyDiscNumber?: number, - spotifyTotalTracks?: number - ) => { - const service = settings.downloader; - - const query = trackName && artistName ? `${trackName} ${artistName}` : undefined; - const os = settings.operatingSystem; - - 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__"; - const templateData: TemplateData = { - 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?.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.replace(/\//g, " "), os)); - } - - // Apply folder template if available - if (settings.folderTemplate) { - // Parse and apply folder template - const folderPath = parseTemplate(settings.folderTemplate, templateData); - if (folderPath) { - // Split by / (template separators), then restore placeholders as spaces - const parts = folderPath.split("/").filter(p => 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)); - } - } - - // Use album track number if template contains {album} - if (settings.folderTemplate.includes("{album}")) { - useAlbumTrackNumber = true; - } - } - - 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 { - const { GetStreamingURLs } = await import("../../wailsjs/go/main/App"); - const urlsJson = await GetStreamingURLs(spotifyId); - streamingURLs = JSON.parse(urlsJson); - } catch (err) { - console.error("Failed to get streaming URLs:", err); - } - } - - const durationSeconds = durationMs ? Math.round(durationMs / 1000) : undefined; - - // Try Tidal first - if (streamingURLs?.tidal_url) { - try { - const tidalResponse = await downloadTrack({ - isrc, - service: "tidal", - 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, - 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) { - return tidalResponse; - } - } catch (tidalErr) { - console.error("Tidal error:", tidalErr); - } - } - - // Try Amazon second - if (streamingURLs?.amazon_url) { - try { - const amazonResponse = await downloadTrack({ - isrc, - service: "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, - service_url: streamingURLs.amazon_url, - item_id: itemID, - spotify_track_number: spotifyTrackNumber, - spotify_disc_number: spotifyDiscNumber, - spotify_total_tracks: spotifyTotalTracks, - }); - - if (amazonResponse.success) { - return amazonResponse; - } - } catch (amazonErr) { - console.error("Amazon error:", amazonErr); - } - } - - // Try Qobuz as last fallback - const qobuzResponse = await downloadTrack({ - isrc, - service: "qobuz", - 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: 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 - if (!qobuzResponse.success) { - const { MarkDownloadItemFailed } = await import("../../wailsjs/go/main/App"); - await MarkDownloadItemFailed(itemID, qobuzResponse.error || "All services failed"); - } - - return qobuzResponse; - } - - // 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 "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 - if (!singleServiceResponse.success) { - const { MarkDownloadItemFailed } = await import("../../wailsjs/go/main/App"); - await MarkDownloadItemFailed(itemID, singleServiceResponse.error || "Download failed"); - } - - return singleServiceResponse; - }; - - const handleDownloadTrack = async ( - isrc: string, - trackName?: string, - artistName?: string, - albumName?: string, - spotifyId?: string, - playlistName?: string, - durationMs?: 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"); - return; - } - - logger.info(`starting download: ${trackName} - ${artistName}`); - const settings = getSettings(); - setDownloadingTrack(isrc); - - 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, - trackName, - artistName, - albumName, - playlistName, - position, // Pass position for track numbering - spotifyId, - durationMs, - releaseYear, - albumArtist || "", - releaseDate, - coverUrl, - spotifyTrackNumber, // Spotify album track number - spotifyDiscNumber, // Spotify disc number - spotifyTotalTracks // Total tracks in album - ); - - if (response.success) { - if (response.already_exists) { - toast.info(response.message); - setSkippedTracks((prev) => new Set(prev).add(isrc)); - } else { - toast.success(response.message); - } - setDownloadedTracks((prev) => new Set(prev).add(isrc)); - setFailedTracks((prev) => { - const newSet = new Set(prev); - newSet.delete(isrc); - return newSet; + spotify_total_discs: spotifyTotalDiscs, + copyright: copyright, + publisher: publisher, }); - } else { - toast.error(response.error || "Download failed"); - setFailedTracks((prev) => new Set(prev).add(isrc)); - } - } catch (err) { - toast.error(err instanceof Error ? err.message : "Download failed"); - setFailedTracks((prev) => new Set(prev).add(isrc)); - } finally { - setDownloadingTrack(null); - } - }; - - const handleDownloadSelected = async ( - selectedTracks: string[], - allTracks: TrackMetadata[], - folderName?: string, - isAlbum?: boolean - ) => { - if (selectedTracks.length === 0) { - toast.error("No tracks selected"); - return; - } - - logger.info(`starting batch download: ${selectedTracks.length} selected tracks`); - const settings = getSettings(); - setIsDownloading(true); - setBulkDownloadType("selected"); - setDownloadProgress(0); - - // 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(); - const existingFilePaths = new Map(); - - 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) { - const track = allTracks.find((t) => t.isrc === isrc); - const itemID = await AddToDownloadQueue( - isrc, - track?.name || "", - track?.artists || "", - 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 = existingISRCs.size; - const total = selectedTracks.length; - - // 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, ${tracksToDownload.length - i} remaining.` - ); - break; - } - - const track = tracksToDownload[i]; - const isrc = track.isrc; - // Find original index and itemID - const originalIndex = selectedTracks.indexOf(isrc); - const itemID = itemIDs[originalIndex]; - - setDownloadingTrack(isrc); - 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); - - // Download with pre-created itemID - const response = await downloadWithItemID( - isrc, - settings, - itemID, - track.name, - track.artists, - track.album_name, - folderName, - originalIndex + 1, // Sequential position based on selection order - track.spotify_id, - track.duration_ms, - isAlbum, - 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)`); - setSkippedTracks((prev) => new Set(prev).add(isrc)); - } else { - successCount++; - logger.success(`downloaded: ${track.name} - ${track.artists}`); - } - setDownloadedTracks((prev) => new Set(prev).add(isrc)); - setFailedTracks((prev) => { - const newSet = new Set(prev); - newSet.delete(isrc); // Remove from failed if it was there - return newSet; - }); - } else { - errorCount++; - logger.error(`failed: ${track.name} - ${track.artists}`); - setFailedTracks((prev) => new Set(prev).add(isrc)); + if (!singleServiceResponse.success && itemID) { + const { MarkDownloadItemFailed } = await import("../../wailsjs/go/main/App"); + await MarkDownloadItemFailed(itemID, singleServiceResponse.error || "Download failed"); } - } catch (err) { - errorCount++; - 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)); - } - - const completedCount = skippedCount + successCount + errorCount; - setDownloadProgress(Math.min(100, Math.round((completedCount / total) * 100))); - } - - setDownloadingTrack(null); - setCurrentDownloadInfo(null); - setIsDownloading(false); - setBulkDownloadType(null); - shouldStopDownloadRef.current = false; - - // Cancel any remaining queued items - const { CancelAllQueuedItems } = await import("../../wailsjs/go/main/App"); - await CancelAllQueuedItems(); - - // Build summary message - logger.info(`batch complete: ${successCount} downloaded, ${skippedCount} skipped, ${errorCount} failed`); - if (errorCount === 0 && skippedCount === 0) { - toast.success(`Downloaded ${successCount} tracks successfully`); - } else if (errorCount === 0 && successCount === 0) { - // All skipped - toast.info(`${skippedCount} tracks already exist`); - } else if (errorCount === 0) { - // Mix of downloaded and skipped - toast.info(`${successCount} downloaded, ${skippedCount} skipped`); - } else { - // Has errors - const parts = []; - if (successCount > 0) parts.push(`${successCount} downloaded`); - if (skippedCount > 0) parts.push(`${skippedCount} skipped`); - parts.push(`${errorCount} failed`); - toast.warning(parts.join(", ")); - } - }; - - const handleDownloadAll = async ( - tracks: TrackMetadata[], - folderName?: string, - isAlbum?: boolean - ) => { - const tracksWithIsrc = tracks.filter((track) => track.isrc); - - if (tracksWithIsrc.length === 0) { - toast.error("No tracks available for download"); - return; - } - - logger.info(`starting batch download: ${tracksWithIsrc.length} tracks`); - const settings = getSettings(); - setIsDownloading(true); - setBulkDownloadType("all"); - setDownloadProgress(0); - - // 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(); - const existingFilePaths = new Map(); - - 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) { - const itemID = await AddToDownloadQueue( - track.isrc, - track.name, - track.artists, - 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 = existingISRCs.size; - const total = tracksWithIsrc.length; - - // 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, ${tracksToDownload.length - i} remaining.` - ); - break; - } - - 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 }); - - try { - // Extract year from release_date (format: YYYY-MM-DD or YYYY) - const releaseYear = track.release_date?.substring(0, 4); - - const response = await downloadWithItemID( - track.isrc, - settings, - itemID, - track.name, - track.artists, - track.album_name, - folderName, - originalIndex + 1, - track.spotify_id, - track.duration_ms, - isAlbum, - 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)`); - setSkippedTracks((prev) => new Set(prev).add(track.isrc)); - } else { - successCount++; - logger.success(`downloaded: ${track.name} - ${track.artists}`); - } - setDownloadedTracks((prev) => new Set(prev).add(track.isrc)); - setFailedTracks((prev) => { - const newSet = new Set(prev); - newSet.delete(track.isrc); // Remove from failed if it was there - return newSet; - }); - } else { - errorCount++; - logger.error(`failed: ${track.name} - ${track.artists}`); - setFailedTracks((prev) => new Set(prev).add(track.isrc)); + return singleServiceResponse; + }; + const downloadWithItemID = async (isrc: string, settings: any, itemID: string, trackName?: string, artistName?: string, albumName?: string, folderName?: string, position?: number, spotifyId?: string, durationMs?: number, isAlbum?: boolean, releaseYear?: string, albumArtist?: string, releaseDate?: string, coverUrl?: string, spotifyTrackNumber?: number, spotifyDiscNumber?: number, spotifyTotalTracks?: number, spotifyTotalDiscs?: number, copyright?: string, publisher?: string) => { + const service = settings.downloader; + const query = trackName && artistName ? `${trackName} ${artistName}` : undefined; + const os = settings.operatingSystem; + let outputDir = settings.downloadPath; + let useAlbumTrackNumber = false; + const placeholder = "__SLASH_PLACEHOLDER__"; + let finalReleaseDate = releaseDate; + let finalTrackNumber = spotifyTrackNumber || 0; + if (spotifyId) { + try { + const trackURL = `https://open.spotify.com/track/${spotifyId}`; + const trackMetadata = await fetchSpotifyMetadata(trackURL, false, 0, 10); + if ("track" in trackMetadata && trackMetadata.track) { + if (trackMetadata.track.release_date) { + finalReleaseDate = trackMetadata.track.release_date; + } + if (trackMetadata.track.track_number > 0) { + finalTrackNumber = trackMetadata.track.track_number; + } + } + } + catch (err) { + } } - } catch (err) { - errorCount++; - logger.error(`error: ${track.name} - ${err}`); - setFailedTracks((prev) => new Set(prev).add(track.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)); - } - - const completedCount = skippedCount + successCount + errorCount; - setDownloadProgress(Math.min(100, Math.round((completedCount / total) * 100))); - } - - setDownloadingTrack(null); - setCurrentDownloadInfo(null); - setIsDownloading(false); - setBulkDownloadType(null); - shouldStopDownloadRef.current = false; - - // Cancel any remaining queued items - const { CancelAllQueuedItems: CancelQueued } = await import("../../wailsjs/go/main/App"); - await CancelQueued(); - - // Build summary message - logger.info(`batch complete: ${successCount} downloaded, ${skippedCount} skipped, ${errorCount} failed`); - if (errorCount === 0 && skippedCount === 0) { - toast.success(`Downloaded ${successCount} tracks successfully`); - } else if (errorCount === 0 && successCount === 0) { - // All skipped - toast.info(`${skippedCount} tracks already exist`); - } else if (errorCount === 0) { - // Mix of downloaded and skipped - toast.info(`${successCount} downloaded, ${skippedCount} skipped`); - } else { - // Has errors - const parts = []; - if (successCount > 0) parts.push(`${successCount} downloaded`); - if (skippedCount > 0) parts.push(`${skippedCount} skipped`); - parts.push(`${errorCount} failed`); - toast.warning(parts.join(", ")); - } - }; - - const handleStopDownload = () => { - logger.info("download stopped by user"); - shouldStopDownloadRef.current = true; - toast.info("Stopping download..."); - }; - - const resetDownloadedTracks = () => { - setDownloadedTracks(new Set()); - setFailedTracks(new Set()); - setSkippedTracks(new Set()); - }; - - return { - downloadProgress, - isDownloading, - downloadingTrack, - bulkDownloadType, - downloadedTracks, - failedTracks, - skippedTracks, - currentDownloadInfo, - handleDownloadTrack, - handleDownloadSelected, - handleDownloadAll, - handleStopDownload, - resetDownloadedTracks, - }; + const yearValue = releaseYear || finalReleaseDate?.substring(0, 4); + const hasSubfolder = settings.folderTemplate && settings.folderTemplate.trim() !== ""; + const trackNumberForTemplate = (hasSubfolder && finalTrackNumber > 0) ? finalTrackNumber : (position || 0); + if (hasSubfolder) { + useAlbumTrackNumber = true; + } + const templateData: TemplateData = { + 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: trackNumberForTemplate, + year: yearValue, + playlist: folderName?.replace(/\//g, placeholder), + }; + if (folderName && !isAlbum) { + outputDir = joinPath(os, outputDir, sanitizePath(folderName.replace(/\//g, " "), os)); + } + if (settings.folderTemplate) { + const folderPath = parseTemplate(settings.folderTemplate, templateData); + if (folderPath) { + const parts = folderPath.split("/").filter(p => p.trim()); + for (const part of parts) { + const sanitizedPart = part.replace(new RegExp(placeholder, "g"), " "); + outputDir = joinPath(os, outputDir, sanitizePath(sanitizedPart, os)); + } + } + } + if (service === "auto") { + let streamingURLs: any = null; + if (spotifyId) { + try { + const { GetStreamingURLs } = await import("../../wailsjs/go/main/App"); + const urlsJson = await GetStreamingURLs(spotifyId); + streamingURLs = JSON.parse(urlsJson); + } + catch (err) { + console.error("Failed to get streaming URLs:", err); + } + } + const durationSeconds = durationMs ? Math.round(durationMs / 1000) : undefined; + if (streamingURLs?.tidal_url) { + try { + const tidalResponse = await downloadTrack({ + isrc, + service: "tidal", + query, + track_name: trackName, + artist_name: artistName, + album_name: albumName, + album_artist: albumArtist, + release_date: finalReleaseDate || 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", + spotify_track_number: spotifyTrackNumber, + spotify_disc_number: spotifyDiscNumber, + spotify_total_tracks: spotifyTotalTracks, + spotify_total_discs: spotifyTotalDiscs, + copyright: copyright, + publisher: publisher, + }); + if (tidalResponse.success) { + return tidalResponse; + } + } + catch (tidalErr) { + console.error("Tidal error:", tidalErr); + } + } + if (streamingURLs?.amazon_url) { + try { + const amazonResponse = await downloadTrack({ + isrc, + service: "amazon", + query, + track_name: trackName, + artist_name: artistName, + album_name: albumName, + album_artist: albumArtist, + release_date: finalReleaseDate || 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, + spotify_total_discs: spotifyTotalDiscs, + copyright: copyright, + publisher: publisher, + }); + if (amazonResponse.success) { + return amazonResponse; + } + } + catch (amazonErr) { + console.error("Amazon error:", amazonErr); + } + } + const qobuzResponse = await downloadTrack({ + isrc, + service: "qobuz", + query, + track_name: trackName, + artist_name: artistName, + album_name: albumName, + album_artist: albumArtist, + release_date: finalReleaseDate || releaseDate, + cover_url: coverUrl, + output_dir: outputDir, + filename_format: settings.filenameTemplate, + track_number: settings.trackNumber, + position: trackNumberForTemplate, + 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", + spotify_track_number: spotifyTrackNumber, + spotify_disc_number: spotifyDiscNumber, + spotify_total_tracks: spotifyTotalTracks, + spotify_total_discs: spotifyTotalDiscs, + copyright: copyright, + publisher: publisher, + }); + if (!qobuzResponse.success && itemID) { + const { MarkDownloadItemFailed } = await import("../../wailsjs/go/main/App"); + await MarkDownloadItemFailed(itemID, qobuzResponse.error || "All services failed"); + } + return qobuzResponse; + } + const durationSecondsForFallback = durationMs ? Math.round(durationMs / 1000) : undefined; + 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 "tidal" | "qobuz" | "amazon", + query, + track_name: trackName, + artist_name: artistName, + album_name: albumName, + album_artist: albumArtist, + release_date: finalReleaseDate || releaseDate, + cover_url: coverUrl, + output_dir: outputDir, + filename_format: settings.filenameTemplate, + track_number: settings.trackNumber, + position: trackNumberForTemplate, + 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, + spotify_total_discs: spotifyTotalDiscs, + copyright: copyright, + publisher: publisher, + }); + if (!singleServiceResponse.success && itemID) { + const { MarkDownloadItemFailed } = await import("../../wailsjs/go/main/App"); + await MarkDownloadItemFailed(itemID, singleServiceResponse.error || "Download failed"); + } + return singleServiceResponse; + }; + const handleDownloadTrack = async (isrc: string, trackName?: string, artistName?: string, albumName?: string, spotifyId?: string, playlistName?: string, durationMs?: number, position?: number, albumArtist?: string, releaseDate?: string, coverUrl?: string, spotifyTrackNumber?: number, spotifyDiscNumber?: number, spotifyTotalTracks?: number, spotifyTotalDiscs?: number, copyright?: string, publisher?: string) => { + if (!isrc) { + toast.error("No ISRC found for this track"); + return; + } + logger.info(`starting download: ${trackName} - ${artistName}`); + const settings = getSettings(); + setDownloadingTrack(isrc); + try { + const releaseYear = releaseDate?.substring(0, 4); + const response = await downloadWithAutoFallback(isrc, settings, trackName, artistName, albumName, playlistName, position, spotifyId, durationMs, releaseYear, albumArtist || "", releaseDate, coverUrl, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks, spotifyTotalDiscs, copyright, publisher); + if (response.success) { + if (response.already_exists) { + toast.info(response.message); + setSkippedTracks((prev) => new Set(prev).add(isrc)); + } + else { + toast.success(response.message); + } + setDownloadedTracks((prev) => new Set(prev).add(isrc)); + setFailedTracks((prev) => { + const newSet = new Set(prev); + newSet.delete(isrc); + return newSet; + }); + } + else { + toast.error(response.error || "Download failed"); + setFailedTracks((prev) => new Set(prev).add(isrc)); + } + } + catch (err) { + toast.error(err instanceof Error ? err.message : "Download failed"); + setFailedTracks((prev) => new Set(prev).add(isrc)); + } + finally { + setDownloadingTrack(null); + } + }; + const handleDownloadSelected = async (selectedTracks: string[], allTracks: TrackMetadata[], folderName?: string, isAlbum?: boolean) => { + if (selectedTracks.length === 0) { + toast.error("No tracks selected"); + return; + } + logger.info(`starting batch download: ${selectedTracks.length} selected tracks`); + const settings = getSettings(); + setIsDownloading(true); + setBulkDownloadType("selected"); + setDownloadProgress(0); + let outputDir = settings.downloadPath; + const os = settings.operatingSystem; + if (folderName && !isAlbum) { + outputDir = joinPath(os, outputDir, sanitizePath(folderName.replace(/\//g, " "), os)); + } + const selectedTrackObjects = selectedTracks + .map((isrc) => allTracks.find((t) => t.isrc === isrc)) + .filter((t): t is TrackMetadata => t !== undefined); + logger.info(`checking existing files in parallel...`); + const useAlbumTrackNumber = settings.folderTemplate?.includes("{album}") || false; + const audioFormat = "flac"; + const existenceChecks = selectedTrackObjects.map((track, index) => { + return { + spotify_id: track.spotify_id || track.isrc, + track_name: track.name || "", + artist_name: track.artists || "", + album_name: track.album_name || "", + album_artist: track.album_artist || "", + release_date: track.release_date || "", + track_number: track.track_number || 0, + disc_number: track.disc_number || 0, + position: index + 1, + use_album_track_number: useAlbumTrackNumber, + filename_format: settings.filenameTemplate || "", + include_track_number: settings.trackNumber || false, + audio_format: audioFormat, + }; + }); + const existenceResults = await CheckFilesExistence(outputDir, existenceChecks); + const existingSpotifyIDs = new Set(); + const existingFilePaths = new Map(); + for (const result of existenceResults) { + if (result.exists) { + existingSpotifyIDs.add(result.spotify_id); + existingFilePaths.set(result.spotify_id, result.file_path || ""); + } + } + logger.info(`found ${existingSpotifyIDs.size} existing files`); + const { AddToDownloadQueue } = await import("../../wailsjs/go/main/App"); + const itemIDs: string[] = []; + for (const isrc of selectedTracks) { + const track = allTracks.find((t) => t.isrc === isrc); + const trackID = track?.spotify_id || isrc; + const itemID = await AddToDownloadQueue(trackID, track?.name || "", track?.artists || "", track?.album_name || ""); + itemIDs.push(itemID); + if (existingSpotifyIDs.has(trackID)) { + const filePath = existingFilePaths.get(trackID) || ""; + setTimeout(() => SkipDownloadItem(itemID, filePath), 10); + setSkippedTracks((prev) => new Set(prev).add(isrc)); + setDownloadedTracks((prev) => new Set(prev).add(isrc)); + } + } + const tracksToDownload = selectedTrackObjects.filter((track) => { + const trackID = track.spotify_id || track.isrc; + return !existingSpotifyIDs.has(trackID); + }); + let successCount = 0; + let errorCount = 0; + let skippedCount = existingSpotifyIDs.size; + const total = selectedTracks.length; + setDownloadProgress(Math.round((skippedCount / total) * 100)); + for (let i = 0; i < tracksToDownload.length; i++) { + if (shouldStopDownloadRef.current) { + toast.info(`Download stopped. ${successCount} tracks downloaded, ${tracksToDownload.length - i} remaining.`); + break; + } + const track = tracksToDownload[i]; + const isrc = track.isrc; + const originalIndex = selectedTracks.indexOf(isrc); + const itemID = itemIDs[originalIndex]; + setDownloadingTrack(isrc); + setCurrentDownloadInfo({ name: track.name, artists: track.artists }); + try { + const releaseYear = track.release_date?.substring(0, 4); + const response = await downloadWithItemID(isrc, settings, itemID, track.name, track.artists, track.album_name, folderName, originalIndex + 1, track.spotify_id, track.duration_ms, isAlbum, releaseYear, track.album_artist || "", track.release_date, track.images, track.track_number, track.disc_number, track.total_tracks, track.total_discs, track.copyright, track.publisher); + if (response.success) { + if (response.already_exists) { + skippedCount++; + logger.info(`skipped: ${track.name} - ${track.artists} (already exists)`); + setSkippedTracks((prev) => new Set(prev).add(isrc)); + } + else { + successCount++; + logger.success(`downloaded: ${track.name} - ${track.artists}`); + } + setDownloadedTracks((prev) => new Set(prev).add(isrc)); + setFailedTracks((prev) => { + const newSet = new Set(prev); + newSet.delete(isrc); + return newSet; + }); + } + else { + errorCount++; + logger.error(`failed: ${track.name} - ${track.artists}`); + setFailedTracks((prev) => new Set(prev).add(isrc)); + } + } + catch (err) { + errorCount++; + logger.error(`error: ${track.name} - ${err}`); + setFailedTracks((prev) => new Set(prev).add(isrc)); + if (itemID) { + const { MarkDownloadItemFailed } = await import("../../wailsjs/go/main/App"); + await MarkDownloadItemFailed(itemID, err instanceof Error ? err.message : String(err)); + } + } + const completedCount = skippedCount + successCount + errorCount; + setDownloadProgress(Math.min(100, Math.round((completedCount / total) * 100))); + } + setDownloadingTrack(null); + setCurrentDownloadInfo(null); + setIsDownloading(false); + setBulkDownloadType(null); + shouldStopDownloadRef.current = false; + const { CancelAllQueuedItems } = await import("../../wailsjs/go/main/App"); + await CancelAllQueuedItems(); + logger.info(`batch complete: ${successCount} downloaded, ${skippedCount} skipped, ${errorCount} failed`); + if (errorCount === 0 && skippedCount === 0) { + toast.success(`Downloaded ${successCount} tracks successfully`); + } + else if (errorCount === 0 && successCount === 0) { + toast.info(`${skippedCount} tracks already exist`); + } + else if (errorCount === 0) { + toast.info(`${successCount} downloaded, ${skippedCount} skipped`); + } + else { + const parts = []; + if (successCount > 0) + parts.push(`${successCount} downloaded`); + if (skippedCount > 0) + parts.push(`${skippedCount} skipped`); + parts.push(`${errorCount} failed`); + toast.warning(parts.join(", ")); + } + }; + const handleDownloadAll = async (tracks: TrackMetadata[], folderName?: string, isAlbum?: boolean) => { + const tracksWithIsrc = tracks.filter((track) => track.isrc); + if (tracksWithIsrc.length === 0) { + toast.error("No tracks available for download"); + return; + } + logger.info(`starting batch download: ${tracksWithIsrc.length} tracks`); + const settings = getSettings(); + setIsDownloading(true); + setBulkDownloadType("all"); + setDownloadProgress(0); + let outputDir = settings.downloadPath; + const os = settings.operatingSystem; + if (folderName && !isAlbum) { + outputDir = joinPath(os, outputDir, sanitizePath(folderName.replace(/\//g, " "), os)); + } + logger.info(`checking existing files in parallel...`); + const useAlbumTrackNumber = settings.folderTemplate?.includes("{album}") || false; + const audioFormat = "flac"; + const existenceChecks = tracksWithIsrc.map((track, index) => { + return { + spotify_id: track.spotify_id || track.isrc, + track_name: track.name || "", + artist_name: track.artists || "", + album_name: track.album_name || "", + album_artist: track.album_artist || "", + release_date: track.release_date || "", + track_number: track.track_number || 0, + disc_number: track.disc_number || 0, + position: index + 1, + use_album_track_number: useAlbumTrackNumber, + filename_format: settings.filenameTemplate || "", + include_track_number: settings.trackNumber || false, + audio_format: audioFormat, + }; + }); + const existenceResults = await CheckFilesExistence(outputDir, existenceChecks); + const existingSpotifyIDs = new Set(); + const existingFilePaths = new Map(); + for (const result of existenceResults) { + if (result.exists) { + existingSpotifyIDs.add(result.spotify_id); + existingFilePaths.set(result.spotify_id, result.file_path || ""); + } + } + logger.info(`found ${existingSpotifyIDs.size} existing files`); + const { AddToDownloadQueue } = await import("../../wailsjs/go/main/App"); + const itemIDs: string[] = []; + for (const track of tracksWithIsrc) { + const itemID = await AddToDownloadQueue(track.isrc, track.name, track.artists, track.album_name || ""); + itemIDs.push(itemID); + const trackID = track.spotify_id || track.isrc; + if (existingSpotifyIDs.has(trackID)) { + const filePath = existingFilePaths.get(trackID) || ""; + setTimeout(() => SkipDownloadItem(itemID, filePath), 10); + setSkippedTracks((prev) => new Set(prev).add(track.isrc)); + setDownloadedTracks((prev) => new Set(prev).add(track.isrc)); + } + } + const tracksToDownload = tracksWithIsrc.filter((track) => { + const trackID = track.spotify_id || track.isrc; + return !existingSpotifyIDs.has(trackID); + }); + let successCount = 0; + let errorCount = 0; + let skippedCount = existingSpotifyIDs.size; + const total = tracksWithIsrc.length; + setDownloadProgress(Math.round((skippedCount / total) * 100)); + for (let i = 0; i < tracksToDownload.length; i++) { + if (shouldStopDownloadRef.current) { + toast.info(`Download stopped. ${successCount} tracks downloaded, ${tracksToDownload.length - i} remaining.`); + break; + } + const track = tracksToDownload[i]; + const originalIndex = tracksWithIsrc.findIndex((t) => t.isrc === track.isrc); + const itemID = itemIDs[originalIndex]; + setDownloadingTrack(track.isrc); + setCurrentDownloadInfo({ name: track.name, artists: track.artists }); + try { + const releaseYear = track.release_date?.substring(0, 4); + const response = await downloadWithItemID(track.isrc, settings, itemID, track.name, track.artists, track.album_name, folderName, originalIndex + 1, track.spotify_id, track.duration_ms, isAlbum, releaseYear, track.album_artist || "", track.release_date, track.images, track.track_number, track.disc_number, track.total_tracks, track.total_discs, track.copyright, track.publisher); + if (response.success) { + if (response.already_exists) { + skippedCount++; + logger.info(`skipped: ${track.name} - ${track.artists} (already exists)`); + setSkippedTracks((prev) => new Set(prev).add(track.isrc)); + } + else { + successCount++; + logger.success(`downloaded: ${track.name} - ${track.artists}`); + } + setDownloadedTracks((prev) => new Set(prev).add(track.isrc)); + setFailedTracks((prev) => { + const newSet = new Set(prev); + newSet.delete(track.isrc); + return newSet; + }); + } + else { + errorCount++; + logger.error(`failed: ${track.name} - ${track.artists}`); + setFailedTracks((prev) => new Set(prev).add(track.isrc)); + } + } + catch (err) { + errorCount++; + logger.error(`error: ${track.name} - ${err}`); + setFailedTracks((prev) => new Set(prev).add(track.isrc)); + const { MarkDownloadItemFailed } = await import("../../wailsjs/go/main/App"); + await MarkDownloadItemFailed(itemID, err instanceof Error ? err.message : String(err)); + } + const completedCount = skippedCount + successCount + errorCount; + setDownloadProgress(Math.min(100, Math.round((completedCount / total) * 100))); + } + setDownloadingTrack(null); + setCurrentDownloadInfo(null); + setIsDownloading(false); + setBulkDownloadType(null); + shouldStopDownloadRef.current = false; + const { CancelAllQueuedItems: CancelQueued } = await import("../../wailsjs/go/main/App"); + await CancelQueued(); + logger.info(`batch complete: ${successCount} downloaded, ${skippedCount} skipped, ${errorCount} failed`); + if (errorCount === 0 && skippedCount === 0) { + toast.success(`Downloaded ${successCount} tracks successfully`); + } + else if (errorCount === 0 && successCount === 0) { + toast.info(`${skippedCount} tracks already exist`); + } + else if (errorCount === 0) { + toast.info(`${successCount} downloaded, ${skippedCount} skipped`); + } + else { + const parts = []; + if (successCount > 0) + parts.push(`${successCount} downloaded`); + if (skippedCount > 0) + parts.push(`${skippedCount} skipped`); + parts.push(`${errorCount} failed`); + toast.warning(parts.join(", ")); + } + }; + const handleStopDownload = () => { + logger.info("download stopped by user"); + shouldStopDownloadRef.current = true; + toast.info("Stopping download..."); + }; + const resetDownloadedTracks = () => { + setDownloadedTracks(new Set()); + setFailedTracks(new Set()); + setSkippedTracks(new Set()); + }; + return { + downloadProgress, + isDownloading, + downloadingTrack, + bulkDownloadType, + downloadedTracks, + failedTracks, + skippedTracks, + currentDownloadInfo, + handleDownloadTrack, + handleDownloadSelected, + handleDownloadAll, + handleStopDownload, + resetDownloadedTracks, + }; } diff --git a/frontend/src/hooks/useDownloadProgress.ts b/frontend/src/hooks/useDownloadProgress.ts index b84ddde..ee7342e 100644 --- a/frontend/src/hooks/useDownloadProgress.ts +++ b/frontend/src/hooks/useDownloadProgress.ts @@ -1,44 +1,34 @@ import { useState, useEffect, useRef } from "react"; import { GetDownloadProgress } from "../../wailsjs/go/main/App"; - export interface DownloadProgressInfo { - is_downloading: boolean; - mb_downloaded: number; - speed_mbps: number; + is_downloading: boolean; + mb_downloaded: number; + speed_mbps: number; } - export function useDownloadProgress() { - const [progress, setProgress] = useState({ - is_downloading: false, - mb_downloaded: 0, - speed_mbps: 0, - }); - const intervalRef = useRef(null); - - useEffect(() => { - // Poll progress every 200ms for smooth updates - const pollProgress = async () => { - try { - const progressInfo = await GetDownloadProgress(); - setProgress(progressInfo); - } catch (error) { - console.error("Failed to get download progress:", error); - } - }; - - // Start polling - intervalRef.current = window.setInterval(pollProgress, 200); - - // Initial fetch - pollProgress(); - - // Cleanup - return () => { - if (intervalRef.current) { - clearInterval(intervalRef.current); - } - }; - }, []); - - return progress; + const [progress, setProgress] = useState({ + is_downloading: false, + mb_downloaded: 0, + speed_mbps: 0, + }); + const intervalRef = useRef(null); + useEffect(() => { + const pollProgress = async () => { + try { + const progressInfo = await GetDownloadProgress(); + setProgress(progressInfo); + } + catch (error) { + console.error("Failed to get download progress:", error); + } + }; + intervalRef.current = window.setInterval(pollProgress, 200); + pollProgress(); + return () => { + if (intervalRef.current) { + clearInterval(intervalRef.current); + } + }; + }, []); + return progress; } diff --git a/frontend/src/hooks/useDownloadQueueData.ts b/frontend/src/hooks/useDownloadQueueData.ts index 15c9b32..2257f2b 100644 --- a/frontend/src/hooks/useDownloadQueueData.ts +++ b/frontend/src/hooks/useDownloadQueueData.ts @@ -1,40 +1,31 @@ import { useEffect, useState } from "react"; import { GetDownloadQueue } from "../../wailsjs/go/main/App"; import { backend } from "../../wailsjs/go/models"; - export function useDownloadQueueData() { - const [queueInfo, setQueueInfo] = useState( - new backend.DownloadQueueInfo({ - is_downloading: false, - queue: [], - current_speed: 0, - total_downloaded: 0, - session_start_time: 0, - queued_count: 0, - completed_count: 0, - failed_count: 0, - skipped_count: 0, - }) - ); - - useEffect(() => { - const fetchQueue = async () => { - try { - const info = await GetDownloadQueue(); - setQueueInfo(info); - } catch (error) { - console.error("Failed to get download queue:", error); - } - }; - - // Initial fetch - fetchQueue(); - - // Poll every 200ms - const interval = setInterval(fetchQueue, 200); - - return () => clearInterval(interval); - }, []); - - return queueInfo; + const [queueInfo, setQueueInfo] = useState(new backend.DownloadQueueInfo({ + is_downloading: false, + queue: [], + current_speed: 0, + total_downloaded: 0, + session_start_time: 0, + queued_count: 0, + completed_count: 0, + failed_count: 0, + skipped_count: 0, + })); + useEffect(() => { + const fetchQueue = async () => { + try { + const info = await GetDownloadQueue(); + setQueueInfo(info); + } + catch (error) { + console.error("Failed to get download queue:", error); + } + }; + fetchQueue(); + const interval = setInterval(fetchQueue, 200); + return () => clearInterval(interval); + }, []); + return queueInfo; } diff --git a/frontend/src/hooks/useDownloadQueueDialog.ts b/frontend/src/hooks/useDownloadQueueDialog.ts index 649a61b..cf03847 100644 --- a/frontend/src/hooks/useDownloadQueueDialog.ts +++ b/frontend/src/hooks/useDownloadQueueDialog.ts @@ -1,16 +1,13 @@ import { useState } from "react"; - export function useDownloadQueueDialog() { - const [isOpen, setIsOpen] = useState(false); - - const openQueue = () => setIsOpen(true); - const closeQueue = () => setIsOpen(false); - const toggleQueue = () => setIsOpen((prev) => !prev); - - return { - isOpen, - openQueue, - closeQueue, - toggleQueue, - }; + const [isOpen, setIsOpen] = useState(false); + const openQueue = () => setIsOpen(true); + const closeQueue = () => setIsOpen(false); + const toggleQueue = () => setIsOpen((prev) => !prev); + return { + isOpen, + openQueue, + closeQueue, + toggleQueue, + }; } diff --git a/frontend/src/hooks/useLyrics.ts b/frontend/src/hooks/useLyrics.ts index b0f5a18..a95400e 100644 --- a/frontend/src/hooks/useLyrics.ts +++ b/frontend/src/hooks/useLyrics.ts @@ -5,260 +5,207 @@ 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(null); - const [downloadedLyrics, setDownloadedLyrics] = useState>(new Set()); - const [failedLyrics, setFailedLyrics] = useState>(new Set()); - const [skippedLyrics, setSkippedLyrics] = useState>(new Set()); - const [isBulkDownloadingLyrics, setIsBulkDownloadingLyrics] = useState(false); - const [lyricsDownloadProgress, setLyricsDownloadProgress] = useState(0); - const stopBulkDownloadRef = useRef(false); - - const handleDownloadLyrics = async ( - spotifyId: string, - trackName: string, - artistName: string, - albumName?: string, - playlistName?: string, - position?: number, - albumArtist?: string, - releaseDate?: string, - discNumber?: number, - isAlbum?: boolean // Add isAlbum parameter - ) => { - if (!spotifyId) { - toast.error("No Spotify ID found for this track"); - return; - } - - logger.info(`downloading lyrics: ${trackName} - ${artistName}`); - const settings = getSettings(); - setDownloadingLyricsTrack(spotifyId); - - try { - const os = settings.operatingSystem; - let outputDir = settings.downloadPath; - - // Build output path 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?.replace(/\//g, placeholder), - album: albumName?.replace(/\//g, placeholder), - title: trackName?.replace(/\//g, placeholder), - track: position, - playlist: playlistName?.replace(/\//g, placeholder), - }; - - // For playlist/discography, prepend the folder name - // Only do this if it's NOT an album download - if (playlistName && !isAlbum) { - 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 [downloadingLyricsTrack, setDownloadingLyricsTrack] = useState(null); + const [downloadedLyrics, setDownloadedLyrics] = useState>(new Set()); + const [failedLyrics, setFailedLyrics] = useState>(new Set()); + const [skippedLyrics, setSkippedLyrics] = useState>(new Set()); + const [isBulkDownloadingLyrics, setIsBulkDownloadingLyrics] = useState(false); + const [lyricsDownloadProgress, setLyricsDownloadProgress] = useState(0); + const stopBulkDownloadRef = useRef(false); + const handleDownloadLyrics = async (spotifyId: string, trackName: string, artistName: string, albumName?: string, playlistName?: string, position?: number, albumArtist?: string, releaseDate?: string, discNumber?: number, isAlbum?: boolean) => { + if (!spotifyId) { + toast.error("No Spotify ID found for this track"); + return; } - } - - const useAlbumTrackNumber = settings.folderTemplate?.includes("{album}") || false; - - const response = await downloadLyrics({ - spotify_id: spotifyId, - track_name: trackName, - artist_name: artistName, - album_name: albumName, - album_artist: albumArtist, - release_date: releaseDate, - output_dir: outputDir, - filename_format: settings.filenameTemplate || "{title}", - track_number: settings.trackNumber, - position: position || 0, - use_album_track_number: useAlbumTrackNumber, - disc_number: discNumber, - }); - - if (response.success) { - if (response.already_exists) { - toast.info("Lyrics file already exists"); - setSkippedLyrics((prev) => new Set(prev).add(spotifyId)); - } else { - toast.success("Lyrics downloaded successfully"); - setDownloadedLyrics((prev) => new Set(prev).add(spotifyId)); - } - setFailedLyrics((prev) => { - const newSet = new Set(prev); - newSet.delete(spotifyId); - return newSet; - }); - } else { - toast.error(response.error || "Failed to download lyrics"); - setFailedLyrics((prev) => new Set(prev).add(spotifyId)); - } - } catch (err) { - toast.error(err instanceof Error ? err.message : "Failed to download lyrics"); - setFailedLyrics((prev) => new Set(prev).add(spotifyId)); - } finally { - setDownloadingLyricsTrack(null); - } - }; - - const handleDownloadAllLyrics = async ( - tracks: TrackMetadata[], - playlistName?: string, - _isArtistDiscography?: boolean, - isAlbum?: boolean // Add isAlbum parameter - ) => { - 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 (let i = 0; i < tracksWithSpotifyId.length; i++) { - const track = tracksWithSpotifyId[i]; - 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__"; - - // Determine if we should use album track number or sequential position - const useAlbumTrackNumber = settings.folderTemplate?.includes("{album}") || false; - // Use track.track_number for album context, otherwise use sequential position (consistent with track download) - const trackPosition = useAlbumTrackNumber ? (track.track_number || i + 1) : (i + 1); - - // 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: trackPosition, - playlist: playlistName?.replace(/\//g, placeholder), - }; - - // For playlist/discography, prepend the folder name - // Only do this if it's NOT an album download - if (playlistName && !isAlbum) { - 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)); + logger.info(`downloading lyrics: ${trackName} - ${artistName}`); + const settings = getSettings(); + setDownloadingLyricsTrack(spotifyId); + try { + const os = settings.operatingSystem; + let outputDir = settings.downloadPath; + const placeholder = "__SLASH_PLACEHOLDER__"; + const templateData: TemplateData = { + artist: artistName?.replace(/\//g, placeholder), + album: albumName?.replace(/\//g, placeholder), + title: trackName?.replace(/\//g, placeholder), + track: position, + playlist: playlistName?.replace(/\//g, placeholder), + }; + if (playlistName && !isAlbum) { + outputDir = joinPath(os, outputDir, sanitizePath(playlistName.replace(/\//g, " "), os)); + } + 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) { + 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: spotifyId, + track_name: trackName, + artist_name: artistName, + album_name: albumName, + album_artist: albumArtist, + release_date: releaseDate, + output_dir: outputDir, + filename_format: settings.filenameTemplate || "{title}", + track_number: settings.trackNumber, + position: position || 0, + use_album_track_number: useAlbumTrackNumber, + disc_number: discNumber, + }); + if (response.success) { + if (response.already_exists) { + toast.info("Lyrics file already exists"); + setSkippedLyrics((prev) => new Set(prev).add(spotifyId)); + } + else { + toast.success("Lyrics downloaded successfully"); + setDownloadedLyrics((prev) => new Set(prev).add(spotifyId)); + } + setFailedLyrics((prev) => { + const newSet = new Set(prev); + newSet.delete(spotifyId); + return newSet; + }); + } + else { + toast.error(response.error || "Failed to download lyrics"); + setFailedLyrics((prev) => new Set(prev).add(spotifyId)); } - } } - - const response = await downloadLyrics({ - spotify_id: id, - track_name: track.name, - artist_name: track.artists, - album_name: track.album_name, - album_artist: track.album_artist, - release_date: track.release_date, - output_dir: outputDir, - filename_format: settings.filenameTemplate || "{title}", - track_number: settings.trackNumber, - position: trackPosition, - use_album_track_number: useAlbumTrackNumber, - disc_number: track.disc_number, - }); - - 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) { + toast.error(err instanceof Error ? err.message : "Failed to download lyrics"); + setFailedLyrics((prev) => new Set(prev).add(spotifyId)); } - } 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()); - setSkippedLyrics(new Set()); - }; - - return { - downloadingLyricsTrack, - downloadedLyrics, - failedLyrics, - skippedLyrics, - isBulkDownloadingLyrics, - lyricsDownloadProgress, - handleDownloadLyrics, - handleDownloadAllLyrics, - handleStopLyricsDownload, - resetLyricsState, - }; + finally { + setDownloadingLyricsTrack(null); + } + }; + const handleDownloadAllLyrics = async (tracks: TrackMetadata[], playlistName?: string, _isArtistDiscography?: boolean, isAlbum?: 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 (let i = 0; i < tracksWithSpotifyId.length; i++) { + const track = tracksWithSpotifyId[i]; + 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; + const placeholder = "__SLASH_PLACEHOLDER__"; + const useAlbumTrackNumber = settings.folderTemplate?.includes("{album}") || false; + const trackPosition = useAlbumTrackNumber ? (track.track_number || i + 1) : (i + 1); + const templateData: TemplateData = { + artist: track.artists?.replace(/\//g, placeholder), + album: track.album_name?.replace(/\//g, placeholder), + title: track.name?.replace(/\//g, placeholder), + track: trackPosition, + playlist: playlistName?.replace(/\//g, placeholder), + }; + if (playlistName && !isAlbum) { + outputDir = joinPath(os, outputDir, sanitizePath(playlistName.replace(/\//g, " "), os)); + } + 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) { + const sanitizedPart = part.replace(new RegExp(placeholder, "g"), " "); + outputDir = joinPath(os, outputDir, sanitizePath(sanitizedPart, os)); + } + } + } + const response = await downloadLyrics({ + spotify_id: id, + track_name: track.name, + artist_name: track.artists, + album_name: track.album_name, + album_artist: track.album_artist, + release_date: track.release_date, + output_dir: outputDir, + filename_format: settings.filenameTemplate || "{title}", + track_number: settings.trackNumber, + position: trackPosition, + use_album_track_number: useAlbumTrackNumber, + disc_number: track.disc_number, + }); + 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()); + setSkippedLyrics(new Set()); + }; + return { + downloadingLyricsTrack, + downloadedLyrics, + failedLyrics, + skippedLyrics, + isBulkDownloadingLyrics, + lyricsDownloadProgress, + handleDownloadLyrics, + handleDownloadAllLyrics, + handleStopLyricsDownload, + resetLyricsState, + }; } diff --git a/frontend/src/hooks/useMetadata.ts b/frontend/src/hooks/useMetadata.ts index 6a446c6..4e2d60e 100644 --- a/frontend/src/hooks/useMetadata.ts +++ b/frontend/src/hooks/useMetadata.ts @@ -3,202 +3,215 @@ import { fetchSpotifyMetadata } from "@/lib/api"; import { toastWithSound as toast } from "@/lib/toast-with-sound"; import { logger } from "@/lib/logger"; import type { SpotifyMetadataResponse } from "@/types/api"; - export function useMetadata() { - const [loading, setLoading] = useState(false); - const [metadata, setMetadata] = useState(null); - const [showTimeoutDialog, setShowTimeoutDialog] = useState(false); - const [timeoutValue, setTimeoutValue] = useState(60); - const [pendingUrl, setPendingUrl] = useState(""); - const [showAlbumDialog, setShowAlbumDialog] = useState(false); - const [selectedAlbum, setSelectedAlbum] = useState<{ - id: string; - name: string; - external_urls: string; - } | null>(null); - const [pendingArtistName, setPendingArtistName] = useState(null); - - const getUrlType = (url: string): string => { - if (url.includes("/track/")) return "track"; - if (url.includes("/album/")) return "album"; - if (url.includes("/playlist/")) return "playlist"; - if (url.includes("/artist/")) return "artist"; - return "unknown"; - }; - - const fetchMetadataDirectly = async (url: string) => { - const urlType = getUrlType(url); - logger.info(`fetching ${urlType} metadata...`); - logger.debug(`url: ${url}`); - - setLoading(true); - setMetadata(null); - - try { - const startTime = Date.now(); - const data = await fetchSpotifyMetadata(url); - const elapsed = ((Date.now() - startTime) / 1000).toFixed(2); - - setMetadata(data); - - // Log detailed info based on type - if ("track" in data) { - logger.success(`fetched track: ${data.track.name} - ${data.track.artists}`); - logger.debug(`isrc: ${data.track.isrc}, duration: ${data.track.duration_ms}ms`); - } else if ("album_info" in data) { - logger.success(`fetched album: ${data.album_info.name}`); - logger.debug(`${data.track_list.length} tracks, released: ${data.album_info.release_date}`); - } else if ("playlist_info" in data) { - logger.success(`fetched playlist: ${data.track_list.length} tracks`); - logger.debug(`by ${data.playlist_info.owner.display_name || data.playlist_info.owner.name}`); - } else if ("artist_info" in data) { - logger.success(`fetched artist: ${data.artist_info.name}`); - logger.debug(`${data.album_list.length} albums, ${data.track_list.length} tracks`); - } - - logger.info(`fetch completed in ${elapsed}s`); - toast.success("Metadata fetched successfully"); - } catch (err) { - const errorMsg = err instanceof Error ? err.message : "Failed to fetch metadata"; - logger.error(`fetch failed: ${errorMsg}`); - toast.error(errorMsg); - } finally { - setLoading(false); - } - }; - - const handleFetchMetadata = async (url: string) => { - if (!url.trim()) { - logger.warning("empty url provided"); - toast.error("Please enter a Spotify URL"); - return; - } - - let urlToFetch = url.trim(); - const isArtistUrl = urlToFetch.includes("/artist/"); - - if (isArtistUrl && !urlToFetch.includes("/discography")) { - urlToFetch = urlToFetch.replace(/\/$/, "") + "/discography/all"; - logger.debug("converted to discography url"); - } - - if (isArtistUrl) { - logger.info("artist url detected, showing timeout dialog"); - setPendingUrl(urlToFetch); - setPendingArtistName(null); // Clear artist name for URL input - setShowTimeoutDialog(true); - } else { - await fetchMetadataDirectly(urlToFetch); - } - - return urlToFetch; - }; - - const handleConfirmFetch = async () => { - setShowTimeoutDialog(false); - logger.info(`fetching artist discography (timeout: ${timeoutValue}s)...`); - logger.debug(`url: ${pendingUrl}`); - - setLoading(true); - setMetadata(null); - - try { - const startTime = Date.now(); - const data = await fetchSpotifyMetadata(pendingUrl, true, 1.0, timeoutValue); - const elapsed = ((Date.now() - startTime) / 1000).toFixed(2); - - setMetadata(data); - - if ("artist_info" in data) { - logger.success(`fetched artist: ${data.artist_info.name}`); - logger.debug(`${data.album_list.length} albums, ${data.track_list.length} tracks`); - } - - logger.info(`fetch completed in ${elapsed}s`); - toast.success("Metadata fetched successfully"); - } catch (err) { - const errorMsg = err instanceof Error ? err.message : "Failed to fetch metadata"; - logger.error(`fetch failed: ${errorMsg}`); - toast.error(errorMsg); - } finally { - setLoading(false); - } - }; - - const handleAlbumClick = (album: { - id: string; - name: string; - external_urls: string; - }) => { - logger.debug(`album clicked: ${album.name}`); - setSelectedAlbum(album); - setShowAlbumDialog(true); - }; - - const handleArtistClick = async (artist: { - id: string; - name: string; - external_urls: string; - }) => { - logger.debug(`artist clicked: ${artist.name}`); - const artistUrl = artist.external_urls.replace(/\/$/, "") + "/discography/all"; - setPendingUrl(artistUrl); - setPendingArtistName(artist.name); - setShowTimeoutDialog(true); - return artistUrl; - }; - - const handleConfirmAlbumFetch = async () => { - if (!selectedAlbum) return; - - const albumUrl = selectedAlbum.external_urls; - logger.info(`fetching album: ${selectedAlbum.name}...`); - logger.debug(`url: ${albumUrl}`); - - setShowAlbumDialog(false); - setLoading(true); - setMetadata(null); - - try { - const startTime = Date.now(); - const data = await fetchSpotifyMetadata(albumUrl); - const elapsed = ((Date.now() - startTime) / 1000).toFixed(2); - - setMetadata(data); - - if ("album_info" in data) { - logger.success(`fetched album: ${data.album_info.name}`); - logger.debug(`${data.track_list.length} tracks, released: ${data.album_info.release_date}`); - } - - logger.info(`fetch completed in ${elapsed}s`); - toast.success("Album metadata fetched successfully"); - return albumUrl; - } catch (err) { - const errorMsg = err instanceof Error ? err.message : "Failed to fetch album metadata"; - logger.error(`fetch failed: ${errorMsg}`); - toast.error(errorMsg); - } finally { - setLoading(false); - setSelectedAlbum(null); - } - }; - - return { - loading, - metadata, - showTimeoutDialog, - setShowTimeoutDialog, - timeoutValue, - setTimeoutValue, - showAlbumDialog, - setShowAlbumDialog, - selectedAlbum, - pendingArtistName, - handleFetchMetadata, - handleConfirmFetch, - handleAlbumClick, - handleConfirmAlbumFetch, - handleArtistClick, - }; + const [loading, setLoading] = useState(false); + const [metadata, setMetadata] = useState(null); + const [showTimeoutDialog, setShowTimeoutDialog] = useState(false); + const [timeoutValue, setTimeoutValue] = useState(60); + const [pendingUrl, setPendingUrl] = useState(""); + const [showAlbumDialog, setShowAlbumDialog] = useState(false); + const [selectedAlbum, setSelectedAlbum] = useState<{ + id: string; + name: string; + external_urls: string; + } | null>(null); + const [pendingArtistName, setPendingArtistName] = useState(null); + const getUrlType = (url: string): string => { + if (url.includes("/track/")) + return "track"; + if (url.includes("/album/")) + return "album"; + if (url.includes("/playlist/")) + return "playlist"; + if (url.includes("/artist/")) + return "artist"; + return "unknown"; + }; + const fetchMetadataDirectly = async (url: string) => { + const urlType = getUrlType(url); + logger.info(`fetching ${urlType} metadata...`); + logger.debug(`url: ${url}`); + setLoading(true); + setMetadata(null); + try { + const startTime = Date.now(); + const data = await fetchSpotifyMetadata(url); + const elapsed = ((Date.now() - startTime) / 1000).toFixed(2); + if ("playlist_info" in data) { + const playlistInfo = data.playlist_info; + if (!playlistInfo.owner.name && playlistInfo.tracks.total === 0 && data.track_list.length === 0) { + logger.warning("playlist appears to be empty or private"); + toast.error("Playlist not found or may be private"); + setMetadata(null); + return; + } + } + else if ("album_info" in data) { + const albumInfo = data.album_info; + if (!albumInfo.name && albumInfo.total_tracks === 0 && data.track_list.length === 0) { + logger.warning("album appears to be empty or not found"); + toast.error("Album not found or may be private"); + setMetadata(null); + return; + } + } + setMetadata(data); + if ("track" in data) { + logger.success(`fetched track: ${data.track.name} - ${data.track.artists}`); + logger.debug(`isrc: ${data.track.isrc}, duration: ${data.track.duration_ms}ms`); + } + else if ("album_info" in data) { + logger.success(`fetched album: ${data.album_info.name}`); + logger.debug(`${data.track_list.length} tracks, released: ${data.album_info.release_date}`); + } + else if ("playlist_info" in data) { + logger.success(`fetched playlist: ${data.track_list.length} tracks`); + logger.debug(`by ${data.playlist_info.owner.display_name || data.playlist_info.owner.name}`); + } + else if ("artist_info" in data) { + logger.success(`fetched artist: ${data.artist_info.name}`); + logger.debug(`${data.album_list.length} albums, ${data.track_list.length} tracks`); + } + logger.info(`fetch completed in ${elapsed}s`); + toast.success("Metadata fetched successfully"); + } + catch (err) { + const errorMsg = err instanceof Error ? err.message : "Failed to fetch metadata"; + logger.error(`fetch failed: ${errorMsg}`); + toast.error(errorMsg); + } + finally { + setLoading(false); + } + }; + const handleFetchMetadata = async (url: string) => { + if (!url.trim()) { + logger.warning("empty url provided"); + toast.error("Please enter a Spotify URL"); + return; + } + let urlToFetch = url.trim(); + const isArtistUrl = urlToFetch.includes("/artist/"); + if (isArtistUrl && !urlToFetch.includes("/discography")) { + urlToFetch = urlToFetch.replace(/\/$/, "") + "/discography/all"; + logger.debug("converted to discography url"); + } + if (isArtistUrl) { + logger.info("artist url detected, showing timeout dialog"); + setPendingUrl(urlToFetch); + setPendingArtistName(null); + setShowTimeoutDialog(true); + } + else { + await fetchMetadataDirectly(urlToFetch); + } + return urlToFetch; + }; + const handleConfirmFetch = async () => { + setShowTimeoutDialog(false); + logger.info(`fetching artist discography (timeout: ${timeoutValue}s)...`); + logger.debug(`url: ${pendingUrl}`); + setLoading(true); + setMetadata(null); + try { + const startTime = Date.now(); + const data = await fetchSpotifyMetadata(pendingUrl, true, 1.0, timeoutValue); + const elapsed = ((Date.now() - startTime) / 1000).toFixed(2); + setMetadata(data); + if ("artist_info" in data) { + logger.success(`fetched artist: ${data.artist_info.name}`); + logger.debug(`${data.album_list.length} albums, ${data.track_list.length} tracks`); + } + logger.info(`fetch completed in ${elapsed}s`); + toast.success("Metadata fetched successfully"); + } + catch (err) { + const errorMsg = err instanceof Error ? err.message : "Failed to fetch metadata"; + logger.error(`fetch failed: ${errorMsg}`); + toast.error(errorMsg); + } + finally { + setLoading(false); + } + }; + const handleAlbumClick = (album: { + id: string; + name: string; + external_urls: string; + }) => { + logger.debug(`album clicked: ${album.name}`); + setSelectedAlbum(album); + setShowAlbumDialog(true); + }; + const handleArtistClick = async (artist: { + id: string; + name: string; + external_urls: string; + }) => { + logger.debug(`artist clicked: ${artist.name}`); + const artistUrl = artist.external_urls.replace(/\/$/, "") + "/discography/all"; + setPendingUrl(artistUrl); + setPendingArtistName(artist.name); + setShowTimeoutDialog(true); + return artistUrl; + }; + const handleConfirmAlbumFetch = async () => { + if (!selectedAlbum) + return; + const albumUrl = selectedAlbum.external_urls; + logger.info(`fetching album: ${selectedAlbum.name}...`); + logger.debug(`url: ${albumUrl}`); + setShowAlbumDialog(false); + setLoading(true); + setMetadata(null); + try { + const startTime = Date.now(); + const data = await fetchSpotifyMetadata(albumUrl); + const elapsed = ((Date.now() - startTime) / 1000).toFixed(2); + if ("album_info" in data) { + const albumInfo = data.album_info; + if (!albumInfo.name && albumInfo.total_tracks === 0 && data.track_list.length === 0) { + logger.warning("album appears to be empty or not found"); + toast.error("Album not found or may be private"); + setMetadata(null); + setSelectedAlbum(null); + return albumUrl; + } + } + setMetadata(data); + if ("album_info" in data) { + logger.success(`fetched album: ${data.album_info.name}`); + logger.debug(`${data.track_list.length} tracks, released: ${data.album_info.release_date}`); + } + logger.info(`fetch completed in ${elapsed}s`); + toast.success("Album metadata fetched successfully"); + return albumUrl; + } + catch (err) { + const errorMsg = err instanceof Error ? err.message : "Failed to fetch album metadata"; + logger.error(`fetch failed: ${errorMsg}`); + toast.error(errorMsg); + } + finally { + setLoading(false); + setSelectedAlbum(null); + } + }; + return { + loading, + metadata, + showTimeoutDialog, + setShowTimeoutDialog, + timeoutValue, + setTimeoutValue, + showAlbumDialog, + setShowAlbumDialog, + selectedAlbum, + pendingArtistName, + handleFetchMetadata, + handleConfirmFetch, + handleAlbumClick, + handleConfirmAlbumFetch, + handleArtistClick, + }; } diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index c386650..c1f96c4 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -1,59 +1,43 @@ -import type { - SpotifyMetadataResponse, - DownloadRequest, - DownloadResponse, - HealthResponse, - LyricsDownloadRequest, - LyricsDownloadResponse, - CoverDownloadRequest, - CoverDownloadResponse, -} from "@/types/api"; -import { GetSpotifyMetadata, DownloadTrack, DownloadLyrics, DownloadCover } from "../../wailsjs/go/main/App"; +import type { SpotifyMetadataResponse, DownloadRequest, DownloadResponse, HealthResponse, LyricsDownloadRequest, LyricsDownloadResponse, CoverDownloadRequest, CoverDownloadResponse, HeaderDownloadRequest, HeaderDownloadResponse, GalleryImageDownloadRequest, GalleryImageDownloadResponse, AvatarDownloadRequest, AvatarDownloadResponse, } from "@/types/api"; +import { GetSpotifyMetadata, DownloadTrack, DownloadLyrics, DownloadCover, DownloadHeader, DownloadGalleryImage, DownloadAvatar } from "../../wailsjs/go/main/App"; import { main } from "../../wailsjs/go/models"; - -export async function fetchSpotifyMetadata( - url: string, - batch: boolean = true, - delay: number = 1.0, - timeout: number = 300.0 -): Promise { - const req = new main.SpotifyMetadataRequest({ - url, - batch, - delay, - timeout, - }); - - const jsonString = await GetSpotifyMetadata(req); - return JSON.parse(jsonString); +export async function fetchSpotifyMetadata(url: string, batch: boolean = true, delay: number = 1.0, timeout: number = 300.0): Promise { + const req = new main.SpotifyMetadataRequest({ + url, + batch, + delay, + timeout, + }); + const jsonString = await GetSpotifyMetadata(req); + return JSON.parse(jsonString); } - -export async function downloadTrack( - request: DownloadRequest -): Promise { - const req = new main.DownloadRequest(request); - return await DownloadTrack(req); +export async function downloadTrack(request: DownloadRequest): Promise { + const req = new main.DownloadRequest(request); + return await DownloadTrack(req); } - export async function checkHealth(): Promise { - // For Wails, we can just return a simple health check - // since the app is running locally - return { - status: "ok", - time: new Date().toISOString(), - }; + return { + status: "ok", + time: new Date().toISOString(), + }; } - -export async function downloadLyrics( - request: LyricsDownloadRequest -): Promise { - const req = new main.LyricsDownloadRequest(request); - return await DownloadLyrics(req); +export async function downloadLyrics(request: LyricsDownloadRequest): Promise { + const req = new main.LyricsDownloadRequest(request); + return await DownloadLyrics(req); } - -export async function downloadCover( - request: CoverDownloadRequest -): Promise { - const req = new main.CoverDownloadRequest(request); - return await DownloadCover(req); +export async function downloadCover(request: CoverDownloadRequest): Promise { + const req = new main.CoverDownloadRequest(request); + return await DownloadCover(req); +} +export async function downloadHeader(request: HeaderDownloadRequest): Promise { + const req = new main.HeaderDownloadRequest(request); + return await DownloadHeader(req); +} +export async function downloadGalleryImage(request: GalleryImageDownloadRequest): Promise { + const req = new main.GalleryImageDownloadRequest(request); + return await DownloadGalleryImage(req); +} +export async function downloadAvatar(request: AvatarDownloadRequest): Promise { + const req = new main.AvatarDownloadRequest(request); + return await DownloadAvatar(req); } diff --git a/frontend/src/lib/audio.ts b/frontend/src/lib/audio.ts index d7c7833..241d183 100644 --- a/frontend/src/lib/audio.ts +++ b/frontend/src/lib/audio.ts @@ -1,106 +1,71 @@ -// Audio utility for toast notifications using Web Audio API - class AudioManager { - private audioContext: AudioContext | null = null; - - private getAudioContext(): AudioContext { - if (!this.audioContext) { - this.audioContext = new (window.AudioContext || (window as any).webkitAudioContext)(); + private audioContext: AudioContext | null = null; + private getAudioContext(): AudioContext { + if (!this.audioContext) { + this.audioContext = new (window.AudioContext || (window as any).webkitAudioContext)(); + } + return this.audioContext; } - return this.audioContext; - } - - // Generate a simple tone using oscillator - private playTone(frequency: number, duration: number, type: OscillatorType = 'sine', volume: number = 0.3) { - try { - const ctx = this.getAudioContext(); - const oscillator = ctx.createOscillator(); - const gainNode = ctx.createGain(); - - oscillator.connect(gainNode); - gainNode.connect(ctx.destination); - - oscillator.frequency.value = frequency; - oscillator.type = type; - - gainNode.gain.setValueAtTime(volume, ctx.currentTime); - gainNode.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + duration); - - oscillator.start(ctx.currentTime); - oscillator.stop(ctx.currentTime + duration); - } catch (error) { - console.error('Error playing audio:', error); + private playTone(frequency: number, duration: number, type: OscillatorType = 'sine', volume: number = 0.3) { + try { + const ctx = this.getAudioContext(); + const oscillator = ctx.createOscillator(); + const gainNode = ctx.createGain(); + oscillator.connect(gainNode); + gainNode.connect(ctx.destination); + oscillator.frequency.value = frequency; + oscillator.type = type; + gainNode.gain.setValueAtTime(volume, ctx.currentTime); + gainNode.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + duration); + oscillator.start(ctx.currentTime); + oscillator.stop(ctx.currentTime + duration); + } + catch (error) { + console.error('Error playing audio:', error); + } } - } - - // Success sound - pleasant ascending tones - playSuccess() { - const ctx = this.getAudioContext(); - const now = ctx.currentTime; - - // First tone - this.playToneAt(523.25, 0.08, 'sine', 0.2, now); // C5 - // Second tone - this.playToneAt(659.25, 0.08, 'sine', 0.2, now + 0.08); // E5 - // Third tone - this.playToneAt(783.99, 0.15, 'sine', 0.25, now + 0.16); // G5 - } - - // Error sound - descending tones - playError() { - const ctx = this.getAudioContext(); - const now = ctx.currentTime; - - // First tone - this.playToneAt(392.00, 0.1, 'square', 0.15, now); // G4 - // Second tone - this.playToneAt(329.63, 0.2, 'square', 0.2, now + 0.1); // E4 - } - - // Warning sound - alternating tones - playWarning() { - const ctx = this.getAudioContext(); - const now = ctx.currentTime; - - // First tone - this.playToneAt(440.00, 0.1, 'triangle', 0.2, now); // A4 - // Second tone - this.playToneAt(493.88, 0.1, 'triangle', 0.2, now + 0.12); // B4 - } - - // Info sound - single soft tone - playInfo() { - this.playTone(523.25, 0.15, 'sine', 0.15); // C5 - } - - // Helper method to play tone at specific time - private playToneAt(frequency: number, duration: number, type: OscillatorType, volume: number, startTime: number) { - try { - const ctx = this.getAudioContext(); - const oscillator = ctx.createOscillator(); - const gainNode = ctx.createGain(); - - oscillator.connect(gainNode); - gainNode.connect(ctx.destination); - - oscillator.frequency.value = frequency; - oscillator.type = type; - - gainNode.gain.setValueAtTime(volume, startTime); - gainNode.gain.exponentialRampToValueAtTime(0.01, startTime + duration); - - oscillator.start(startTime); - oscillator.stop(startTime + duration); - } catch (error) { - console.error('Error playing audio:', error); + playSuccess() { + const ctx = this.getAudioContext(); + const now = ctx.currentTime; + this.playToneAt(523.25, 0.08, 'sine', 0.2, now); + this.playToneAt(659.25, 0.08, 'sine', 0.2, now + 0.08); + this.playToneAt(783.99, 0.15, 'sine', 0.25, now + 0.16); + } + playError() { + const ctx = this.getAudioContext(); + const now = ctx.currentTime; + this.playToneAt(392.00, 0.1, 'square', 0.15, now); + this.playToneAt(329.63, 0.2, 'square', 0.2, now + 0.1); + } + playWarning() { + const ctx = this.getAudioContext(); + const now = ctx.currentTime; + this.playToneAt(440.00, 0.1, 'triangle', 0.2, now); + this.playToneAt(493.88, 0.1, 'triangle', 0.2, now + 0.12); + } + playInfo() { + this.playTone(523.25, 0.15, 'sine', 0.15); + } + private playToneAt(frequency: number, duration: number, type: OscillatorType, volume: number, startTime: number) { + try { + const ctx = this.getAudioContext(); + const oscillator = ctx.createOscillator(); + const gainNode = ctx.createGain(); + oscillator.connect(gainNode); + gainNode.connect(ctx.destination); + oscillator.frequency.value = frequency; + oscillator.type = type; + gainNode.gain.setValueAtTime(volume, startTime); + gainNode.gain.exponentialRampToValueAtTime(0.01, startTime + duration); + oscillator.start(startTime); + oscillator.stop(startTime + duration); + } + catch (error) { + console.error('Error playing audio:', error); + } } - } } - -// Export singleton instance export const audioManager = new AudioManager(); - -// Helper functions for easy use export const playSuccessSound = () => audioManager.playSuccess(); export const playErrorSound = () => audioManager.playError(); export const playWarningSound = () => audioManager.playWarning(); diff --git a/frontend/src/lib/logger.ts b/frontend/src/lib/logger.ts index abfa69a..dbcf9e2 100644 --- a/frontend/src/lib/logger.ts +++ b/frontend/src/lib/logger.ts @@ -1,66 +1,53 @@ export type LogLevel = "info" | "success" | "warning" | "error" | "debug"; - export interface LogEntry { - timestamp: Date; - level: LogLevel; - message: string; + timestamp: Date; + level: LogLevel; + message: string; } - class Logger { - private logs: LogEntry[] = []; - private maxLogs = 500; - private listeners: Set<() => void> = new Set(); - - private addLog(level: LogLevel, message: string) { - const entry: LogEntry = { - timestamp: new Date(), - level, - message: message.toLowerCase(), - }; - this.logs.push(entry); - if (this.logs.length > this.maxLogs) { - this.logs.shift(); + private logs: LogEntry[] = []; + private maxLogs = 500; + private listeners: Set<() => void> = new Set(); + private addLog(level: LogLevel, message: string) { + const entry: LogEntry = { + timestamp: new Date(), + level, + message: message.toLowerCase(), + }; + this.logs.push(entry); + if (this.logs.length > this.maxLogs) { + this.logs.shift(); + } + this.notifyListeners(); + } + info(message: string) { + this.addLog("info", message); + } + success(message: string) { + this.addLog("success", message); + } + warning(message: string) { + this.addLog("warning", message); + } + error(message: string) { + this.addLog("error", message); + } + debug(message: string) { + this.addLog("debug", message); + } + getLogs(): LogEntry[] { + return [...this.logs]; + } + clear() { + this.logs = []; + this.notifyListeners(); + } + subscribe(listener: () => void) { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } + private notifyListeners() { + this.listeners.forEach((listener) => listener()); } - this.notifyListeners(); - } - - info(message: string) { - this.addLog("info", message); - } - - success(message: string) { - this.addLog("success", message); - } - - warning(message: string) { - this.addLog("warning", message); - } - - error(message: string) { - this.addLog("error", message); - } - - debug(message: string) { - this.addLog("debug", message); - } - - getLogs(): LogEntry[] { - return [...this.logs]; - } - - clear() { - this.logs = []; - this.notifyListeners(); - } - - subscribe(listener: () => void) { - this.listeners.add(listener); - return () => this.listeners.delete(listener); - } - - private notifyListeners() { - this.listeners.forEach((listener) => listener()); - } } - export const logger = new Logger(); diff --git a/frontend/src/lib/relative-time.ts b/frontend/src/lib/relative-time.ts index 46cb4cc..8b3fdfe 100644 --- a/frontend/src/lib/relative-time.ts +++ b/frontend/src/lib/relative-time.ts @@ -1,59 +1,57 @@ -/** - * Format a date to relative time string with max 2 units - * e.g., "23 hours 32 minutes ago", "1 day 14 hours ago" - */ export function formatRelativeTime(date: Date | string | number): string { - const now = new Date(); - const target = new Date(date); - const diffMs = now.getTime() - target.getTime(); - - if (diffMs < 0) return "just now"; - - const seconds = Math.floor(diffMs / 1000); - const minutes = Math.floor(seconds / 60); - const hours = Math.floor(minutes / 60); - const days = Math.floor(hours / 24); - const weeks = Math.floor(days / 7); - const months = Math.floor(days / 30); - const years = Math.floor(days / 365); - - const parts: string[] = []; - - if (years > 0) { - parts.push(`${years} ${years === 1 ? "year" : "years"}`); - const remainingMonths = Math.floor((days % 365) / 30); - if (remainingMonths > 0) { - parts.push(`${remainingMonths} ${remainingMonths === 1 ? "month" : "months"}`); + const now = new Date(); + const target = new Date(date); + const diffMs = now.getTime() - target.getTime(); + if (diffMs < 0) + return "just now"; + const seconds = Math.floor(diffMs / 1000); + const minutes = Math.floor(seconds / 60); + const hours = Math.floor(minutes / 60); + const days = Math.floor(hours / 24); + const weeks = Math.floor(days / 7); + const months = Math.floor(days / 30); + const years = Math.floor(days / 365); + const parts: string[] = []; + if (years > 0) { + parts.push(`${years} ${years === 1 ? "year" : "years"}`); + const remainingMonths = Math.floor((days % 365) / 30); + if (remainingMonths > 0) { + parts.push(`${remainingMonths} ${remainingMonths === 1 ? "month" : "months"}`); + } } - } else if (months > 0) { - parts.push(`${months} ${months === 1 ? "month" : "months"}`); - const remainingDays = days % 30; - if (remainingDays > 0) { - parts.push(`${remainingDays} ${remainingDays === 1 ? "day" : "days"}`); + else if (months > 0) { + parts.push(`${months} ${months === 1 ? "month" : "months"}`); + const remainingDays = days % 30; + if (remainingDays > 0) { + parts.push(`${remainingDays} ${remainingDays === 1 ? "day" : "days"}`); + } } - } else if (weeks > 0) { - parts.push(`${weeks} ${weeks === 1 ? "week" : "weeks"}`); - const remainingDays = days % 7; - if (remainingDays > 0) { - parts.push(`${remainingDays} ${remainingDays === 1 ? "day" : "days"}`); + else if (weeks > 0) { + parts.push(`${weeks} ${weeks === 1 ? "week" : "weeks"}`); + const remainingDays = days % 7; + if (remainingDays > 0) { + parts.push(`${remainingDays} ${remainingDays === 1 ? "day" : "days"}`); + } } - } else if (days > 0) { - parts.push(`${days} ${days === 1 ? "day" : "days"}`); - const remainingHours = hours % 24; - if (remainingHours > 0) { - parts.push(`${remainingHours} ${remainingHours === 1 ? "hour" : "hours"}`); + else if (days > 0) { + parts.push(`${days} ${days === 1 ? "day" : "days"}`); + const remainingHours = hours % 24; + if (remainingHours > 0) { + parts.push(`${remainingHours} ${remainingHours === 1 ? "hour" : "hours"}`); + } } - } else if (hours > 0) { - parts.push(`${hours} ${hours === 1 ? "hour" : "hours"}`); - const remainingMinutes = minutes % 60; - if (remainingMinutes > 0) { - parts.push(`${remainingMinutes} ${remainingMinutes === 1 ? "minute" : "minutes"}`); + else if (hours > 0) { + parts.push(`${hours} ${hours === 1 ? "hour" : "hours"}`); + const remainingMinutes = minutes % 60; + if (remainingMinutes > 0) { + parts.push(`${remainingMinutes} ${remainingMinutes === 1 ? "minute" : "minutes"}`); + } } - } else if (minutes > 0) { - parts.push(`${minutes} ${minutes === 1 ? "minute" : "minutes"}`); - } else { - return "just now"; - } - - return "Released " + parts.slice(0, 2).join(" ") + " ago"; + else if (minutes > 0) { + parts.push(`${minutes} ${minutes === 1 ? "minute" : "minutes"}`); + } + else { + return "just now"; + } + return "Released " + parts.slice(0, 2).join(" ") + " ago"; } diff --git a/frontend/src/lib/settings.ts b/frontend/src/lib/settings.ts index 8f13716..f1e69df 100644 --- a/frontend/src/lib/settings.ts +++ b/frontend/src/lib/settings.ts @@ -1,287 +1,269 @@ import { GetDefaults } from "../../wailsjs/go/main/App"; - 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" | "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" | "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" | "tidal" | "qobuz" | "amazon"; - theme: string; - themeMode: "auto" | "light" | "dark"; - fontFamily: FontFamily; - // New template system - folderPreset: FolderPreset; - folderTemplate: string; - filenamePreset: FilenamePreset; - filenameTemplate: string; - // Legacy settings (kept for migration) - filenameFormat?: "title-artist" | "artist-title" | "title"; - artistSubfolder?: boolean; - albumSubfolder?: boolean; - trackNumber: boolean; - sfxEnabled: boolean; - embedLyrics: boolean; - embedMaxQualityCover: boolean; - operatingSystem: "Windows" | "linux/MacOS"; - // Quality settings for specific sources - tidalQuality: "LOSSLESS" | "HI_RES_LOSSLESS"; - qobuzQuality: "6" | "7" | "27"; + downloadPath: string; + downloader: "auto" | "tidal" | "qobuz" | "amazon"; + theme: string; + themeMode: "auto" | "light" | "dark"; + fontFamily: FontFamily; + folderPreset: FolderPreset; + folderTemplate: string; + filenamePreset: FilenamePreset; + filenameTemplate: string; + filenameFormat?: "title-artist" | "artist-title" | "title"; + artistSubfolder?: boolean; + albumSubfolder?: boolean; + trackNumber: boolean; + sfxEnabled: boolean; + embedLyrics: boolean; + embedMaxQualityCover: boolean; + operatingSystem: "Windows" | "linux/MacOS"; + tidalQuality: "LOSSLESS" | "HI_RES_LOSSLESS"; + qobuzQuality: "6" | "7" | "27"; + amazonQuality: "HI_RES"; } - -// Folder preset templates -export const FOLDER_PRESETS: Record = { - "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}" }, - "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}" }, +export const FOLDER_PRESETS: Record = { + "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}" }, + "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 -export const FILENAME_PRESETS: Record = { - "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}" }, +export const FILENAME_PRESETS: Record = { + "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}" }, }; - -// Available template variables export const TEMPLATE_VARIABLES = [ - { 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: "{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" }, ]; - -// Auto-detect operating system function detectOS(): "Windows" | "linux/MacOS" { - const platform = window.navigator.platform.toLowerCase(); - if (platform.includes('win')) { - return "Windows"; - } - return "linux/MacOS"; + const platform = window.navigator.platform.toLowerCase(); + if (platform.includes('win')) { + return "Windows"; + } + return "linux/MacOS"; } - export const DEFAULT_SETTINGS: Settings = { - downloadPath: "", - downloader: "auto", - theme: "yellow", - themeMode: "auto", - fontFamily: "google-sans", - folderPreset: "none", - folderTemplate: "", - filenamePreset: "title-artist", - filenameTemplate: "{title} - {artist}", - trackNumber: false, - sfxEnabled: true, - embedLyrics: false, - embedMaxQualityCover: false, - operatingSystem: detectOS(), - tidalQuality: "LOSSLESS", // Default: 16-bit lossless - qobuzQuality: "6" // Default: FLAC 16-bit + downloadPath: "", + downloader: "auto", + theme: "yellow", + themeMode: "auto", + fontFamily: "google-sans", + folderPreset: "none", + folderTemplate: "", + filenamePreset: "title-artist", + filenameTemplate: "{title} - {artist}", + trackNumber: false, + sfxEnabled: true, + embedLyrics: false, + embedMaxQualityCover: false, + operatingSystem: detectOS(), + tidalQuality: "LOSSLESS", + qobuzQuality: "6", + amazonQuality: "HI_RES" }; - -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", fontFamily: '"Google Sans", system-ui, sans-serif' }, - { value: "inter", label: "Inter", fontFamily: '"Inter", 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' }, +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", fontFamily: '"Google Sans", system-ui, sans-serif' }, + { value: "inter", label: "Inter", fontFamily: '"Inter", 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' }, ]; - export function applyFont(fontFamily: FontFamily): void { - const font = FONT_OPTIONS.find(f => f.value === fontFamily); - if (font) { - document.documentElement.style.setProperty('--font-sans', font.fontFamily); - document.body.style.fontFamily = font.fontFamily; - } + const font = FONT_OPTIONS.find(f => f.value === fontFamily); + if (font) { + document.documentElement.style.setProperty('--font-sans', font.fontFamily); + document.body.style.fontFamily = font.fontFamily; + } } - async function fetchDefaultPath(): Promise { - try { - const data = await GetDefaults(); - return data.downloadPath || ""; - } catch (error) { - console.error("Failed to fetch default path:", error); - return ""; - } + try { + const data = await GetDefaults(); + return data.downloadPath || ""; + } + catch (error) { + console.error("Failed to fetch default path:", error); + return ""; + } } - const SETTINGS_KEY = "spotiflac-settings"; - export function getSettings(): Settings { - try { - const stored = localStorage.getItem(SETTINGS_KEY); - if (stored) { - const parsed = JSON.parse(stored); - // Migrate old darkMode to themeMode - if ('darkMode' in parsed && !('themeMode' in parsed)) { - parsed.themeMode = parsed.darkMode ? 'dark' : 'light'; - delete parsed.darkMode; - } - // Migrate old folder/filename settings to new template system - if (!('folderPreset' in parsed) && ('artistSubfolder' in parsed || 'albumSubfolder' in parsed)) { - const hasArtist = parsed.artistSubfolder; - const hasAlbum = parsed.albumSubfolder; - if (hasArtist && hasAlbum) { - parsed.folderPreset = "artist-album"; - parsed.folderTemplate = "{artist}/{album}"; - } else if (hasArtist) { - parsed.folderPreset = "artist"; - parsed.folderTemplate = "{artist}"; - } else if (hasAlbum) { - parsed.folderPreset = "album"; - parsed.folderTemplate = "{album}"; - } else { - parsed.folderPreset = "none"; - parsed.folderTemplate = ""; + try { + const stored = localStorage.getItem(SETTINGS_KEY); + if (stored) { + const parsed = JSON.parse(stored); + if ('darkMode' in parsed && !('themeMode' in parsed)) { + parsed.themeMode = parsed.darkMode ? 'dark' : 'light'; + delete parsed.darkMode; + } + if (!('folderPreset' in parsed) && ('artistSubfolder' in parsed || 'albumSubfolder' in parsed)) { + const hasArtist = parsed.artistSubfolder; + const hasAlbum = parsed.albumSubfolder; + if (hasArtist && hasAlbum) { + parsed.folderPreset = "artist-album"; + parsed.folderTemplate = "{artist}/{album}"; + } + else if (hasArtist) { + parsed.folderPreset = "artist"; + parsed.folderTemplate = "{artist}"; + } + else if (hasAlbum) { + parsed.folderPreset = "album"; + parsed.folderTemplate = "{album}"; + } + else { + parsed.folderPreset = "none"; + parsed.folderTemplate = ""; + } + } + if (!('filenamePreset' in parsed) && 'filenameFormat' in parsed) { + const format = parsed.filenameFormat; + if (format === "title-artist") { + parsed.filenamePreset = "artist-title"; + parsed.filenameTemplate = "{artist} - {title}"; + } + else if (format === "artist-title") { + parsed.filenamePreset = "artist-title"; + parsed.filenameTemplate = "{artist} - {title}"; + } + else { + parsed.filenamePreset = "title"; + parsed.filenameTemplate = "{title}"; + } + } + parsed.operatingSystem = detectOS(); + if (!('tidalQuality' in parsed)) { + parsed.tidalQuality = "LOSSLESS"; + } + if (!('qobuzQuality' in parsed)) { + parsed.qobuzQuality = "6"; + } + if (!('amazonQuality' in parsed)) { + parsed.amazonQuality = "HI_RES"; + } + return { ...DEFAULT_SETTINGS, ...parsed }; } - } - if (!('filenamePreset' in parsed) && 'filenameFormat' in parsed) { - const format = parsed.filenameFormat; - if (format === "title-artist") { - parsed.filenamePreset = "artist-title"; - parsed.filenameTemplate = "{artist} - {title}"; - } else if (format === "artist-title") { - parsed.filenamePreset = "artist-title"; - parsed.filenameTemplate = "{artist} - {title}"; - } else { - parsed.filenamePreset = "title"; - parsed.filenameTemplate = "{title}"; - } - } - // 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) { - console.error("Failed to load settings:", error); - } - return DEFAULT_SETTINGS; + catch (error) { + console.error("Failed to load settings:", error); + } + return DEFAULT_SETTINGS; } - -// Parse template and replace variables with actual values export interface TemplateData { - artist?: string; - album?: string; - album_artist?: string; - title?: string; - track?: number; - disc?: number; - year?: string; - isrc?: string; - playlist?: string; + artist?: string; + album?: string; + album_artist?: string; + title?: string; + track?: number; + disc?: number; + year?: string; + playlist?: string; } - export function parseTemplate(template: string, data: TemplateData): string { - if (!template) return ""; - - 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(/\{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 || ""); - - return result; + if (!template) + return ""; + let result = template; + 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(/\{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(/\{playlist\}/g, data.playlist || ""); + return result; } - export async function getSettingsWithDefaults(): Promise { - const settings = getSettings(); - - // If downloadPath is empty, fetch from backend - if (!settings.downloadPath) { - settings.downloadPath = await fetchDefaultPath(); - } - - return settings; -} - -export function saveSettings(settings: Settings): void { - try { - localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings)); - } catch (error) { - console.error("Failed to save settings:", error); - } -} - -export function updateSettings(partial: Partial): Settings { - const current = getSettings(); - const updated = { ...current, ...partial }; - saveSettings(updated); - return updated; -} - -export async function resetToDefaultSettings(): Promise { - const defaultPath = await fetchDefaultPath(); - const defaultSettings = { ...DEFAULT_SETTINGS, downloadPath: defaultPath }; - saveSettings(defaultSettings); - return defaultSettings; -} - -export function applyThemeMode(mode: "auto" | "light" | "dark"): void { - if (mode === "auto") { - // Check system preference - const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches; - if (prefersDark) { - document.documentElement.classList.add("dark"); - } else { - document.documentElement.classList.remove("dark"); + const settings = getSettings(); + if (!settings.downloadPath) { + settings.downloadPath = await fetchDefaultPath(); + } + return settings; +} +export function saveSettings(settings: Settings): void { + try { + localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings)); + } + catch (error) { + console.error("Failed to save settings:", error); + } +} +export function updateSettings(partial: Partial): Settings { + const current = getSettings(); + const updated = { ...current, ...partial }; + saveSettings(updated); + return updated; +} +export async function resetToDefaultSettings(): Promise { + const defaultPath = await fetchDefaultPath(); + const defaultSettings = { ...DEFAULT_SETTINGS, downloadPath: defaultPath }; + saveSettings(defaultSettings); + return defaultSettings; +} +export function applyThemeMode(mode: "auto" | "light" | "dark"): void { + if (mode === "auto") { + const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches; + if (prefersDark) { + document.documentElement.classList.add("dark"); + } + else { + document.documentElement.classList.remove("dark"); + } + } + else if (mode === "dark") { + document.documentElement.classList.add("dark"); + } + else { + document.documentElement.classList.remove("dark"); } - } else if (mode === "dark") { - document.documentElement.classList.add("dark"); - } else { - document.documentElement.classList.remove("dark"); - } } diff --git a/frontend/src/lib/spectrum-cache.ts b/frontend/src/lib/spectrum-cache.ts index 313b6cc..65f200b 100644 --- a/frontend/src/lib/spectrum-cache.ts +++ b/frontend/src/lib/spectrum-cache.ts @@ -1,21 +1,15 @@ -// Memory cache for spectrum data (fast access, cleared on page refresh) -// Key: file path, Value: spectrum data - const spectrumCache = new Map(); - export function setSpectrumCache(filePath: string, spectrumData: any): void { - spectrumCache.set(filePath, spectrumData); + spectrumCache.set(filePath, spectrumData); } - export function getSpectrumCache(filePath: string): any | null { - return spectrumCache.get(filePath) || null; + return spectrumCache.get(filePath) || null; } - export function clearSpectrumCache(filePath?: string): void { - if (filePath) { - spectrumCache.delete(filePath); - } else { - spectrumCache.clear(); - } + if (filePath) { + spectrumCache.delete(filePath); + } + else { + spectrumCache.clear(); + } } - diff --git a/frontend/src/lib/themes.ts b/frontend/src/lib/themes.ts index 0ac9895..f94086d 100644 --- a/frontend/src/lib/themes.ts +++ b/frontend/src/lib/themes.ts @@ -1,290 +1,275 @@ export interface Theme { - name: string; - label: string; - cssVars: { - light: Record; - dark: Record; - }; -} - -// Base colors yang sama untuk semua tema (kecuali primary dan primary-foreground) -const baseLightColors: Record = { - 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 = { - 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 = { - 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, + name: string; + label: string; cssVars: { - light: { ...baseLightColors, ...primary.light }, - dark: { ...baseDarkColors, ...primary.dark }, + light: Record; + dark: Record; + }; +} +const baseLightColors: Record = { + 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 = { + 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)", +}; +interface PrimaryColors { + light: { + primary: string; + "primary-foreground": string; + }; + dark: { + primary: string; + "primary-foreground": string; + }; +} +const primaryColors: Record = { + 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)", + }, + }, +}; +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[] = [ - 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), + 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]; - const root = document.documentElement; - const isDark = root.classList.contains("dark"); - const vars = isDark ? theme.cssVars.dark : theme.cssVars.light; - - Object.entries(vars).forEach(([key, value]) => { - root.style.setProperty(`--${key}`, value); - }); + const theme = themes.find((t) => t.name === themeName) || themes[0]; + const root = document.documentElement; + const isDark = root.classList.contains("dark"); + const vars = isDark ? theme.cssVars.dark : theme.cssVars.light; + Object.entries(vars).forEach(([key, value]) => { + root.style.setProperty(`--${key}`, value); + }); } diff --git a/frontend/src/lib/toast-with-sound.ts b/frontend/src/lib/toast-with-sound.ts index 840eb50..1fcb30b 100644 --- a/frontend/src/lib/toast-with-sound.ts +++ b/frontend/src/lib/toast-with-sound.ts @@ -1,55 +1,45 @@ import { toast } from "sonner"; -import { - playSuccessSound, - playErrorSound, - playWarningSound, - playInfoSound, -} from "./audio"; +import { playSuccessSound, playErrorSound, playWarningSound, playInfoSound, } from "./audio"; import { logger } from "./logger"; import { getSettings } from "./settings"; - const toastStyle = { - className: "font-mono lowercase", + className: "font-mono lowercase", }; - -// Helper to check if SFX is enabled const isSfxEnabled = () => getSettings().sfxEnabled; - -// Wrapper functions for toast with sound effects export const toastWithSound = { - success: (message: string, data?: any) => { - const msg = message.toLowerCase(); - logger.success(msg); - if (isSfxEnabled()) playSuccessSound(); - return toast.success(msg, { ...toastStyle, ...data }); - }, - - error: (message: string, data?: any) => { - const msg = message.toLowerCase(); - logger.error(msg); - if (isSfxEnabled()) playErrorSound(); - return toast.error(msg, { ...toastStyle, ...data }); - }, - - warning: (message: string, data?: any) => { - const msg = message.toLowerCase(); - logger.warning(msg); - if (isSfxEnabled()) playWarningSound(); - return toast.warning(msg, { ...toastStyle, ...data }); - }, - - info: (message: string, data?: any) => { - const msg = message.toLowerCase(); - logger.info(msg); - if (isSfxEnabled()) playInfoSound(); - return toast.info(msg, { ...toastStyle, ...data }); - }, - - // Default toast without specific type - message: (message: string, data?: any) => { - const msg = message.toLowerCase(); - logger.info(msg); - if (isSfxEnabled()) playInfoSound(); - return toast(msg, { ...toastStyle, ...data }); - }, + success: (message: string, data?: any) => { + const msg = message.toLowerCase(); + logger.success(msg); + if (isSfxEnabled()) + playSuccessSound(); + return toast.success(msg, { ...toastStyle, ...data }); + }, + error: (message: string, data?: any) => { + const msg = message.toLowerCase(); + logger.error(msg); + if (isSfxEnabled()) + playErrorSound(); + return toast.error(msg, { ...toastStyle, ...data }); + }, + warning: (message: string, data?: any) => { + const msg = message.toLowerCase(); + logger.warning(msg); + if (isSfxEnabled()) + playWarningSound(); + return toast.warning(msg, { ...toastStyle, ...data }); + }, + info: (message: string, data?: any) => { + const msg = message.toLowerCase(); + logger.info(msg); + if (isSfxEnabled()) + playInfoSound(); + return toast.info(msg, { ...toastStyle, ...data }); + }, + message: (message: string, data?: any) => { + const msg = message.toLowerCase(); + logger.info(msg); + if (isSfxEnabled()) + playInfoSound(); + return toast(msg, { ...toastStyle, ...data }); + }, }; diff --git a/frontend/src/lib/utils.ts b/frontend/src/lib/utils.ts index e2a5307..25d4548 100644 --- a/frontend/src/lib/utils.ts +++ b/frontend/src/lib/utils.ts @@ -1,60 +1,48 @@ -import { clsx, type ClassValue } from "clsx" -import { twMerge } from "tailwind-merge" -import { BrowserOpenURL } from "../../wailsjs/runtime/runtime" +import { clsx, type ClassValue } from "clsx"; +import { twMerge } from "tailwind-merge"; +import { BrowserOpenURL } from "../../wailsjs/runtime/runtime"; import type { Settings } from "./settings"; - export function cn(...inputs: ClassValue[]) { - return twMerge(clsx(inputs)) + return twMerge(clsx(inputs)); } - - export function sanitizePath(input: string, os: string): string { - let sanitized = input.trim(); // it will trim whitespace - if (os === "Windows") { - return input.replace(/[<>:"/\\|?*]/g, "_"); - } - - // unix-based OS - return input.replace(/\//g, "_"); -} - -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) - if (i === 0) { - return p.replace(/[/\\]+$/g, ""); - } - // For other parts, remove both leading and trailing slashes - return p.replace(/^[/\\]+|[/\\]+$/g, ""); - }) - .filter(Boolean) // Remove empty strings after trimming - .join(sep); - - return joined; -} - -export function buildOutputPath(settings: Settings, folder?: string) { - const os = settings.operatingSystem; - - const base = settings.downloadPath || ""; - const sanitized = folder ? sanitizePath(folder, os) : undefined; - - return sanitized ? joinPath(os, base, sanitized) : base; -} - -export function openExternal(url: string) { - if (!url) return; - try { - BrowserOpenURL(url); - } catch (error) { - if (typeof window !== "undefined") { - window.open(url, "_blank", "noopener,noreferrer"); + let sanitized = input.trim(); + if (os === "Windows") { + return sanitized.replace(/[<>:"/\\|?*]/g, "_"); + } + return sanitized.replace(/\//g, "_"); +} +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) => { + if (i === 0) { + return p.replace(/[/\\]+$/g, ""); + } + return p.replace(/^[/\\]+|[/\\]+$/g, ""); + }) + .filter(Boolean) + .join(sep); + return joined; +} +export function buildOutputPath(settings: Settings, folder?: string) { + const os = settings.operatingSystem; + const base = settings.downloadPath || ""; + const sanitized = folder ? sanitizePath(folder, os) : undefined; + return sanitized ? joinPath(os, base, sanitized) : base; +} +export function openExternal(url: string) { + if (!url) + return; + try { + BrowserOpenURL(url); + } + catch (error) { + if (typeof window !== "undefined") { + window.open(url, "_blank", "noopener,noreferrer"); + } } - } } diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index d9181e8..bccce25 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -3,10 +3,7 @@ import { createRoot } from "react-dom/client"; import "./index.css"; import App from "./App.tsx"; import { Toaster } from "@/components/ui/sonner"; - -createRoot(document.getElementById("root")!).render( - +createRoot(document.getElementById("root")!).render( - - -); + + ); diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts index d76e7ad..7528ce2 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -1,249 +1,273 @@ export interface ArtistSimple { - id: string; - name: string; - external_urls: string; -} - -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; - spotify_id?: string; - album_id?: string; - album_url?: string; - artist_id?: string; - artist_url?: string; - artists_data?: ArtistSimple[]; -} - -export interface TrackResponse { - track: TrackMetadata; -} - -export interface AlbumInfo { - total_tracks: number; - name: string; - release_date: string; - artists: string; - images: string; - batch?: string; -} - -export interface AlbumResponse { - album_info: AlbumInfo; - track_list: TrackMetadata[]; -} - -export interface PlaylistInfo { - tracks: { - total: number; - }; - followers: { - total: number; - }; - owner: { - display_name: string; + id: string; name: string; + external_urls: string; +} +export interface TrackMetadata { + artists: string; + name: string; + album_name: string; + album_artist?: string; + duration_ms: number; images: string; - }; - batch?: string; + release_date: string; + track_number: number; + total_tracks?: number; + total_discs?: number; + disc_number?: number; + external_urls: string; + isrc: string; + album_type?: string; + spotify_id?: string; + album_id?: string; + album_url?: string; + artist_id?: string; + artist_url?: string; + artists_data?: ArtistSimple[]; + copyright?: string; + publisher?: string; + plays?: string; + status?: string; +} +export interface TrackResponse { + track: TrackMetadata; +} +export interface AlbumInfo { + total_tracks: number; + name: string; + release_date: string; + artists: string; + images: string; + batch?: string; +} +export interface AlbumResponse { + album_info: AlbumInfo; + track_list: TrackMetadata[]; +} +export interface PlaylistInfo { + tracks: { + total: number; + }; + followers: { + total: number; + }; + owner: { + display_name: string; + name: string; + images: string; + }; + cover?: string; + description?: string; + batch?: string; } - export interface PlaylistResponse { - playlist_info: PlaylistInfo; - track_list: TrackMetadata[]; + playlist_info: PlaylistInfo; + track_list: TrackMetadata[]; } - export interface ArtistInfo { - name: string; - followers: number; - genres: string[]; - images: string; - external_urls: string; - discography_type: string; - total_albums: number; - batch?: string; -} - -export interface DiscographyAlbum { - id: string; - name: string; - album_type: string; - release_date: string; - total_tracks: number; - artists: string; - images: string; - external_urls: string; -} - -export interface ArtistDiscographyResponse { - artist_info: ArtistInfo; - album_list: DiscographyAlbum[]; - track_list: TrackMetadata[]; -} - -export interface ArtistResponse { - artist: { name: string; followers: number; genres: string[]; images: string; + header?: string; + gallery?: string[]; external_urls: string; - popularity: number; - }; + discography_type: string; + total_albums: number; + biography?: string; + verified?: boolean; + listeners?: number; + rank?: number; + batch?: string; } - -export type SpotifyMetadataResponse = - | TrackResponse - | AlbumResponse - | PlaylistResponse - | ArtistDiscographyResponse - | ArtistResponse; - +export interface DiscographyAlbum { + id: string; + name: string; + album_type: string; + release_date: string; + total_tracks: number; + artists: string; + images: string; + external_urls: string; +} +export interface ArtistDiscographyResponse { + artist_info: ArtistInfo; + album_list: DiscographyAlbum[]; + track_list: TrackMetadata[]; +} +export interface ArtistResponse { + artist: { + name: string; + followers: number; + genres: string[]; + images: string; + external_urls: string; + popularity: number; + }; +} +export type SpotifyMetadataResponse = TrackResponse | AlbumResponse | PlaylistResponse | ArtistDiscographyResponse | ArtistResponse; export interface DownloadRequest { - isrc: string; - 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; - folder_name?: string; - filename_format?: string; - track_number?: boolean; - 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 + isrc: string; + service: "tidal" | "qobuz" | "amazon"; + query?: string; + track_name?: string; + artist_name?: string; + album_name?: string; + album_artist?: string; + release_date?: string; + cover_url?: string; + api_url?: string; + output_dir?: string; + audio_format?: string; + folder_name?: string; + filename_format?: string; + track_number?: boolean; + position?: number; + use_album_track_number?: boolean; + spotify_id?: string; + embed_lyrics?: boolean; + embed_max_quality_cover?: boolean; + service_url?: string; + duration?: number; + item_id?: string; + spotify_track_number?: number; + spotify_disc_number?: number; + spotify_total_tracks?: number; + spotify_total_discs?: number; + copyright?: string; + publisher?: string; + spotify_url?: string; } - export interface DownloadResponse { - success: boolean; - message: string; - file?: string; - error?: string; - already_exists?: boolean; - item_id?: string; // Queue item ID for tracking + success: boolean; + message: string; + file?: string; + error?: string; + already_exists?: boolean; + item_id?: string; } - export interface HealthResponse { - status: string; - time: string; + status: string; + time: string; } - export interface TimeSlice { - time: number; - magnitudes: number[]; + time: number; + magnitudes: number[]; } - export interface SpectrumData { - time_slices: TimeSlice[]; - sample_rate: number; - freq_bins: number; - duration: number; - max_freq: number; + time_slices: TimeSlice[]; + sample_rate: number; + freq_bins: number; + duration: number; + max_freq: number; } - export interface AnalysisResult { - file_path: string; - file_size: number; - sample_rate: number; - channels: number; - bits_per_sample: number; - total_samples: number; - duration: number; - bit_depth: string; - dynamic_range: number; - peak_amplitude: number; - rms_level: number; - spectrum?: SpectrumData; + file_path: string; + file_size: number; + sample_rate: number; + channels: number; + bits_per_sample: number; + total_samples: number; + duration: number; + bit_depth: string; + dynamic_range: number; + peak_amplitude: number; + rms_level: number; + spectrum?: SpectrumData; } - export interface LyricsDownloadRequest { - spotify_id: string; - track_name: string; - artist_name: string; - album_name?: string; - album_artist?: string; - release_date?: string; - output_dir?: string; - filename_format?: string; - track_number?: boolean; - position?: number; - use_album_track_number?: boolean; - disc_number?: number; + spotify_id: string; + track_name: string; + artist_name: string; + album_name?: string; + album_artist?: string; + release_date?: string; + output_dir?: string; + filename_format?: string; + track_number?: boolean; + position?: number; + use_album_track_number?: boolean; + disc_number?: number; } - export interface LyricsDownloadResponse { - success: boolean; - message: string; - file?: string; - error?: string; - already_exists?: boolean; + success: boolean; + message: string; + file?: string; + error?: string; + already_exists?: boolean; } - export interface TrackAvailability { - spotify_id: string; - tidal: boolean; - amazon: boolean; - qobuz: boolean; - tidal_url?: string; - amazon_url?: string; - qobuz_url?: string; + spotify_id: string; + tidal: boolean; + amazon: boolean; + qobuz: boolean; + tidal_url?: string; + amazon_url?: string; + qobuz_url?: string; } - export interface CoverDownloadRequest { - cover_url: string; - track_name: string; - artist_name: string; - album_name?: string; - album_artist?: string; - release_date?: string; - output_dir?: string; - filename_format?: string; - track_number?: boolean; - position?: number; - disc_number?: number; + cover_url: string; + track_name: string; + artist_name: string; + album_name?: string; + album_artist?: string; + release_date?: string; + output_dir?: string; + filename_format?: string; + track_number?: boolean; + position?: number; + disc_number?: number; } - export interface CoverDownloadResponse { - success: boolean; - message: string; - file?: string; - error?: string; - already_exists?: boolean; + success: boolean; + message: string; + file?: string; + error?: string; + already_exists?: boolean; +} +export interface HeaderDownloadRequest { + header_url: string; + artist_name: string; + output_dir?: string; +} +export interface HeaderDownloadResponse { + success: boolean; + message: string; + file?: string; + error?: string; + already_exists?: boolean; +} +export interface GalleryImageDownloadRequest { + image_url: string; + artist_name: string; + image_index: number; + output_dir?: string; +} +export interface GalleryImageDownloadResponse { + success: boolean; + message: string; + file?: string; + error?: string; + already_exists?: boolean; +} +export interface AvatarDownloadRequest { + avatar_url: string; + artist_name: string; + output_dir?: string; +} +export interface AvatarDownloadResponse { + success: boolean; + message: string; + file?: string; + error?: string; + already_exists?: boolean; } - - - export interface AudioMetadata { - title: string; - artist: string; - album: string; - album_artist: string; - track_number: number; - disc_number: number; - year: string; + title: string; + artist: string; + album: string; + album_artist: string; + track_number: number; + disc_number: number; + year: string; } diff --git a/main.go b/main.go index 28af173..964f6d3 100644 --- a/main.go +++ b/main.go @@ -14,10 +14,9 @@ import ( var assets embed.FS func main() { - // Create an instance of the app structure + app := NewApp() - // Create application with options err := wails.Run(&options.App{ Title: "SpotiFLAC", Width: 1024, diff --git a/wails.json b/wails.json index 8a1e29f..bf0bb68 100644 --- a/wails.json +++ b/wails.json @@ -12,7 +12,9 @@ }, "info": { "productName": "SpotiFLAC", - "productVersion": "7.0" + "productVersion": "7.0.1", + "copyright": "© 2026 afkarxyz", + "comments": "Get Spotify tracks in true FLAC from Tidal, Qobuz & Amazon Music — no account required." }, "wailsjsdir": "./frontend", "assetdir": "./frontend/dist",