diff --git a/app.go b/app.go index 5d5d319..66b4c2f 100644 --- a/app.go +++ b/app.go @@ -8,7 +8,6 @@ import ( "os" "path/filepath" - "regexp" "spotiflac/backend" "strings" @@ -17,12 +16,6 @@ import ( "github.com/wailsapp/wails/v2/pkg/runtime" ) -var isrcRegex = regexp.MustCompile(`^[A-Z]{2}[A-Z0-9]{3}\d{2}\d{5}$`) - -func isValidISRC(isrc string) bool { - return isrcRegex.MatchString(isrc) -} - type App struct { ctx context.Context } @@ -31,6 +24,19 @@ func NewApp() *App { return &App{} } +func (a *App) getFirstArtist(artistString string) string { + if artistString == "" { + return "" + } + delimiters := []string{", ", " & ", " feat. ", " ft. ", " featuring "} + for _, d := range delimiters { + if idx := strings.Index(strings.ToLower(artistString), d); idx != -1 { + return strings.TrimSpace(artistString[:idx]) + } + } + return artistString +} + func (a *App) startup(ctx context.Context) { a.ctx = ctx @@ -51,7 +57,6 @@ type SpotifyMetadataRequest struct { } type DownloadRequest struct { - ISRC string `json:"isrc"` Service string `json:"service"` Query string `json:"query,omitempty"` TrackName string `json:"track_name,omitempty"` @@ -82,6 +87,7 @@ type DownloadRequest struct { PlaylistName string `json:"playlist_name,omitempty"` PlaylistOwner string `json:"playlist_owner,omitempty"` AllowFallback bool `json:"allow_fallback"` + UseFirstArtistOnly bool `json:"use_first_artist_only,omitempty"` } type DownloadResponse struct { @@ -210,7 +216,7 @@ func (a *App) SearchSpotifyByType(req SpotifySearchByTypeRequest) ([]backend.Sea func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) { - if req.Service == "qobuz" && req.ISRC == "" && req.SpotifyID == "" { + if req.Service == "qobuz" && req.SpotifyID == "" { return DownloadResponse{ Success: false, Error: "Spotify ID is required for Qobuz", @@ -326,89 +332,72 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) { } } + lyricsChan := make(chan string, 1) + isrcChan := make(chan string, 1) + + if req.SpotifyID != "" { + if req.EmbedLyrics { + go func() { + client := backend.NewLyricsClient() + resp, _, err := client.FetchLyricsAllSources(req.SpotifyID, req.TrackName, req.ArtistName, req.Duration) + if err == nil && resp != nil && len(resp.Lines) > 0 { + lrc := client.ConvertToLRC(resp, req.TrackName, req.ArtistName) + lyricsChan <- lrc + } else { + lyricsChan <- "" + } + }() + } else { + close(lyricsChan) + } + + go func() { + client := backend.NewSongLinkClient() + isrc, _ := client.GetISRC(req.SpotifyID) + isrcChan <- isrc + }() + } else { + close(lyricsChan) + close(isrcChan) + } + switch req.Service { case "amazon": + downloader := backend.NewAmazonDownloader() if req.ServiceURL != "" { - filename, err = downloader.DownloadByURL(req.ServiceURL, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.PlaylistName, req.PlaylistOwner, 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) + filename, err = downloader.DownloadByURL(req.ServiceURL, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.PlaylistName, req.PlaylistOwner, 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, req.UseFirstArtistOnly) } else { - if req.SpotifyID == "" { - return DownloadResponse{ - Success: false, - Error: "Spotify ID is required for Amazon Music", - }, fmt.Errorf("spotify ID is required for Amazon Music") - } - filename, err = downloader.DownloadBySpotifyID(req.SpotifyID, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.PlaylistName, req.PlaylistOwner, 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) + filename, err = downloader.DownloadBySpotifyID(req.SpotifyID, req.OutputDir, req.AudioFormat, req.FilenameFormat, req.PlaylistName, req.PlaylistOwner, 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, req.UseFirstArtistOnly) } case "tidal": if req.ApiURL == "" || req.ApiURL == "auto" { downloader := backend.NewTidalDownloader("") if req.ServiceURL != "" { - 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, req.AllowFallback) + 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, req.AllowFallback, req.UseFirstArtistOnly) } else { - if req.SpotifyID == "" { - return DownloadResponse{ - Success: false, - Error: "Spotify ID is required for Tidal", - }, fmt.Errorf("spotify ID is required for Tidal") - } - 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, req.AllowFallback) + 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, req.AllowFallback, req.UseFirstArtistOnly) } } else { downloader := backend.NewTidalDownloader(req.ApiURL) if req.ServiceURL != "" { - 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, req.AllowFallback) + 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, req.AllowFallback, req.UseFirstArtistOnly) } else { - if req.SpotifyID == "" { - return DownloadResponse{ - Success: false, - Error: "Spotify ID is required for Tidal", - }, fmt.Errorf("spotify ID is required for Tidal") - } - 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, req.AllowFallback) + 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, req.AllowFallback, req.UseFirstArtistOnly) } } case "qobuz": - downloader := backend.NewQobuzDownloader() + fmt.Println("Waiting for ISRC (Qobuz dependency)...") + isrc := <-isrcChan + downloader := backend.NewQobuzDownloader() quality := req.AudioFormat if quality == "" { quality = "6" } - - deezerISRC := req.ISRC - - if len(deezerISRC) != 12 || !isValidISRC(deezerISRC) { - deezerISRC = "" - } - - 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, req.AllowFallback) + filename, err = downloader.DownloadTrackWithISRC(isrc, req.SpotifyID, 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, req.AllowFallback, req.UseFirstArtistOnly) default: return DownloadResponse{ @@ -443,53 +432,30 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) { filename = strings.TrimPrefix(filename, "EXISTS:") } - if !alreadyExists && req.SpotifyID != "" && req.EmbedLyrics && strings.HasSuffix(filename, ".flac") { - go func(filePath, spotifyID, trackName, artistName string) { - fmt.Printf("\n========== LYRICS FETCH START ==========\n") - fmt.Printf("Spotify ID: %s\n", spotifyID) - fmt.Printf("Track: %s\n", trackName) - fmt.Printf("Artist: %s\n", artistName) - fmt.Println("Searching all sources...") - - lyricsClient := backend.NewLyricsClient() - - 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") - return - } - - if lyricsResp == nil || len(lyricsResp.Lines) == 0 { - fmt.Println("No lyrics content found") - fmt.Printf("========== LYRICS FETCH END (FAILED) ==========\n\n") - return - } - - fmt.Printf("Lyrics found from: %s\n", source) - fmt.Printf("Sync type: %s\n", lyricsResp.SyncType) - fmt.Printf("Total lines: %d\n", len(lyricsResp.Lines)) - - lyrics := lyricsClient.ConvertToLRC(lyricsResp, trackName, artistName) - if lyrics == "" { - fmt.Println("No lyrics content to embed") - fmt.Printf("========== LYRICS FETCH END (FAILED) ==========\n\n") - return - } - + if !alreadyExists && req.SpotifyID != "" && req.EmbedLyrics && (strings.HasSuffix(filename, ".flac") || strings.HasSuffix(filename, ".mp3") || strings.HasSuffix(filename, ".m4a")) { + fmt.Printf("\nWaiting for lyrics fetch to complete...\n") + lyrics := <-lyricsChan + if lyrics != "" { fmt.Printf("\n--- Full LRC Content ---\n") fmt.Println(lyrics) fmt.Printf("--- End LRC Content ---\n\n") - fmt.Printf("Embedding into: %s\n", filePath) - if err := backend.EmbedLyricsOnly(filePath, lyrics); err != nil { + fmt.Printf("Embedding into: %s\n", filename) + + if err := backend.EmbedLyricsOnlyUniversal(filename, lyrics); err != nil { fmt.Printf("Failed to embed lyrics: %v\n", err) - fmt.Printf("========== LYRICS FETCH END (FAILED) ==========\n\n") } else { fmt.Printf("Lyrics embedded successfully!\n") - fmt.Printf("========== LYRICS FETCH END (SUCCESS) ==========\n\n") } - }(filename, req.SpotifyID, req.TrackName, req.ArtistName) + } else { + fmt.Println("No lyrics found to embed.") + } + } else { + + select { + case <-lyricsChan: + default: + } } message := "Download completed successfully" @@ -599,9 +565,9 @@ func (a *App) ClearAllDownloads() { backend.ClearAllDownloads() } -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) +func (a *App) AddToDownloadQueue(spotifyID, trackName, artistName, albumName string) string { + itemID := fmt.Sprintf("%s-%d", spotifyID, time.Now().UnixNano()) + backend.AddToQueue(itemID, trackName, artistName, albumName, "") return itemID } @@ -644,11 +610,9 @@ func (a *App) ExportFailedDownloads() (string, error) { failedItems = append(failedItems, line) failedItems = append(failedItems, fmt.Sprintf(" Error: %s", item.ErrorMessage)) - if item.ISRC != "" { - failedItems = append(failedItems, fmt.Sprintf(" ID: %s", item.ISRC)) - if !strings.HasPrefix(item.ISRC, "http") { - failedItems = append(failedItems, fmt.Sprintf(" URL: https://open.spotify.com/track/%s", item.ISRC)) - } + if item.SpotifyID != "" { + failedItems = append(failedItems, fmt.Sprintf(" ID: %s", item.SpotifyID)) + failedItems = append(failedItems, fmt.Sprintf(" URL: https://open.spotify.com/track/%s", item.SpotifyID)) } failedItems = append(failedItems, "") } @@ -979,13 +943,13 @@ func (a *App) DownloadAvatar(req AvatarDownloadRequest) (backend.AvatarDownloadR return *resp, nil } -func (a *App) CheckTrackAvailability(spotifyTrackID string, isrc string) (string, error) { +func (a *App) CheckTrackAvailability(spotifyTrackID string) (string, error) { if spotifyTrackID == "" { return "", fmt.Errorf("spotify track ID is required") } client := backend.NewSongLinkClient() - availability, err := client.CheckTrackAvailability(spotifyTrackID, isrc) + availability, err := client.CheckTrackAvailability(spotifyTrackID) if err != nil { return "", err } diff --git a/backend/amazon.go b/backend/amazon.go index d1ed2de..4d79c34 100644 --- a/backend/amazon.go +++ b/backend/amazon.go @@ -261,7 +261,7 @@ func (a *AmazonDownloader) DownloadFromService(amazonURL, outputDir, quality str return a.DownloadFromAfkarXYZ(amazonURL, outputDir, quality) } -func (a *AmazonDownloader) DownloadByURL(amazonURL, outputDir, quality, filenameFormat, playlistName, playlistOwner 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) { +func (a *AmazonDownloader) DownloadByURL(amazonURL, outputDir, quality, filenameFormat, playlistName, playlistOwner string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, spotifyCoverURL string, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int, embedMaxQualityCover bool, spotifyTotalDiscs int, spotifyCopyright, spotifyPublisher, spotifyURL string, useFirstArtistOnly bool) (string, error) { if outputDir != "." { if err := os.MkdirAll(outputDir, 0755); err != nil { @@ -270,7 +270,13 @@ func (a *AmazonDownloader) DownloadByURL(amazonURL, outputDir, quality, filename } if spotifyTrackName != "" && spotifyArtistName != "" { - expectedFilename := BuildExpectedFilename(spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, filenameFormat, playlistName, playlistOwner, includeTrackNumber, position, spotifyDiscNumber, false) + filenameArtist := spotifyArtistName + filenameAlbumArtist := spotifyAlbumArtist + if useFirstArtistOnly { + filenameArtist = GetFirstArtist(spotifyArtistName) + filenameAlbumArtist = GetFirstArtist(spotifyAlbumArtist) + } + expectedFilename := BuildExpectedFilename(spotifyTrackName, filenameArtist, spotifyAlbumName, filenameAlbumArtist, spotifyReleaseDate, filenameFormat, playlistName, playlistOwner, includeTrackNumber, position, spotifyDiscNumber, false) expectedPath := filepath.Join(outputDir, expectedFilename) if fileInfo, err := os.Stat(expectedPath); err == nil && fileInfo.Size() > 0 { @@ -279,6 +285,26 @@ func (a *AmazonDownloader) DownloadByURL(amazonURL, outputDir, quality, filename } } + isrcChan := make(chan string, 1) + if spotifyURL != "" { + go func() { + var isrc string + parts := strings.Split(spotifyURL, "/") + if len(parts) > 0 { + sID := strings.Split(parts[len(parts)-1], "?")[0] + if sID != "" { + client := NewSongLinkClient() + if val, err := client.GetISRC(sID); err == nil { + isrc = val + } + } + } + isrcChan <- isrc + }() + } else { + close(isrcChan) + } + fmt.Printf("Using Amazon URL: %s\n", amazonURL) filePath, err := a.DownloadFromService(amazonURL, outputDir, quality) @@ -286,14 +312,25 @@ func (a *AmazonDownloader) DownloadByURL(amazonURL, outputDir, quality, filename return "", err } + var isrc string + if spotifyURL != "" { + isrc = <-isrcChan + } + originalFileDir := filepath.Dir(filePath) originalFileBase := strings.TrimSuffix(filepath.Base(filePath), filepath.Ext(filePath)) if spotifyTrackName != "" && spotifyArtistName != "" { safeArtist := sanitizeFilename(spotifyArtistName) + safeAlbumArtist := sanitizeFilename(spotifyAlbumArtist) + + if useFirstArtistOnly { + safeArtist = sanitizeFilename(GetFirstArtist(spotifyArtistName)) + safeAlbumArtist = sanitizeFilename(GetFirstArtist(spotifyAlbumArtist)) + } + safeTitle := sanitizeFilename(spotifyTrackName) safeAlbum := sanitizeFilename(spotifyAlbumName) - safeAlbumArtist := sanitizeFilename(spotifyAlbumArtist) year := "" if len(spotifyReleaseDate) >= 4 { @@ -390,6 +427,7 @@ func (a *AmazonDownloader) DownloadByURL(amazonURL, outputDir, quality, filename Copyright: spotifyCopyright, Publisher: spotifyPublisher, Description: "https://github.com/afkarxyz/SpotiFLAC", + ISRC: isrc, } if err := EmbedMetadataToConvertedFile(filePath, metadata, coverPath); err != nil { @@ -415,12 +453,14 @@ func (a *AmazonDownloader) DownloadByURL(amazonURL, outputDir, quality, filename return filePath, nil } -func (a *AmazonDownloader) DownloadBySpotifyID(spotifyTrackID, outputDir, quality, filenameFormat, playlistName, playlistOwner 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) { +func (a *AmazonDownloader) DownloadBySpotifyID(spotifyTrackID, outputDir, quality, filenameFormat, playlistName, playlistOwner string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, spotifyCoverURL string, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int, embedMaxQualityCover bool, spotifyTotalDiscs int, spotifyCopyright, spotifyPublisher, spotifyURL string, + useFirstArtistOnly bool, +) (string, error) { amazonURL, err := a.GetAmazonURLFromSpotify(spotifyTrackID) if err != nil { return "", err } - return a.DownloadByURL(amazonURL, outputDir, quality, filenameFormat, playlistName, playlistOwner, includeTrackNumber, position, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, spotifyCoverURL, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks, embedMaxQualityCover, spotifyTotalDiscs, spotifyCopyright, spotifyPublisher, spotifyURL) + return a.DownloadByURL(amazonURL, outputDir, quality, filenameFormat, playlistName, playlistOwner, includeTrackNumber, position, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, spotifyCoverURL, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks, embedMaxQualityCover, spotifyTotalDiscs, spotifyCopyright, spotifyPublisher, spotifyURL, useFirstArtistOnly) } diff --git a/backend/filename.go b/backend/filename.go index 63bc036..6dd49dc 100644 --- a/backend/filename.go +++ b/backend/filename.go @@ -118,6 +118,19 @@ func SanitizeFilename(name string) string { return sanitized } +func GetFirstArtist(artistString string) string { + if artistString == "" { + return "" + } + delimiters := []string{", ", " & ", " feat. ", " ft. ", " featuring "} + for _, d := range delimiters { + if idx := strings.Index(strings.ToLower(artistString), d); idx != -1 { + return strings.TrimSpace(artistString[:idx]) + } + } + return artistString +} + func NormalizePath(folderPath string) string { return strings.ReplaceAll(folderPath, "/", string(filepath.Separator)) diff --git a/backend/metadata.go b/backend/metadata.go index a5cb8e1..0cd79cc 100644 --- a/backend/metadata.go +++ b/backend/metadata.go @@ -31,6 +31,7 @@ type Metadata struct { Publisher string Lyrics string Description string + ISRC string } func EmbedMetadata(filepath string, metadata Metadata, coverPath string) error { @@ -86,6 +87,10 @@ func EmbedMetadata(filepath string, metadata Metadata, coverPath string) error { _ = cmt.Add("DESCRIPTION", metadata.Description) } + if metadata.ISRC != "" { + _ = cmt.Add("ISRC", metadata.ISRC) + } + if metadata.Lyrics != "" { _ = cmt.Add("LYRICS", metadata.Lyrics) } @@ -504,6 +509,13 @@ func EmbedLyricsOnlyUniversal(filepath string, lyrics string) error { return nil } + validatedLyrics, err := validateLyricsDuration(lyrics, filepath) + if err != nil { + fmt.Printf("[EmbedLyricsOnlyUniversal] Warning: Failed to validate lyrics duration: %v, using original lyrics\n", err) + validatedLyrics = lyrics + } + lyrics = validatedLyrics + ext := strings.ToLower(pathfilepath.Ext(filepath)) switch ext { case ".mp3": @@ -635,27 +647,22 @@ func validateLyricsDuration(lyrics string, filepath string) (string, error) { 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) + if ms >= 0 { + if 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 { - fmt.Printf("[ValidateLyrics] Filtered out line with timestamp %s (exceeds duration %d ms): %s\n", timestampStr, durationMs, trimmedLine) + validLines = append(validLines, line) } - } else { - - validLines = append(validLines, line) + continue } } else { @@ -858,6 +865,11 @@ func embedMetadataToMP3(filePath string, metadata Metadata, coverPath string) er tag.AddTextFrame("TPUB", id3v2.EncodingUTF8, metadata.Publisher) } + if metadata.ISRC != "" { + tag.DeleteFrames("TSRC") + tag.AddTextFrame("TSRC", id3v2.EncodingUTF8, metadata.ISRC) + } + if coverPath != "" && fileExists(coverPath) { tag.DeleteFrames(tag.CommonID("Attached picture")) @@ -941,6 +953,9 @@ func embedMetadataToM4A(filePath string, metadata Metadata, coverPath string) er if metadata.Publisher != "" { args = append(args, "-metadata", "publisher="+metadata.Publisher) } + if metadata.ISRC != "" { + args = append(args, "-metadata", "isrc="+metadata.ISRC) + } tmpOutputFile := strings.TrimSuffix(filePath, pathfilepath.Ext(filePath)) + ".tmp" + pathfilepath.Ext(filePath) defer func() { diff --git a/backend/progress.go b/backend/progress.go index 356f95b..0a43155 100644 --- a/backend/progress.go +++ b/backend/progress.go @@ -22,7 +22,7 @@ type DownloadItem struct { TrackName string `json:"track_name"` ArtistName string `json:"artist_name"` AlbumName string `json:"album_name"` - ISRC string `json:"isrc"` + SpotifyID string `json:"spotify_id"` Status DownloadStatus `json:"status"` Progress float64 `json:"progress"` TotalSize float64 `json:"total_size"` @@ -184,7 +184,7 @@ func (pw *ProgressWriter) GetTotal() int64 { return pw.total } -func AddToQueue(id, trackName, artistName, albumName, isrc string) { +func AddToQueue(id, trackName, artistName, albumName, spotifyID string) { downloadQueueLock.Lock() defer downloadQueueLock.Unlock() @@ -193,7 +193,7 @@ func AddToQueue(id, trackName, artistName, albumName, isrc string) { TrackName: trackName, ArtistName: artistName, AlbumName: albumName, - ISRC: isrc, + SpotifyID: spotifyID, Status: StatusQueued, Progress: 0, TotalSize: 0, diff --git a/backend/qobuz.go b/backend/qobuz.go index c157b80..a684d25 100644 --- a/backend/qobuz.go +++ b/backend/qobuz.go @@ -77,7 +77,7 @@ func NewQobuzDownloader() *QobuzDownloader { } } -func (q *QobuzDownloader) SearchByISRC(isrc string) (*QobuzTrack, error) { +func (q *QobuzDownloader) searchByISRC(isrc string) (*QobuzTrack, error) { apiBase := "https://www.qobuz.com/api.json/0.2/track/search?query=" url := fmt.Sprintf("%s%s&limit=1&app_id=%s", apiBase, isrc, q.appID) @@ -433,7 +433,23 @@ func buildQobuzFilename(title, artist, album, albumArtist, releaseDate string, t return filename + ".flac" } -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, allowFallback bool) (string, error) { +func (q *QobuzDownloader) DownloadTrack(spotifyID, 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, allowFallback bool, useFirstArtistOnly bool) (string, error) { + var deezerISRC string + if spotifyID != "" { + songlinkClient := NewSongLinkClient() + isrc, err := songlinkClient.GetISRC(spotifyID) + if err != nil { + return "", fmt.Errorf("failed to get ISRC: %v", err) + } + deezerISRC = isrc + } else { + return "", fmt.Errorf("spotify ID is required for Qobuz download") + } + + return q.DownloadTrackWithISRC(deezerISRC, spotifyID, outputDir, quality, filenameFormat, includeTrackNumber, position, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, useAlbumTrackNumber, spotifyCoverURL, embedMaxQualityCover, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks, spotifyTotalDiscs, spotifyCopyright, spotifyPublisher, spotifyURL, allowFallback, useFirstArtistOnly) +} + +func (q *QobuzDownloader) DownloadTrackWithISRC(deezerISRC, spotifyID, 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, allowFallback bool, useFirstArtistOnly bool) (string, error) { fmt.Printf("Fetching track info for ISRC: %s\n", deezerISRC) if outputDir != "." { @@ -442,7 +458,7 @@ func (q *QobuzDownloader) DownloadByISRC(deezerISRC, outputDir, quality, filenam } } - track, err := q.SearchByISRC(deezerISRC) + track, err := q.searchByISRC(deezerISRC) if err != nil { return "", err } @@ -477,9 +493,15 @@ func (q *QobuzDownloader) DownloadByISRC(deezerISRC, outputDir, quality, filenam fmt.Printf("Download URL obtained: %s\n", urlPreview) safeArtist := sanitizeFilename(artists) + safeAlbumArtist := sanitizeFilename(spotifyAlbumArtist) + + if useFirstArtistOnly { + safeArtist = sanitizeFilename(GetFirstArtist(artists)) + safeAlbumArtist = sanitizeFilename(GetFirstArtist(spotifyAlbumArtist)) + } + safeTitle := sanitizeFilename(trackTitle) safeAlbum := sanitizeFilename(albumTitle) - safeAlbumArtist := sanitizeFilename(spotifyAlbumArtist) filename := buildQobuzFilename(safeTitle, safeArtist, safeAlbum, safeAlbumArtist, spotifyReleaseDate, spotifyTrackNumber, spotifyDiscNumber, filenameFormat, includeTrackNumber, position, useAlbumTrackNumber) filepath := filepath.Join(outputDir, filename) @@ -531,6 +553,7 @@ func (q *QobuzDownloader) DownloadByISRC(deezerISRC, outputDir, quality, filenam Copyright: spotifyCopyright, Publisher: spotifyPublisher, Description: "https://github.com/afkarxyz/SpotiFLAC", + ISRC: deezerISRC, } if err := EmbedMetadata(filepath, metadata, coverPath); err != nil { diff --git a/backend/songlink.go b/backend/songlink.go index 5a9768a..525f053 100644 --- a/backend/songlink.go +++ b/backend/songlink.go @@ -21,6 +21,7 @@ type SongLinkClient struct { type SongLinkURLs struct { TidalURL string `json:"tidal_url"` AmazonURL string `json:"amazon_url"` + ISRC string `json:"isrc"` } type TrackAvailability struct { @@ -158,6 +159,12 @@ func (s *SongLinkClient) GetAllURLsFromSpotify(spotifyTrackID string, region str } } + if deezerLink, ok := songLinkResp.LinksByPlatform["deezer"]; ok && deezerLink.URL != "" { + if isrc, err := getDeezerISRC(deezerLink.URL); err == nil && isrc != "" { + urls.ISRC = isrc + } + } + if urls.TidalURL == "" && urls.AmazonURL == "" { return nil, fmt.Errorf("no streaming URLs found") } @@ -165,7 +172,7 @@ func (s *SongLinkClient) GetAllURLsFromSpotify(spotifyTrackID string, region str return urls, nil } -func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc string) (*TrackAvailability, error) { +func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string) (*TrackAvailability, error) { now := time.Now() if now.Sub(s.apiCallResetTime) >= time.Minute { @@ -278,7 +285,7 @@ func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc stri if deezerLink, ok := songLinkResp.LinksByPlatform["deezer"]; ok && deezerLink.URL != "" { deezerURL := deezerLink.URL - deezerISRC, err := GetDeezerISRC(deezerURL) + deezerISRC, err := getDeezerISRC(deezerURL) if err == nil && deezerISRC != "" { qobuzAvailable := checkQobuzAvailability(deezerISRC) availability.Qobuz = qobuzAvailable @@ -408,7 +415,7 @@ func (s *SongLinkClient) GetDeezerURLFromSpotify(spotifyTrackID string) (string, return deezerURL, nil } -func GetDeezerISRC(deezerURL string) (string, error) { +func getDeezerISRC(deezerURL string) (string, error) { var trackID string if strings.Contains(deezerURL, "/track/") { @@ -452,3 +459,11 @@ func GetDeezerISRC(deezerURL string) (string, error) { fmt.Printf("Found ISRC from Deezer: %s (track: %s)\n", deezerTrack.ISRC, deezerTrack.Title) return deezerTrack.ISRC, nil } + +func (s *SongLinkClient) GetISRC(spotifyID string) (string, error) { + deezerURL, err := s.GetDeezerURLFromSpotify(spotifyID) + if err != nil { + return "", err + } + return getDeezerISRC(deezerURL) +} diff --git a/backend/spotfetch.go b/backend/spotfetch.go index 475237f..20f16d7 100644 --- a/backend/spotfetch.go +++ b/backend/spotfetch.go @@ -364,9 +364,6 @@ func getBool(m map[string]interface{}, key string) bool { func extractArtists(artistsData map[string]interface{}) []map[string]interface{} { items := getSlice(artistsData, "items") - if items == nil { - return []map[string]interface{}{} - } artists := []map[string]interface{}{} for _, item := range items { @@ -384,7 +381,7 @@ func extractArtists(artistsData map[string]interface{}) []map[string]interface{} } func extractCoverImage(coverData map[string]interface{}) map[string]interface{} { - if coverData == nil || len(coverData) == 0 { + if len(coverData) == 0 { return nil } @@ -401,7 +398,7 @@ func extractCoverImage(coverData map[string]interface{}) map[string]interface{} } } - if sources == nil || len(sources) == 0 { + if len(sources) == 0 { return nil } @@ -532,7 +529,7 @@ func FilterTrack(data map[string]interface{}, albumFetchData ...map[string]inter } var albumFetchDataMap map[string]interface{} - if len(albumFetchData) > 0 && albumFetchData[0] != nil { + if len(albumFetchData) > 0 { albumFetchDataMap = albumFetchData[0] } @@ -541,39 +538,35 @@ func FilterTrack(data map[string]interface{}, albumFetchData ...map[string]inter if len(artists) == 0 { artists = []map[string]interface{}{} firstArtistItems := getSlice(getMap(trackData, "firstArtist"), "items") - if firstArtistItems != nil { - for _, item := range firstArtistItems { - itemMap, ok := item.(map[string]interface{}) - if !ok { - continue - } - if profile, exists := itemMap["profile"]; exists { - profileMap, ok := profile.(map[string]interface{}) - if ok { - artistInfo := map[string]interface{}{ - "name": getString(profileMap, "name"), - } - artists = append(artists, artistInfo) + for _, item := range firstArtistItems { + itemMap, ok := item.(map[string]interface{}) + if !ok { + continue + } + if profile, exists := itemMap["profile"]; exists { + profileMap, ok := profile.(map[string]interface{}) + if ok { + artistInfo := map[string]interface{}{ + "name": getString(profileMap, "name"), } + artists = append(artists, artistInfo) } } } otherArtistItems := getSlice(getMap(trackData, "otherArtists"), "items") - if otherArtistItems != nil { - for _, item := range otherArtistItems { - itemMap, ok := item.(map[string]interface{}) - if !ok { - continue - } - if profile, exists := itemMap["profile"]; exists { - profileMap, ok := profile.(map[string]interface{}) - if ok { - artistInfo := map[string]interface{}{ - "name": getString(profileMap, "name"), - } - artists = append(artists, artistInfo) + for _, item := range otherArtistItems { + itemMap, ok := item.(map[string]interface{}) + if !ok { + continue + } + if profile, exists := itemMap["profile"]; exists { + profileMap, ok := profile.(map[string]interface{}) + if ok { + artistInfo := map[string]interface{}{ + "name": getString(profileMap, "name"), } + artists = append(artists, artistInfo) } } } @@ -710,6 +703,9 @@ func FilterTrack(data map[string]interface{}, albumFetchData ...map[string]inter } albumArtistsString = strings.Join(albumArtistNames, ", ") } + if albumArtistsString == "" { + albumArtistsString = getString(albumUnionData, "artists") + } albumLabel = getString(albumUnionData, "label") } } @@ -977,6 +973,7 @@ func FilterAlbum(data map[string]interface{}) map[string]interface{} { "discs": map[string]interface{}{ "totalCount": totalDiscs, }, + "label": getString(albumData, "label"), } return filtered diff --git a/backend/spotify_metadata.go b/backend/spotify_metadata.go index fef00a8..ef296d8 100644 --- a/backend/spotify_metadata.go +++ b/backend/spotify_metadata.go @@ -42,7 +42,6 @@ type TrackMetadata struct { 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"` @@ -70,7 +69,6 @@ type AlbumTrackMetadata struct { 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"` AlbumID string `json:"album_id,omitempty"` AlbumURL string `json:"album_url,omitempty"` @@ -210,6 +208,7 @@ type apiAlbumResponse struct { Cover string `json:"cover"` ReleaseDate string `json:"releaseDate"` Count int `json:"count"` + Label string `json:"label"` Discs struct { TotalCount int `json:"totalCount"` } `json:"discs"` @@ -472,6 +471,8 @@ func (c *SpotifyMetadataClient) fetchTrack(ctx context.Context, trackID string) "items": tracksItems, "totalCount": albumResponse.Count, }, + "artists": albumResponse.Artists, + "label": albumResponse.Label, }, }, } @@ -886,7 +887,6 @@ func (c *SpotifyMetadataClient) formatTrackData(raw *apiTrackResponse) TrackResp DiscNumber: raw.Disc, TotalDiscs: raw.Discs, ExternalURL: externalURL, - ISRC: raw.ID, Copyright: raw.Copyright, Publisher: raw.Album.Label, Plays: raw.Plays, @@ -945,7 +945,6 @@ func (c *SpotifyMetadataClient) formatAlbumData(raw *apiAlbumResponse) (*AlbumRe DiscNumber: item.DiscNumber, TotalDiscs: raw.Discs.TotalCount, 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, @@ -1005,7 +1004,6 @@ func (c *SpotifyMetadataClient) formatPlaylistData(raw *apiPlaylistResponse) Pla DiscNumber: item.DiscNumber, 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, @@ -1124,7 +1122,6 @@ func (c *SpotifyMetadataClient) formatArtistDiscographyData(ctx context.Context, TotalTracks: albumData.Count, DiscNumber: tr.DiscNumber, ExternalURL: fmt.Sprintf("https://open.spotify.com/track/%s", tr.ID), - ISRC: tr.ID, AlbumID: albumID, AlbumURL: fmt.Sprintf("https://open.spotify.com/album/%s", albumID), ArtistID: artistID, diff --git a/backend/tidal.go b/backend/tidal.go index b16d894..b4d043e 100644 --- a/backend/tidal.go +++ b/backend/tidal.go @@ -446,7 +446,7 @@ func (t *TidalDownloader) DownloadFromManifest(manifestB64, outputPath string) e return nil } -func (t *TidalDownloader) DownloadByURL(tidalURL, outputDir, quality, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate string, useAlbumTrackNumber bool, spotifyCoverURL string, embedMaxQualityCover bool, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int, spotifyTotalDiscs int, spotifyCopyright, spotifyPublisher, spotifyURL string, allowFallback bool) (string, error) { +func (t *TidalDownloader) DownloadByURL(tidalURL, outputDir, quality, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate string, useAlbumTrackNumber bool, spotifyCoverURL string, embedMaxQualityCover bool, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int, spotifyTotalDiscs int, spotifyCopyright, spotifyPublisher, spotifyURL string, allowFallback bool, useFirstArtistOnly bool) (string, error) { if outputDir != "." { if err := os.MkdirAll(outputDir, 0755); err != nil { return "", fmt.Errorf("directory error: %w", err) @@ -469,9 +469,15 @@ func (t *TidalDownloader) DownloadByURL(tidalURL, outputDir, quality, filenameFo albumTitle := spotifyAlbumName artistNameForFile := sanitizeFilename(artistName) + albumArtistForFile := sanitizeFilename(spotifyAlbumArtist) + + if useFirstArtistOnly { + artistNameForFile = sanitizeFilename(GetFirstArtist(artistName)) + albumArtistForFile = sanitizeFilename(GetFirstArtist(spotifyAlbumArtist)) + } + trackTitleForFile := sanitizeFilename(trackTitle) albumTitleForFile := sanitizeFilename(albumTitle) - albumArtistForFile := sanitizeFilename(spotifyAlbumArtist) filename := buildTidalFilename(trackTitleForFile, artistNameForFile, albumTitleForFile, albumArtistForFile, spotifyReleaseDate, spotifyTrackNumber, spotifyDiscNumber, filenameFormat, includeTrackNumber, position, useAlbumTrackNumber) outputFilename := filepath.Join(outputDir, filename) @@ -494,11 +500,36 @@ func (t *TidalDownloader) DownloadByURL(tidalURL, outputDir, quality, filenameFo } } + isrcChan := make(chan string, 1) + if spotifyURL != "" { + go func() { + var isrc string + parts := strings.Split(spotifyURL, "/") + if len(parts) > 0 { + sID := strings.Split(parts[len(parts)-1], "?")[0] + if sID != "" { + client := NewSongLinkClient() + if val, err := client.GetISRC(sID); err == nil { + isrc = val + } + } + } + isrcChan <- isrc + }() + } else { + close(isrcChan) + } + fmt.Printf("Downloading to: %s\n", outputFilename) if err := t.DownloadFile(downloadURL, outputFilename); err != nil { return "", err } + var isrc string + if spotifyURL != "" { + isrc = <-isrcChan + } + fmt.Println("Adding metadata...") coverPath := "" @@ -534,6 +565,7 @@ func (t *TidalDownloader) DownloadByURL(tidalURL, outputDir, quality, filenameFo Copyright: spotifyCopyright, Publisher: spotifyPublisher, Description: "https://github.com/afkarxyz/SpotiFLAC", + ISRC: isrc, } if err := EmbedMetadata(outputFilename, metadata, coverPath); err != nil { @@ -547,7 +579,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, spotifyTotalDiscs int, spotifyCopyright, spotifyPublisher, spotifyURL string, allowFallback bool) (string, error) { +func (t *TidalDownloader) DownloadByURLWithFallback(tidalURL, outputDir, quality, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate string, useAlbumTrackNumber bool, spotifyCoverURL string, embedMaxQualityCover bool, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int, spotifyTotalDiscs int, spotifyCopyright, spotifyPublisher, spotifyURL string, allowFallback bool, useFirstArtistOnly bool) (string, error) { apis, err := t.GetAvailableAPIs() if err != nil { return "", fmt.Errorf("no APIs available for fallback: %w", err) @@ -575,9 +607,15 @@ func (t *TidalDownloader) DownloadByURLWithFallback(tidalURL, outputDir, quality albumTitle := spotifyAlbumName artistNameForFile := sanitizeFilename(artistName) + albumArtistForFile := sanitizeFilename(spotifyAlbumArtist) + + if useFirstArtistOnly { + artistNameForFile = sanitizeFilename(GetFirstArtist(artistName)) + albumArtistForFile = sanitizeFilename(GetFirstArtist(spotifyAlbumArtist)) + } + trackTitleForFile := sanitizeFilename(trackTitle) albumTitleForFile := sanitizeFilename(albumTitle) - albumArtistForFile := sanitizeFilename(spotifyAlbumArtist) filename := buildTidalFilename(trackTitleForFile, artistNameForFile, albumTitleForFile, albumArtistForFile, spotifyReleaseDate, spotifyTrackNumber, spotifyDiscNumber, filenameFormat, includeTrackNumber, position, useAlbumTrackNumber) outputFilename := filepath.Join(outputDir, filename) @@ -600,12 +638,37 @@ func (t *TidalDownloader) DownloadByURLWithFallback(tidalURL, outputDir, quality } } + isrcChan := make(chan string, 1) + if spotifyURL != "" { + go func() { + var isrc string + parts := strings.Split(spotifyURL, "/") + if len(parts) > 0 { + sID := strings.Split(parts[len(parts)-1], "?")[0] + if sID != "" { + client := NewSongLinkClient() + if val, err := client.GetISRC(sID); err == nil { + isrc = val + } + } + } + isrcChan <- isrc + }() + } else { + close(isrcChan) + } + fmt.Printf("Downloading to: %s\n", outputFilename) downloader := NewTidalDownloader(successAPI) if err := downloader.DownloadFile(downloadURL, outputFilename); err != nil { return "", err } + var isrc string + if spotifyURL != "" { + isrc = <-isrcChan + } + fmt.Println("Adding metadata...") coverPath := "" @@ -641,6 +704,7 @@ func (t *TidalDownloader) DownloadByURLWithFallback(tidalURL, outputDir, quality Copyright: spotifyCopyright, Publisher: spotifyPublisher, Description: "https://github.com/afkarxyz/SpotiFLAC", + ISRC: isrc, } if err := EmbedMetadata(outputFilename, metadata, coverPath); err != nil { @@ -654,14 +718,14 @@ 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, spotifyTotalDiscs int, spotifyCopyright, spotifyPublisher, spotifyURL string, allowFallback bool) (string, error) { +func (t *TidalDownloader) Download(spotifyTrackID, outputDir, quality, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate string, useAlbumTrackNumber bool, spotifyCoverURL string, embedMaxQualityCover bool, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int, spotifyTotalDiscs int, spotifyCopyright, spotifyPublisher, spotifyURL string, allowFallback bool, useFirstArtistOnly bool) (string, error) { tidalURL, err := t.GetTidalURLFromSpotify(spotifyTrackID) if err != nil { 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, spotifyTotalDiscs, spotifyCopyright, spotifyPublisher, spotifyURL, allowFallback) + return t.DownloadByURLWithFallback(tidalURL, outputDir, quality, filenameFormat, includeTrackNumber, position, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, useAlbumTrackNumber, spotifyCoverURL, embedMaxQualityCover, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks, spotifyTotalDiscs, spotifyCopyright, spotifyPublisher, spotifyURL, allowFallback, useFirstArtistOnly) } type SegmentTemplate struct { diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 21f6776..900c043 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -3,7 +3,7 @@ import { Button } from "@/components/ui/button"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; import { Search, X, ArrowUp } from "lucide-react"; import { TooltipProvider } from "@/components/ui/tooltip"; -import { getSettings, getSettingsWithDefaults, loadSettings, saveSettings, applyThemeMode, applyFont } from "@/lib/settings"; +import { getSettings, getSettingsWithDefaults, loadSettings, saveSettings, applyThemeMode, applyFont, updateSettings } from "@/lib/settings"; import { applyTheme } from "@/lib/themes"; import { OpenFolder, CheckFFmpegInstalled, DownloadFFmpeg } from "../wailsjs/go/main/App"; import { EventsOn, EventsOff, Quit } from "../wailsjs/runtime/runtime"; @@ -119,6 +119,17 @@ function App() { window.removeEventListener("scroll", handleScroll); }; }, []); + const handleEnableSpotFetchApi = async () => { + try { + await updateSettings({ useSpotFetchAPI: true }); + metadata.setShowApiModal(false); + toast.success("SpotFetch API enabled! You can now try fetching again."); + } + catch (err) { + console.error("Failed to enable SpotFetch API:", err); + toast.error("Failed to update settings"); + } + }; const scrollToTop = useCallback(() => { window.scrollTo({ top: 0, behavior: "smooth" }); }, []); @@ -290,19 +301,19 @@ function App() { setSearchQuery(value); setCurrentListPage(1); }; - const toggleTrackSelection = (isrc: string) => { - setSelectedTracks((prev) => prev.includes(isrc) ? prev.filter((id) => id !== isrc) : [...prev, isrc]); + const toggleTrackSelection = (id: string) => { + setSelectedTracks((prev) => prev.includes(id) ? prev.filter((prevId) => prevId !== id) : [...prev, id]); }; const toggleSelectAll = (tracks: any[]) => { - const tracksWithIsrc = tracks.filter((track) => track.isrc).map((track) => track.isrc); - if (tracksWithIsrc.length === 0) + const tracksWithId = tracks.filter((track) => track.spotify_id).map((track) => track.spotify_id || ""); + if (tracksWithId.length === 0) return; - const allSelected = tracksWithIsrc.every(isrc => selectedTracks.includes(isrc)); + const allSelected = tracksWithId.every(id => selectedTracks.includes(id)); if (allSelected) { - setSelectedTracks(prev => prev.filter(isrc => !tracksWithIsrc.includes(isrc))); + setSelectedTracks(prev => prev.filter(id => !tracksWithId.includes(id))); } else { - setSelectedTracks(prev => Array.from(new Set([...prev, ...tracksWithIsrc]))); + setSelectedTracks(prev => Array.from(new Set([...prev, ...tracksWithId]))); } }; const handleOpenFolder = async () => { @@ -324,7 +335,8 @@ function App() { return null; if ("track" in metadata.metadata) { const { track } = metadata.metadata; - return ( lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, track.album_name, undefined, albumArtist, releaseDate, discNumber)} onDownloadCover={(coverUrl, trackName, artistName, albumName, _playlistName, _position, trackId, albumArtist, releaseDate, discNumber) => cover.handleDownloadCover(coverUrl, trackName, artistName, albumName, track.album_name, undefined, trackId, albumArtist, releaseDate, discNumber)} onCheckAvailability={availability.checkAvailability} onOpenFolder={handleOpenFolder} onBack={metadata.resetMetadata}/>); + const trackId = track.spotify_id || ""; + return ( lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, track.album_name, undefined, albumArtist, releaseDate, discNumber)} onDownloadCover={(coverUrl, trackName, artistName, albumName, _playlistName, _position, trackId, albumArtist, releaseDate, discNumber) => cover.handleDownloadCover(coverUrl, trackName, artistName, albumName, track.album_name, undefined, trackId, albumArtist, releaseDate, discNumber)} onCheckAvailability={availability.checkAvailability} onOpenFolder={handleOpenFolder} onBack={metadata.resetMetadata}/>); } if ("album_info" in metadata.metadata) { const { album_info, track_list } = metadata.metadata; @@ -555,6 +567,25 @@ function App() { + + + + + SpotFetch API Recommended + + Direct fetch failed. This usually happens when your country is blocked by Spotify or your IP is restricted. Would you like to enable the SpotFetch API to bypass this? + + + + + + + + ); } diff --git a/frontend/src/components/AlbumInfo.tsx b/frontend/src/components/AlbumInfo.tsx index 3e0f83f..6f4b63a 100644 --- a/frontend/src/components/AlbumInfo.tsx +++ b/frontend/src/components/AlbumInfo.tsx @@ -48,9 +48,9 @@ interface AlbumInfoProps { isBulkDownloadingLyrics?: boolean; onSearchChange: (value: string) => void; onSortChange: (value: string) => void; - onToggleTrack: (isrc: string) => void; + onToggleTrack: (id: 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; + onDownloadTrack: (id: 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; diff --git a/frontend/src/components/ArtistInfo.tsx b/frontend/src/components/ArtistInfo.tsx index ef8c2b9..aa98c54 100644 --- a/frontend/src/components/ArtistInfo.tsx +++ b/frontend/src/components/ArtistInfo.tsx @@ -67,9 +67,9 @@ interface ArtistInfoProps { isBulkDownloadingLyrics?: boolean; onSearchChange: (value: string) => void; onSortChange: (value: string) => void; - onToggleTrack: (isrc: string) => void; + onToggleTrack: (id: 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; + onDownloadTrack: (id: 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; @@ -491,8 +491,8 @@ export function ArtistInfo({ artistInfo, albumList, trackList, searchQuery, sort
{filteredAlbumGroups.map(([albumName, data]) => { - const tracksWithIsrc = data.tracks.filter(t => t.isrc); - const isSelected = tracksWithIsrc.length > 0 && tracksWithIsrc.every(t => selectedTracks.includes(t.isrc!)); + const tracksWithId = data.tracks.filter(t => t.spotify_id); + const isSelected = tracksWithId.length > 0 && tracksWithId.every(t => selectedTracks.includes(t.spotify_id!)); return (
onToggleSelectAll(data.tracks)} className="mt-1"/>
diff --git a/frontend/src/components/PlaylistInfo.tsx b/frontend/src/components/PlaylistInfo.tsx index 2334db1..997d17b 100644 --- a/frontend/src/components/PlaylistInfo.tsx +++ b/frontend/src/components/PlaylistInfo.tsx @@ -54,9 +54,9 @@ interface PlaylistInfoProps { isBulkDownloadingLyrics?: boolean; onSearchChange: (value: string) => void; onSortChange: (value: string) => void; - onToggleTrack: (isrc: string) => void; + onToggleTrack: (id: 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; + onDownloadTrack: (id: 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; diff --git a/frontend/src/components/SearchBar.tsx b/frontend/src/components/SearchBar.tsx index 23f9d22..eea17bd 100644 --- a/frontend/src/components/SearchBar.tsx +++ b/frontend/src/components/SearchBar.tsx @@ -1,7 +1,7 @@ import { useState, useEffect, useRef } from "react"; import { Button } from "@/components/ui/button"; import { InputWithContext } from "@/components/ui/input-with-context"; -import { CloudDownload, XCircle, Link, Search, X, ChevronDown } from "lucide-react"; +import { CloudDownload, XCircle, Link, Search, X, ChevronDown, } from "lucide-react"; import { Spinner } from "@/components/ui/spinner"; import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip"; import { FetchHistory } from "@/components/FetchHistory"; @@ -10,12 +10,13 @@ import { SearchSpotify, SearchSpotifyByType } from "../../wailsjs/go/main/App"; import { backend } from "../../wailsjs/go/models"; import { cn } from "@/lib/utils"; import { useTypingEffect } from "@/hooks/useTypingEffect"; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; +import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; const FETCH_PLACEHOLDERS = [ "https://open.spotify.com/track/...", "https://open.spotify.com/album/...", "https://open.spotify.com/playlist/...", - "https://open.spotify.com/artist/..." + "https://open.spotify.com/artist/...", ]; const SEARCH_PLACEHOLDERS = [ "Golden", @@ -23,10 +24,194 @@ const SEARCH_PLACEHOLDERS = [ "The Weeknd", "Starboy", "Joji", - "Die For You" + "Die For You", ]; -const REGIONS = ["AD", "AE", "AG", "AL", "AM", "AO", "AR", "AT", "AU", "AZ", "BA", "BB", "BD", "BE", "BF", "BG", "BH", "BI", "BJ", "BN", "BO", "BR", "BS", "BT", "BW", "BZ", "CA", "CD", "CG", "CH", "CI", "CL", "CM", "CO", "CR", "CV", "CW", "CY", "CZ", "DE", "DJ", "DK", "DM", "DO", "DZ", "EC", "EE", "EG", "ES", "ET", "FI", "FJ", "FM", "FR", "GA", "GB", "GD", "GE", "GH", "GM", "GN", "GQ", "GR", "GT", "GW", "GY", "HK", "HN", "HR", "HT", "HU", "ID", "IE", "IL", "IN", "IQ", "IS", "IT", "JM", "JO", "JP", "KE", "KG", "KH", "KI", "KM", "KN", "KR", "KW", "KZ", "LA", "LB", "LC", "LI", "LK", "LR", "LS", "LT", "LU", "LV", "LY", "MA", "MC", "MD", "ME", "MG", "MH", "MK", "ML", "MN", "MO", "MR", "MT", "MU", "MV", "MW", "MX", "MY", "MZ", "NA", "NE", "NG", "NI", "NL", "NO", "NP", "NR", "NZ", "OM", "PA", "PE", "PG", "PH", "PK", "PL", "PS", "PT", "PW", "PY", "QA", "RO", "RS", "RW", "SA", "SB", "SC", "SE", "SG", "SI", "SK", "SL", "SM", "SN", "SR", "ST", "SV", "SZ", "TD", "TG", "TH", "TJ", "TL", "TN", "TO", "TR", "TT", "TV", "TW", "TZ", "UA", "UG", "US", "UY", "UZ", "VC", "VE", "VN", "VU", "WS", "XK", "ZA", "ZM", "ZW"]; -const regionNames = new Intl.DisplayNames(['en'], { type: 'region' }); +const REGIONS = [ + "AD", + "AE", + "AG", + "AL", + "AM", + "AO", + "AR", + "AT", + "AU", + "AZ", + "BA", + "BB", + "BD", + "BE", + "BF", + "BG", + "BH", + "BI", + "BJ", + "BN", + "BO", + "BR", + "BS", + "BT", + "BW", + "BZ", + "CA", + "CD", + "CG", + "CH", + "CI", + "CL", + "CM", + "CO", + "CR", + "CV", + "CW", + "CY", + "CZ", + "DE", + "DJ", + "DK", + "DM", + "DO", + "DZ", + "EC", + "EE", + "EG", + "ES", + "ET", + "FI", + "FJ", + "FM", + "FR", + "GA", + "GB", + "GD", + "GE", + "GH", + "GM", + "GN", + "GQ", + "GR", + "GT", + "GW", + "GY", + "HK", + "HN", + "HR", + "HT", + "HU", + "ID", + "IE", + "IL", + "IN", + "IQ", + "IS", + "IT", + "JM", + "JO", + "JP", + "KE", + "KG", + "KH", + "KI", + "KM", + "KN", + "KR", + "KW", + "KZ", + "LA", + "LB", + "LC", + "LI", + "LK", + "LR", + "LS", + "LT", + "LU", + "LV", + "LY", + "MA", + "MC", + "MD", + "ME", + "MG", + "MH", + "MK", + "ML", + "MN", + "MO", + "MR", + "MT", + "MU", + "MV", + "MW", + "MX", + "MY", + "MZ", + "NA", + "NE", + "NG", + "NI", + "NL", + "NO", + "NP", + "NR", + "NZ", + "OM", + "PA", + "PE", + "PG", + "PH", + "PK", + "PL", + "PS", + "PT", + "PW", + "PY", + "QA", + "RO", + "RS", + "RW", + "SA", + "SB", + "SC", + "SE", + "SG", + "SI", + "SK", + "SL", + "SM", + "SN", + "SR", + "ST", + "SV", + "SZ", + "TD", + "TG", + "TH", + "TJ", + "TL", + "TN", + "TO", + "TR", + "TT", + "TV", + "TW", + "TZ", + "UA", + "UG", + "US", + "UY", + "UZ", + "VC", + "VE", + "VN", + "VU", + "WS", + "XK", + "ZA", + "ZM", + "ZW", +]; +const regionNames = new Intl.DisplayNames(["en"], { type: "region" }); const getRegionName = (code: string) => { try { if (code === "XK") @@ -56,7 +241,7 @@ interface SearchBarProps { region: string; onRegionChange: (region: string) => void; } -export function SearchBar({ url, loading, onUrlChange, onFetch, onFetchUrl, history, onHistorySelect, onHistoryRemove, hasResult, searchMode, onSearchModeChange, region, onRegionChange }: SearchBarProps) { +export function SearchBar({ url, loading, onUrlChange, onFetch, onFetchUrl, history, onHistorySelect, onHistoryRemove, hasResult, searchMode, onSearchModeChange, region, onRegionChange, }: SearchBarProps) { const [searchQuery, setSearchQuery] = useState(""); const [searchResults, setSearchResults] = useState(null); const [isSearching, setIsSearching] = useState(false); @@ -70,6 +255,8 @@ export function SearchBar({ url, loading, onUrlChange, onFetch, onFetchUrl, hist artists: false, playlists: false, }); + const [showInvalidUrlDialog, setShowInvalidUrlDialog] = useState(false); + const [invalidUrl, setInvalidUrl] = useState(""); const searchTimeoutRef = useRef | null>(null); const placeholders = searchMode ? SEARCH_PLACEHOLDERS : FETCH_PLACEHOLDERS; const placeholderText = useTypingEffect(placeholders); @@ -125,7 +312,10 @@ export function SearchBar({ url, loading, onUrlChange, onFetch, onFetchUrl, hist searchTimeoutRef.current = setTimeout(async () => { setIsSearching(true); try { - const results = await SearchSpotify({ query: searchQuery, limit: SEARCH_LIMIT }); + const results = await SearchSpotify({ + query: searchQuery, + limit: SEARCH_LIMIT, + }); setSearchResults(results); setLastSearchedQuery(searchQuery.trim()); saveRecentSearch(searchQuery.trim()); @@ -181,10 +371,18 @@ export function SearchBar({ url, loading, onUrlChange, onFetch, onFetchUrl, hist 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, + 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; }); @@ -201,6 +399,35 @@ export function SearchBar({ url, loading, onUrlChange, onFetch, onFetchUrl, hist setIsLoadingMore(false); } }; + const isSpotifyUrl = (text: string) => { + const trimmed = text.trim(); + if (!trimmed) + return true; + const isUrl = /^(https?:\/\/|www\.)/i.test(trimmed) || /^spotify:/i.test(trimmed); + if (!isUrl) + return true; + return (trimmed.includes("spotify.com") || + trimmed.includes("spotify.link") || + trimmed.startsWith("spotify:")); + }; + const handlePaste = (e: React.ClipboardEvent) => { + if (searchMode) + return; + const pastedText = e.clipboardData.getData("text"); + if (pastedText && !isSpotifyUrl(pastedText)) { + e.preventDefault(); + setInvalidUrl(pastedText); + setShowInvalidUrlDialog(true); + } + }; + const handleFetchWithValidation = () => { + if (!isSpotifyUrl(url)) { + setInvalidUrl(url); + setShowInvalidUrlDialog(true); + return; + } + onFetch(); + }; const handleResultClick = (externalUrl: string) => { onSearchModeChange(false); onFetchUrl(externalUrl); @@ -210,18 +437,23 @@ export function SearchBar({ url, loading, onUrlChange, onFetch, onFetchUrl, hist 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 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; + 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: { @@ -234,167 +466,201 @@ export function SearchBar({ url, loading, onUrlChange, onFetch, onFetchUrl, hist { key: "playlists", label: "Playlists" }, ]; return (
-
- - - - - -

{searchMode ? "Fetch Mode" : "Search Mode"}

-
-
+
+ + + + + +

{searchMode ? "Fetch Mode" : "Search Mode"}

+
+
-
- {!searchMode ? (<> - onUrlChange(e.target.value)} onKeyDown={(e) => e.key === "Enter" && onFetch()} className="pr-8"/> - {url && ()} - ) : (<> - setSearchQuery(e.target.value)} className="pr-8"/> - {searchQuery && ()} + ) : (<> + setSearchQuery(e.target.value)} className="pr-8"/> + {searchQuery && ()} - )} -
+ + )} + )} +
- {!searchMode && (<> - - - )} -
+ {!searchMode && (<> + + + )} +
- {!searchMode && !hasResult && ()} + {!searchMode && !hasResult && ()} - {searchMode && (
- {!searchQuery && !searchResults && recentSearches.length > 0 && (
-

Recent Searches

-
- {recentSearches.map((query) => (
setSearchQuery(query)}> - {query} - -
))} -
-
)} + + +
))} +
+
)} - {isSearching && (
- - Searching... -
)} + {isSearching && (
+ + Searching... +
)} - {!isSearching && searchQuery && !hasAnyResults && (
- No results found for "{searchQuery}" -
)} + {!isSearching && searchQuery && !hasAnyResults && (
+ No results found for "{searchQuery}" +
)} - {!isSearching && hasAnyResults && (<> -
- {tabs.map((tab) => { + {!isSearching && hasAnyResults && (<> +
+ {tabs.map((tab) => { const count = getTabCount(tab.key); if (count === 0) return null; return (); + {tab.label} ({count}) + ); })} -
+
-
- {activeTab === "tracks" && +
+ {activeTab === "tracks" && searchResults?.tracks.map((track) => ())} + {track.images ? () : (
)} +
+
+

{track.name}

+ {track.is_explicit && ( + E + )} +
+

+ {track.artists} +

+
+ + {formatDuration(track.duration_ms || 0)} + + ))} - {activeTab === "albums" && + {activeTab === "albums" && searchResults?.albums.map((album) => ())} + {album.images ? () : (
)} +
+

{album.name}

+

+ {album.artists} +

+
+ + {album.release_date || ""} + + ))} - {activeTab === "artists" && + {activeTab === "artists" && searchResults?.artists.map((artist) => ())} + {artist.images ? () : (
)} +
+

{artist.name}

+

Artist

+
+ ))} - {activeTab === "playlists" && + {activeTab === "playlists" && searchResults?.playlists.map((playlist) => ())} -
+ {playlist.images ? () : (
)} +
+

{playlist.name}

+

+ {playlist.owner || ""} +

+
+ ))} +
- {hasMore[activeTab] && (
- -
)} - )} + {hasMore[activeTab] && (
+
)} -
); + )} +
)} + + + + + Invalid URL + + Only Spotify links are allowed in Fetch mode. + + + + {invalidUrl && (
+ {invalidUrl} +
)} + + + + + +
+
+
); } diff --git a/frontend/src/components/TitleBar.tsx b/frontend/src/components/TitleBar.tsx index 0c55727..e5160d0 100644 --- a/frontend/src/components/TitleBar.tsx +++ b/frontend/src/components/TitleBar.tsx @@ -11,6 +11,14 @@ export function TitleBar() { if (settings) { setUseSpotFetchAPI(settings.useSpotFetchAPI || false); } + const handleSettingsUpdate = (event: any) => { + const updatedSettings = event.detail; + if (updatedSettings && typeof updatedSettings.useSpotFetchAPI !== 'undefined') { + setUseSpotFetchAPI(updatedSettings.useSpotFetchAPI); + } + }; + window.addEventListener('settingsUpdated', handleSettingsUpdate); + return () => window.removeEventListener('settingsUpdated', handleSettingsUpdate); }, []); const handleSpotFetchAPIToggle = () => { const newValue = !useSpotFetchAPI; diff --git a/frontend/src/components/TrackInfo.tsx b/frontend/src/components/TrackInfo.tsx index 6b4053b..6dce36a 100644 --- a/frontend/src/components/TrackInfo.tsx +++ b/frontend/src/components/TrackInfo.tsx @@ -26,9 +26,9 @@ interface TrackInfoProps { 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; + onDownload: (id: 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; + onCheckAvailability?: (spotifyId: 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; onBack?: () => void; @@ -95,9 +95,9 @@ export function TrackInfo({ track, isDownloading, downloadingTrack, isDownloaded
)}
- {track.isrc && (
- diff --git a/frontend/src/components/TrackList.tsx b/frontend/src/components/TrackList.tsx index ae6b2bf..3ca3db5 100644 --- a/frontend/src/components/TrackList.tsx +++ b/frontend/src/components/TrackList.tsx @@ -33,11 +33,11 @@ interface TrackListProps { failedCovers?: Set; skippedCovers?: Set; downloadingCoverTrack?: string | null; - onToggleTrack: (isrc: string) => void; + onToggleTrack: (id: 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; + onDownloadTrack: (id: 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; + onCheckAvailability?: (spotifyId: 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: { @@ -104,15 +104,15 @@ export function TrackList({ tracks, searchQuery, sortBy, selectedTracks, downloa } else if (sortBy === "downloaded") { filteredTracks = [...filteredTracks].sort((a, b) => { - const aDownloaded = downloadedTracks.has(a.isrc); - const bDownloaded = downloadedTracks.has(b.isrc); + const aDownloaded = a.spotify_id ? downloadedTracks.has(a.spotify_id) : false; + const bDownloaded = b.spotify_id ? downloadedTracks.has(b.spotify_id) : false; 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); + const aDownloaded = a.spotify_id ? downloadedTracks.has(a.spotify_id) : false; + const bDownloaded = b.spotify_id ? downloadedTracks.has(b.spotify_id) : false; return (aDownloaded ? 1 : 0) - (bDownloaded ? 1 : 0); }); } @@ -149,9 +149,9 @@ export function TrackList({ tracks, searchQuery, sortBy, selectedTracks, downloa } return pages; }; - const tracksWithIsrc = filteredTracks.filter((track) => track.isrc); - const allSelected = tracksWithIsrc.length > 0 && - tracksWithIsrc.every((track) => selectedTracks.includes(track.isrc)); + const tracksWithId = filteredTracks.filter((track) => track.spotify_id); + const allSelected = tracksWithId.length > 0 && + tracksWithId.every((track) => selectedTracks.includes(track.spotify_id!)); const formatDuration = (ms: number) => { const minutes = Math.floor(ms / 60000); const seconds = Math.floor((ms % 60000) / 1000); @@ -197,7 +197,7 @@ export function TrackList({ tracks, searchQuery, sortBy, selectedTracks, downloa {paginatedTracks.map((track, index) => ( {showCheckboxes && ( - {track.isrc && ( onToggleTrack(track.isrc)}/>)} + {track.spotify_id && ( onToggleTrack(track.spotify_id!)}/>)} )}
@@ -223,7 +223,7 @@ export function TrackList({ tracks, searchQuery, sortBy, selectedTracks, downloa ) : ({track.name})} {track.is_explicit && (E)} - {skippedTracks.has(track.isrc) ? () : downloadedTracks.has(track.isrc) ? () : failedTracks.has(track.isrc) ? () : null} + {track.spotify_id && skippedTracks.has(track.spotify_id) ? () : track.spotify_id && downloadedTracks.has(track.spotify_id) ? () : track.spotify_id && failedTracks.has(track.spotify_id) ? () : null}
{track.artists_data && track.artists_data.length > 0 ? ((() => { @@ -270,14 +270,14 @@ export function TrackList({ tracks, searchQuery, sortBy, selectedTracks, downloa
- {track.isrc && ( + {track.spotify_id && ( - - {downloadingTrack === track.isrc ? (

Downloading...

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

Already exists

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

Downloaded

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

Failed

) : (

Download Track

)} + {downloadingTrack === track.spotify_id ? (

Downloading...

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

Already exists

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

Downloaded

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

Failed

) : (

Download Track

)}
)} {track.spotify_id && ( @@ -315,7 +315,7 @@ export function TrackList({ tracks, searchQuery, sortBy, selectedTracks, downloa )} {track.spotify_id && onCheckAvailability && ( - diff --git a/frontend/src/hooks/useAvailability.ts b/frontend/src/hooks/useAvailability.ts index 32143f1..efb7dd8 100644 --- a/frontend/src/hooks/useAvailability.ts +++ b/frontend/src/hooks/useAvailability.ts @@ -7,7 +7,7 @@ export function useAvailability() { const [checkingTrackId, setCheckingTrackId] = useState(null); const [availabilityMap, setAvailabilityMap] = useState>(new Map()); const [error, setError] = useState(null); - const checkAvailability = useCallback(async (spotifyId: string, isrc?: string) => { + const checkAvailability = useCallback(async (spotifyId: string) => { if (!spotifyId) { setError("No Spotify ID provided"); return null; @@ -20,7 +20,7 @@ export function useAvailability() { setError(null); try { logger.info(`Checking availability for track: ${spotifyId}`); - const response = await CheckTrackAvailability(spotifyId, isrc || ""); + const response = await CheckTrackAvailability(spotifyId); const availability: TrackAvailability = JSON.parse(response); setAvailabilityMap((prev) => { const newMap = new Map(prev); diff --git a/frontend/src/hooks/useDownload.ts b/frontend/src/hooks/useDownload.ts index f302c63..0b3aa9d 100644 --- a/frontend/src/hooks/useDownload.ts +++ b/frontend/src/hooks/useDownload.ts @@ -51,7 +51,7 @@ export function useDownload(region: 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 downloadWithAutoFallback = async (id: 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; @@ -117,7 +117,7 @@ export function useDownload(region: string) { if (trackName && artistName) { try { const checkRequest: CheckFileExistenceRequest = { - spotify_id: spotifyId || isrc, + spotify_id: spotifyId || id, track_name: trackName, artist_name: displayArtist || "", album_name: albumName, @@ -149,7 +149,7 @@ export function useDownload(region: string) { const { AddToDownloadQueue } = await import("../../wailsjs/go/main/App"); let itemID: string | undefined; if (!fileExists) { - itemID = await AddToDownloadQueue(isrc, trackName || "", displayArtist || "", albumName || ""); + itemID = await AddToDownloadQueue(id, trackName || "", displayArtist || "", albumName || ""); } if (service === "auto") { let streamingURLs: any = null; @@ -174,13 +174,12 @@ export function useDownload(region: string) { try { logger.debug(`trying tidal for: ${trackName} - ${artistName}`); const response = await downloadTrack({ - isrc, service: "tidal", query, track_name: trackName, - artist_name: artistName, + artist_name: displayArtist, album_name: albumName, - album_artist: albumArtist, + album_artist: displayAlbumArtist, release_date: finalReleaseDate || releaseDate, cover_url: coverUrl, output_dir: outputDir, @@ -201,6 +200,7 @@ export function useDownload(region: string) { spotify_total_discs: spotifyTotalDiscs, copyright: copyright, publisher: publisher, + use_first_artist_only: settings.useFirstArtistOnly, }); if (response.success) { logger.success(`tidal: ${trackName} - ${artistName}`); @@ -218,13 +218,12 @@ export function useDownload(region: string) { try { logger.debug(`trying amazon for: ${trackName} - ${artistName}`); const response = await downloadTrack({ - isrc, service: "amazon", query, track_name: trackName, - artist_name: artistName, + artist_name: displayArtist, album_name: albumName, - album_artist: albumArtist, + album_artist: displayAlbumArtist, release_date: finalReleaseDate || releaseDate, cover_url: coverUrl, output_dir: outputDir, @@ -260,13 +259,12 @@ export function useDownload(region: string) { try { logger.debug(`trying qobuz for: ${trackName} - ${artistName}`); const response = await downloadTrack({ - isrc, service: "qobuz", query, track_name: trackName, - artist_name: artistName, + artist_name: displayArtist, album_name: albumName, - album_artist: albumArtist, + album_artist: displayAlbumArtist, release_date: finalReleaseDate || releaseDate, cover_url: coverUrl, output_dir: outputDir, @@ -314,13 +312,12 @@ export function useDownload(region: string) { audioFormat = settings.qobuzQuality || "6"; } const singleServiceResponse = await downloadTrack({ - isrc, service: service as "tidal" | "qobuz" | "amazon", query, track_name: trackName, - artist_name: artistName, + artist_name: displayArtist, album_name: albumName, - album_artist: albumArtist, + album_artist: displayAlbumArtist, release_date: finalReleaseDate || releaseDate, cover_url: coverUrl, output_dir: outputDir, @@ -347,7 +344,7 @@ export function useDownload(region: string) { } 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 downloadWithItemID = async (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; @@ -375,13 +372,16 @@ export function useDownload(region: string) { 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 displayArtist = settings.useFirstArtistOnly && artistName + ? getFirstArtist(artistName) + : artistName; + const displayAlbumArtist = settings.useFirstArtistOnly && albumArtist + ? getFirstArtist(albumArtist) + : albumArtist; const templateData: TemplateData = { - artist: artistName?.replace(/\//g, placeholder), + artist: displayArtist?.replace(/\//g, placeholder), album: albumName?.replace(/\//g, placeholder), - album_artist: albumArtist?.replace(/\//g, placeholder) || artistName?.replace(/\//g, placeholder), + album_artist: displayAlbumArtist?.replace(/\//g, placeholder) || displayArtist?.replace(/\//g, placeholder), title: trackName?.replace(/\//g, placeholder), track: trackNumberForTemplate, year: yearValue, @@ -424,13 +424,12 @@ export function useDownload(region: string) { if (s === "tidal" && streamingURLs?.tidal_url) { try { const response = await downloadTrack({ - isrc, service: "tidal", query, track_name: trackName, - artist_name: artistName, + artist_name: displayArtist, album_name: albumName, - album_artist: albumArtist, + album_artist: displayAlbumArtist, release_date: finalReleaseDate || releaseDate, cover_url: coverUrl, output_dir: outputDir, @@ -451,6 +450,7 @@ export function useDownload(region: string) { spotify_total_discs: spotifyTotalDiscs, copyright: copyright, publisher: publisher, + use_first_artist_only: settings.useFirstArtistOnly, }); if (response.success) { return response; @@ -465,13 +465,12 @@ export function useDownload(region: string) { else if (s === "amazon" && streamingURLs?.amazon_url) { try { const response = await downloadTrack({ - isrc, service: "amazon", query, track_name: trackName, - artist_name: artistName, + artist_name: displayArtist, album_name: albumName, - album_artist: albumArtist, + album_artist: displayAlbumArtist, release_date: finalReleaseDate || releaseDate, cover_url: coverUrl, output_dir: outputDir, @@ -490,6 +489,7 @@ export function useDownload(region: string) { spotify_total_discs: spotifyTotalDiscs, copyright: copyright, publisher: publisher, + use_first_artist_only: settings.useFirstArtistOnly, }); if (response.success) { return response; @@ -504,13 +504,12 @@ export function useDownload(region: string) { else if (s === "qobuz") { try { const response = await downloadTrack({ - isrc, service: "qobuz", query, track_name: trackName, - artist_name: artistName, + artist_name: displayArtist, album_name: albumName, - album_artist: albumArtist, + album_artist: displayAlbumArtist, release_date: finalReleaseDate || releaseDate, cover_url: coverUrl, output_dir: outputDir, @@ -530,6 +529,7 @@ export function useDownload(region: string) { spotify_total_discs: spotifyTotalDiscs, copyright: copyright, publisher: publisher, + use_first_artist_only: settings.useFirstArtistOnly, }); if (response.success) { return response; @@ -557,13 +557,12 @@ export function useDownload(region: string) { audioFormat = settings.qobuzQuality || "6"; } const singleServiceResponse = await downloadTrack({ - isrc, service: service as "tidal" | "qobuz" | "amazon", query, track_name: trackName, - artist_name: artistName, + artist_name: displayArtist, album_name: albumName, - album_artist: albumArtist, + album_artist: displayAlbumArtist, release_date: finalReleaseDate || releaseDate, cover_url: coverUrl, output_dir: outputDir, @@ -590,40 +589,41 @@ export function useDownload(region: string) { } 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"); + const handleDownloadTrack = async (id: 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 (!id) { + toast.error("No ID found for this track"); return; } - logger.info(`starting download: ${trackName} - ${artistName}`); const settings = getSettings(); - setDownloadingTrack(isrc); + const displayArtist = settings.useFirstArtistOnly && artistName ? getFirstArtist(artistName) : artistName; + logger.info(`starting download: ${trackName} - ${displayArtist}`); + setDownloadingTrack(id); 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); + const response = await downloadWithAutoFallback(id, 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)); + setSkippedTracks((prev) => new Set(prev).add(id)); } else { toast.success(response.message); } - setDownloadedTracks((prev) => new Set(prev).add(isrc)); + setDownloadedTracks((prev) => new Set(prev).add(id)); setFailedTracks((prev) => { const newSet = new Set(prev); - newSet.delete(isrc); + newSet.delete(id); return newSet; }); } else { toast.error(response.error || "Download failed"); - setFailedTracks((prev) => new Set(prev).add(isrc)); + setFailedTracks((prev) => new Set(prev).add(id)); } } catch (err) { toast.error(err instanceof Error ? err.message : "Download failed"); - setFailedTracks((prev) => new Set(prev).add(isrc)); + setFailedTracks((prev) => new Set(prev).add(id)); } finally { setDownloadingTrack(null); @@ -646,18 +646,20 @@ export function useDownload(region: string) { outputDir = joinPath(os, outputDir, sanitizePath(folderName.replace(/\//g, " "), os)); } const selectedTrackObjects = selectedTracks - .map((isrc) => allTracks.find((t) => t.isrc === isrc)) + .map((id) => allTracks.find((t) => t.spotify_id === id)) .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) => { + const displayArtist = settings.useFirstArtistOnly && track.artists ? getFirstArtist(track.artists) : track.artists; + const displayAlbumArtist = settings.useFirstArtistOnly && track.album_artist ? getFirstArtist(track.album_artist) : track.album_artist; return { - spotify_id: track.spotify_id || track.isrc, + spotify_id: track.spotify_id || "", track_name: track.name || "", - artist_name: track.artists || "", + artist_name: displayArtist || "", album_name: track.album_name || "", - album_artist: track.album_artist || "", + album_artist: displayAlbumArtist || "", release_date: track.release_date || "", track_number: track.track_number || 0, disc_number: track.disc_number || 0, @@ -682,20 +684,23 @@ export function useDownload(region: string) { 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 || ""); + for (const id of selectedTracks) { + const track = allTracks.find((t) => t.spotify_id === id); + if (!track) + continue; + const trackID = track.spotify_id || id; + const displayArtist = settings.useFirstArtistOnly && track.artists ? getFirstArtist(track.artists) : track.artists; + const itemID = await AddToDownloadQueue(trackID, track.name || "", displayArtist || "", 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)); + setSkippedTracks((prev) => new Set(prev).add(id)); + setDownloadedTracks((prev) => new Set(prev).add(id)); } } const tracksToDownload = selectedTrackObjects.filter((track) => { - const trackID = track.spotify_id || track.isrc; + const trackID = track.spotify_id || ""; return !existingSpotifyIDs.has(trackID); }); let successCount = 0; @@ -709,45 +714,46 @@ export function useDownload(region: string) { break; } const track = tracksToDownload[i]; - const isrc = track.isrc; - const originalIndex = selectedTracks.indexOf(isrc); + const id = track.spotify_id || ""; + const originalIndex = selectedTracks.indexOf(id); const itemID = itemIDs[originalIndex]; - setDownloadingTrack(isrc); - setCurrentDownloadInfo({ name: track.name, artists: track.artists }); + setDownloadingTrack(id); + const displayArtist = settings.useFirstArtistOnly && track.artists ? getFirstArtist(track.artists) : track.artists; + setCurrentDownloadInfo({ name: track.name, artists: displayArtist || "" }); 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); + const response = await downloadWithItemID(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)); + logger.info(`skipped: ${track.name} - ${displayArtist} (already exists)`); + setSkippedTracks((prev) => new Set(prev).add(id)); } else { successCount++; - logger.success(`downloaded: ${track.name} - ${track.artists}`); + logger.success(`downloaded: ${track.name} - ${displayArtist}`); } if (response.file) { - finalFilePaths.set(isrc, response.file); - finalFilePaths.set(track.spotify_id || isrc, response.file); + finalFilePaths.set(id, response.file); + finalFilePaths.set(track.spotify_id || id, response.file); } - setDownloadedTracks((prev) => new Set(prev).add(isrc)); + setDownloadedTracks((prev) => new Set(prev).add(id)); setFailedTracks((prev) => { const newSet = new Set(prev); - newSet.delete(isrc); + newSet.delete(id); return newSet; }); } else { errorCount++; - logger.error(`failed: ${track.name} - ${track.artists}`); - setFailedTracks((prev) => new Set(prev).add(isrc)); + logger.error(`failed: ${track.name} - ${displayArtist}`); + setFailedTracks((prev) => new Set(prev).add(id)); } } catch (err) { errorCount++; logger.error(`error: ${track.name} - ${err}`); - setFailedTracks((prev) => new Set(prev).add(isrc)); + setFailedTracks((prev) => new Set(prev).add(id)); if (itemID) { const { MarkDownloadItemFailed } = await import("../../wailsjs/go/main/App"); await MarkDownloadItemFailed(itemID, err instanceof Error ? err.message : String(err)); @@ -764,7 +770,7 @@ export function useDownload(region: string) { const { CancelAllQueuedItems } = await import("../../wailsjs/go/main/App"); await CancelAllQueuedItems(); if (settings.createM3u8File && folderName) { - const paths = selectedTrackObjects.map((t) => finalFilePaths.get(t.spotify_id || t.isrc) || "").filter((p) => p !== ""); + const paths = selectedTrackObjects.map((t) => finalFilePaths.get(t.spotify_id || "") || "").filter((p) => p !== ""); if (paths.length > 0) { try { logger.info(`creating m3u8 playlist: ${folderName}`); @@ -798,12 +804,12 @@ export function useDownload(region: string) { } }; const handleDownloadAll = async (tracks: TrackMetadata[], folderName?: string, isAlbum?: boolean) => { - const tracksWithIsrc = tracks.filter((track) => track.isrc); - if (tracksWithIsrc.length === 0) { + const tracksWithId = tracks.filter((track) => track.spotify_id); + if (tracksWithId.length === 0) { toast.error("No tracks available for download"); return; } - logger.info(`starting batch download: ${tracksWithIsrc.length} tracks`); + logger.info(`starting batch download: ${tracksWithId.length} tracks`); const settings = getSettings(); setIsDownloading(true); setBulkDownloadType("all"); @@ -817,13 +823,15 @@ export function useDownload(region: string) { logger.info(`checking existing files in parallel...`); const useAlbumTrackNumber = settings.folderTemplate?.includes("{album}") || false; const audioFormat = "flac"; - const existenceChecks = tracksWithIsrc.map((track, index) => { + const existenceChecks = tracksWithId.map((track, index) => { + const displayArtist = settings.useFirstArtistOnly && track.artists ? getFirstArtist(track.artists) : track.artists; + const displayAlbumArtist = settings.useFirstArtistOnly && track.album_artist ? getFirstArtist(track.album_artist) : track.album_artist; return { - spotify_id: track.spotify_id || track.isrc, + spotify_id: track.spotify_id || "", track_name: track.name || "", - artist_name: track.artists || "", + artist_name: displayArtist || "", album_name: track.album_name || "", - album_artist: track.album_artist || "", + album_artist: displayAlbumArtist || "", release_date: track.release_date || "", track_number: track.track_number || 0, disc_number: track.disc_number || 0, @@ -835,7 +843,7 @@ export function useDownload(region: string) { }; }); const existenceResults = await CheckFilesExistence(outputDir, settings.downloadPath, existenceChecks); - const finalFilePaths: string[] = new Array(tracksWithIsrc.length).fill(""); + const finalFilePaths: string[] = new Array(tracksWithId.length).fill(""); const existingSpotifyIDs = new Set(); const existingFilePaths = new Map(); for (let i = 0; i < existenceResults.length; i++) { @@ -849,25 +857,26 @@ export function useDownload(region: string) { 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 || ""); + for (const track of tracksWithId) { + const displayArtist = settings.useFirstArtistOnly && track.artists ? getFirstArtist(track.artists) : track.artists; + const itemID = await AddToDownloadQueue(track.spotify_id || "", track.name || "", displayArtist || "", track.album_name || ""); itemIDs.push(itemID); - const trackID = track.spotify_id || track.isrc; + const trackID = track.spotify_id || ""; 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)); + setSkippedTracks((prev: Set) => new Set(prev).add(trackID)); + setDownloadedTracks((prev: Set) => new Set(prev).add(trackID)); } } - const tracksToDownload = tracksWithIsrc.filter((track) => { - const trackID = track.spotify_id || track.isrc; + const tracksToDownload = tracksWithId.filter((track) => { + const trackID = track.spotify_id || ""; return !existingSpotifyIDs.has(trackID); }); let successCount = 0; let errorCount = 0; let skippedCount = existingSpotifyIDs.size; - const total = tracksWithIsrc.length; + const total = tracksWithId.length; setDownloadProgress(Math.round((skippedCount / total) * 100)); for (let i = 0; i < tracksToDownload.length; i++) { if (shouldStopDownloadRef.current) { @@ -875,27 +884,29 @@ export function useDownload(region: string) { break; } const track = tracksToDownload[i]; - const originalIndex = tracksWithIsrc.findIndex((t) => t.isrc === track.isrc); + const originalIndex = tracksWithId.findIndex((t) => t.spotify_id === track.spotify_id); const itemID = itemIDs[originalIndex]; - setDownloadingTrack(track.isrc); - setCurrentDownloadInfo({ name: track.name, artists: track.artists }); + const trackId = track.spotify_id || ""; + setDownloadingTrack(trackId); + const displayArtist = settings.useFirstArtistOnly && track.artists ? getFirstArtist(track.artists) : track.artists; + setCurrentDownloadInfo({ name: track.name || "", artists: displayArtist || "" }); 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); + const response = await downloadWithItemID(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)); + logger.info(`skipped: ${track.name} - ${displayArtist} (already exists)`); + setSkippedTracks((prev) => new Set(prev).add(trackId)); } else { successCount++; - logger.success(`downloaded: ${track.name} - ${track.artists}`); + logger.success(`downloaded: ${track.name} - ${displayArtist}`); } - setDownloadedTracks((prev) => new Set(prev).add(track.isrc)); + setDownloadedTracks((prev) => new Set(prev).add(trackId)); setFailedTracks((prev) => { const newSet = new Set(prev); - newSet.delete(track.isrc); + newSet.delete(trackId); return newSet; }); if (response.file) { @@ -904,14 +915,14 @@ export function useDownload(region: string) { } else { errorCount++; - logger.error(`failed: ${track.name} - ${track.artists}`); - setFailedTracks((prev) => new Set(prev).add(track.isrc)); + logger.error(`failed: ${track.name} - ${displayArtist}`); + setFailedTracks((prev) => new Set(prev).add(trackId)); } } catch (err) { errorCount++; logger.error(`error: ${track.name} - ${err}`); - setFailedTracks((prev) => new Set(prev).add(track.isrc)); + setFailedTracks((prev) => new Set(prev).add(trackId)); const { MarkDownloadItemFailed } = await import("../../wailsjs/go/main/App"); await MarkDownloadItemFailed(itemID, err instanceof Error ? err.message : String(err)); } diff --git a/frontend/src/hooks/useMetadata.ts b/frontend/src/hooks/useMetadata.ts index 4bc67cd..8477b54 100644 --- a/frontend/src/hooks/useMetadata.ts +++ b/frontend/src/hooks/useMetadata.ts @@ -1,4 +1,5 @@ import { useState } from "react"; +import { getSettings } from "@/lib/settings"; import { fetchSpotifyMetadata } from "@/lib/api"; import { toastWithSound as toast } from "@/lib/toast-with-sound"; import { logger } from "@/lib/logger"; @@ -7,6 +8,7 @@ import type { SpotifyMetadataResponse } from "@/types/api"; export function useMetadata() { const [loading, setLoading] = useState(false); const [metadata, setMetadata] = useState(null); + const [showApiModal, setShowApiModal] = useState(false); const [showAlbumDialog, setShowAlbumDialog] = useState(false); const [selectedAlbum, setSelectedAlbum] = useState<{ id: string; @@ -109,7 +111,7 @@ export function useMetadata() { saveToHistory(url, 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`); + logger.debug(`duration: ${data.track.duration_ms}ms`); } else if ("album_info" in data) { logger.success(`fetched album: ${data.album_info.name}`); @@ -129,7 +131,13 @@ export function useMetadata() { catch (err) { const errorMsg = err instanceof Error ? err.message : "Failed to fetch metadata"; logger.error(`fetch failed: ${errorMsg}`); - toast.error(errorMsg); + const settings = getSettings(); + if (!settings.useSpotFetchAPI) { + setShowApiModal(true); + } + else { + toast.error(errorMsg); + } } finally { setLoading(false); @@ -224,7 +232,13 @@ export function useMetadata() { catch (err) { const errorMsg = err instanceof Error ? err.message : "Failed to fetch album metadata"; logger.error(`fetch failed: ${errorMsg}`); - toast.error(errorMsg); + const settings = getSettings(); + if (!settings.useSpotFetchAPI) { + setShowApiModal(true); + } + else { + toast.error(errorMsg); + } } finally { setLoading(false); @@ -243,6 +257,8 @@ export function useMetadata() { handleConfirmAlbumFetch, handleArtistClick, loadFromCache, + showApiModal, + setShowApiModal, resetMetadata: () => setMetadata(null), }; } diff --git a/frontend/src/lib/settings.ts b/frontend/src/lib/settings.ts index 58b5a5d..540053e 100644 --- a/frontend/src/lib/settings.ts +++ b/frontend/src/lib/settings.ts @@ -363,6 +363,7 @@ export async function saveSettings(settings: Settings): Promise { cachedSettings = settings; localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings)); await SaveToBackend(settings as any); + window.dispatchEvent(new CustomEvent('settingsUpdated', { detail: settings })); } catch (error) { console.error("Failed to save settings:", error); diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts index 4567feb..e9da5fa 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -16,7 +16,6 @@ export interface TrackMetadata { total_discs?: number; disc_number?: number; external_urls: string; - isrc: string; album_type?: string; spotify_id?: string; album_id?: string; @@ -109,7 +108,6 @@ export interface ArtistResponse { } export type SpotifyMetadataResponse = TrackResponse | AlbumResponse | PlaylistResponse | ArtistDiscographyResponse | ArtistResponse; export interface DownloadRequest { - isrc: string; service: "tidal" | "qobuz" | "amazon"; query?: string; track_name?: string; @@ -139,6 +137,7 @@ export interface DownloadRequest { copyright?: string; publisher?: string; spotify_url?: string; + use_first_artist_only?: boolean; } export interface DownloadResponse { success: boolean; diff --git a/wails.json b/wails.json index 87aede8..4d98716 100644 --- a/wails.json +++ b/wails.json @@ -12,7 +12,7 @@ }, "info": { "productName": "SpotiFLAC", - "productVersion": "7.0.8", + "productVersion": "7.0.9", "copyright": "© 2026 afkarxyz" }, "wailsjsdir": "./frontend",