diff --git a/app.go b/app.go index bbe73cd..4dc9be4 100644 --- a/app.go +++ b/app.go @@ -307,6 +307,7 @@ type DownloadRequest struct { ReleaseDate string `json:"release_date,omitempty"` CoverURL string `json:"cover_url,omitempty"` TidalAPIURL string `json:"tidal_api_url,omitempty"` + TidalVariant string `json:"tidal_variant,omitempty"` OutputDir string `json:"output_dir,omitempty"` AudioFormat string `json:"audio_format,omitempty"` FilenameFormat string `json:"filename_format,omitempty"` @@ -661,7 +662,11 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) { } case "tidal": - if req.TidalAPIURL == "" || req.TidalAPIURL == "auto" { + tidalVariant := strings.ToLower(strings.TrimSpace(req.TidalVariant)) + if tidalVariant == "alt" { + downloader := backend.NewTidalDownloader("") + filename, err = downloader.DownloadAlt(req.SpotifyID, req.OutputDir, 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, req.Composer, metadataSeparator, req.ISRC, spotifyURL, req.UseFirstArtistOnly, req.UseSingleGenre, req.EmbedGenre) + } else if req.TidalAPIURL == "" || req.TidalAPIURL == "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, req.Composer, metadataSeparator, req.ISRC, spotifyURL, req.AllowFallback, req.UseFirstArtistOnly, req.UseSingleGenre, req.EmbedGenre) @@ -789,6 +794,11 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) { backend.CompleteDownloadItem(itemID, filename, 0) } + historySource := req.Service + if req.Service == "tidal" && strings.EqualFold(strings.TrimSpace(req.TidalVariant), "alt") { + historySource = "tidal alt" + } + go func(fPath, track, artist, album, sID, cover, format, source string) { time.Sleep(2 * time.Second) @@ -834,7 +844,7 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) { } backend.AddHistoryItem(item, "SpotiFLAC") - }(filename, req.TrackName, req.ArtistName, req.AlbumName, req.SpotifyID, req.CoverURL, req.AudioFormat, req.Service) + }(filename, req.TrackName, req.ArtistName, req.AlbumName, req.SpotifyID, req.CoverURL, req.AudioFormat, historySource) } return DownloadResponse{ diff --git a/backend/amazon.go b/backend/amazon.go index 20f5e0a..51ecd33 100644 --- a/backend/amazon.go +++ b/backend/amazon.go @@ -288,6 +288,16 @@ func (a *AmazonDownloader) DownloadByURL(amazonURL, outputDir, quality, filename mbMeta = result.Metadata } + upc := "" + if spotifyURL != "" { + if identifiers, err := GetSpotifyTrackIdentifiersDirect(spotifyURL); err == nil || identifiers.ISRC != "" || identifiers.UPC != "" { + if strings.TrimSpace(isrc) == "" && strings.TrimSpace(identifiers.ISRC) != "" { + isrc = strings.TrimSpace(identifiers.ISRC) + } + upc = strings.TrimSpace(identifiers.UPC) + } + } + originalFileDir := filepath.Dir(filePath) originalFileBase := strings.TrimSuffix(filepath.Base(filePath), filepath.Ext(filePath)) @@ -407,6 +417,7 @@ func (a *AmazonDownloader) DownloadByURL(amazonURL, outputDir, quality, filename Separator: metadataSeparator, Description: "https://github.com/spotbye/SpotiFLAC", ISRC: isrc, + UPC: upc, Genre: mbMeta.Genre, } diff --git a/backend/tidal.go b/backend/tidal.go index 33643de..a482058 100644 --- a/backend/tidal.go +++ b/backend/tidal.go @@ -9,7 +9,6 @@ import ( "net/http" "os" "os/exec" - "path/filepath" "regexp" "strings" "time" @@ -400,12 +399,6 @@ func (t *TidalDownloader) DownloadFromManifest(manifestB64, outputPath string) e } 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, spotifyComposer, metadataSeparator, isrcOverride, spotifyURL string, allowFallback bool, useFirstArtistOnly bool, useSingleGenre bool, embedGenre bool) (string, error) { - if outputDir != "." { - if err := os.MkdirAll(outputDir, 0755); err != nil { - return "", fmt.Errorf("directory error: %w", err) - } - } - fmt.Printf("Using Tidal URL: %s\n", tidalURL) trackID, err := t.GetTrackIDFromURL(tidalURL) @@ -417,25 +410,10 @@ func (t *TidalDownloader) DownloadByURL(tidalURL, outputDir, quality, filenameFo return "", fmt.Errorf("no track ID found") } - artistName := spotifyArtistName - trackTitle := spotifyTrackName - albumTitle := spotifyAlbumName - - artistNameForFile := sanitizeFilename(artistName) - albumArtistForFile := sanitizeFilename(spotifyAlbumArtist) - - if useFirstArtistOnly { - artistNameForFile = sanitizeFilename(GetFirstArtist(artistName)) - albumArtistForFile = sanitizeFilename(GetFirstArtist(spotifyAlbumArtist)) + outputFilename, alreadyExists, err := buildTidalOutputPath(outputDir, filenameFormat, includeTrackNumber, position, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, useAlbumTrackNumber, spotifyTrackNumber, spotifyDiscNumber, isrcOverride, useFirstArtistOnly) + if err != nil { + return "", err } - - trackTitleForFile := sanitizeFilename(trackTitle) - albumTitleForFile := sanitizeFilename(albumTitle) - - filename := buildTidalFilename(trackTitleForFile, artistNameForFile, albumTitleForFile, albumArtistForFile, spotifyReleaseDate, spotifyTrackNumber, spotifyDiscNumber, filenameFormat, includeTrackNumber, position, useAlbumTrackNumber, isrcOverride) - outputFilename := filepath.Join(outputDir, filename) - - outputFilename, alreadyExists := ResolveOutputPathForDownload(outputFilename, GetRedownloadWithSuffixSetting()) if alreadyExists { fmt.Printf("File already exists: %s (%.2f MB)\n", outputFilename, float64(mustFileSize(outputFilename))/(1024*1024)) return "EXISTS:" + outputFilename, nil @@ -447,56 +425,17 @@ func (t *TidalDownloader) DownloadByURL(tidalURL, outputDir, quality, filenameFo fmt.Println("⚠ HI_RES unavailable/failed, falling back to LOSSLESS...") downloadURL, err = t.GetDownloadURL(trackID, "LOSSLESS") if err != nil { - return "", fmt.Errorf("failed to get download URL (HI_RES & LOSSLESS both failed): %w", err) + return outputFilename, fmt.Errorf("failed to get download URL (HI_RES & LOSSLESS both failed): %w", err) } } else { - return "", err + return outputFilename, err } } - type mbResult struct { - ISRC string - Metadata Metadata - } - - metaChan := make(chan mbResult, 1) - if embedGenre && spotifyURL != "" { - go func() { - res := mbResult{} - 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 - } - } - } - res.ISRC = isrc - if isrc != "" { - if ShouldSkipMusicBrainzMetadataFetch() { - fmt.Println("Skipping MusicBrainz metadata fetch because status check is offline.") - } else { - fmt.Println("Fetching MusicBrainz metadata...") - if fetchedMeta, err := FetchMusicBrainzMetadata(isrc, trackTitle, artistName, albumTitle, useSingleGenre, embedGenre); err == nil { - res.Metadata = fetchedMeta - fmt.Println("✓ MusicBrainz metadata fetched") - } else { - fmt.Printf("Warning: Failed to fetch MusicBrainz metadata: %v\n", err) - } - } - } - metaChan <- res - }() - } else { - close(metaChan) - } - fmt.Printf("Downloading to: %s\n", outputFilename) if err := t.DownloadFile(downloadURL, outputFilename); err != nil { - return "", err + cleanupTidalDownloadArtifacts(outputFilename) + return outputFilename, err } if t.apiURL != "" { if err := RememberTidalAPIUsage(t.apiURL); err != nil { @@ -504,63 +443,7 @@ func (t *TidalDownloader) DownloadByURL(tidalURL, outputDir, quality, filenameFo } } - isrc := strings.TrimSpace(isrcOverride) - var mbMeta Metadata - if spotifyURL != "" { - result := <-metaChan - if isrc == "" { - isrc = result.ISRC - } - mbMeta = result.Metadata - } - - fmt.Println("Adding metadata...") - - coverPath := "" - - if spotifyCoverURL != "" { - coverPath = outputFilename + ".cover.jpg" - coverClient := NewCoverClient() - if err := coverClient.DownloadCoverToPath(spotifyCoverURL, coverPath, embedMaxQualityCover); err != nil { - fmt.Printf("Warning: Failed to download Spotify cover: %v\n", err) - coverPath = "" - } else { - defer os.Remove(coverPath) - fmt.Println("Spotify cover downloaded") - } - } - - trackNumberToEmbed := spotifyTrackNumber - if trackNumberToEmbed == 0 { - trackNumberToEmbed = 1 - } - - metadata := Metadata{ - Title: trackTitle, - Artist: artistName, - Album: albumTitle, - AlbumArtist: spotifyAlbumArtist, - Date: spotifyReleaseDate, - TrackNumber: trackNumberToEmbed, - TotalTracks: spotifyTotalTracks, - DiscNumber: spotifyDiscNumber, - TotalDiscs: spotifyTotalDiscs, - URL: spotifyURL, - Comment: spotifyURL, - Copyright: spotifyCopyright, - Publisher: spotifyPublisher, - Composer: spotifyComposer, - Separator: metadataSeparator, - Description: "https://github.com/spotbye/SpotiFLAC", - ISRC: isrc, - Genre: mbMeta.Genre, - } - - if err := EmbedMetadata(outputFilename, metadata, coverPath); err != nil { - fmt.Printf("Tagging failed: %v\n", err) - } else { - fmt.Println("Metadata saved") - } + finalizeTidalDownload(outputFilename, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, spotifyCoverURL, embedMaxQualityCover, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks, spotifyTotalDiscs, spotifyCopyright, spotifyPublisher, spotifyComposer, metadataSeparator, isrcOverride, spotifyURL, useSingleGenre, embedGenre) fmt.Println("Done") fmt.Println("✓ Downloaded successfully from Tidal") @@ -568,12 +451,6 @@ func (t *TidalDownloader) DownloadByURL(tidalURL, outputDir, quality, filenameFo } 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, spotifyComposer, metadataSeparator, isrcOverride, spotifyURL string, allowFallback bool, useFirstArtistOnly bool, useSingleGenre bool, embedGenre bool) (string, error) { - if outputDir != "." { - if err := os.MkdirAll(outputDir, 0755); err != nil { - return "", fmt.Errorf("directory error: %w", err) - } - } - fmt.Printf("Using Tidal URL: %s\n", tidalURL) trackID, err := t.GetTrackIDFromURL(tidalURL) @@ -585,134 +462,24 @@ func (t *TidalDownloader) DownloadByURLWithFallback(tidalURL, outputDir, quality return "", fmt.Errorf("no track ID found") } - artistName := spotifyArtistName - trackTitle := spotifyTrackName - albumTitle := spotifyAlbumName - - artistNameForFile := sanitizeFilename(artistName) - albumArtistForFile := sanitizeFilename(spotifyAlbumArtist) - - if useFirstArtistOnly { - artistNameForFile = sanitizeFilename(GetFirstArtist(artistName)) - albumArtistForFile = sanitizeFilename(GetFirstArtist(spotifyAlbumArtist)) + outputFilename, alreadyExists, err := buildTidalOutputPath(outputDir, filenameFormat, includeTrackNumber, position, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, useAlbumTrackNumber, spotifyTrackNumber, spotifyDiscNumber, isrcOverride, useFirstArtistOnly) + if err != nil { + return "", err } - - trackTitleForFile := sanitizeFilename(trackTitle) - albumTitleForFile := sanitizeFilename(albumTitle) - - filename := buildTidalFilename(trackTitleForFile, artistNameForFile, albumTitleForFile, albumArtistForFile, spotifyReleaseDate, spotifyTrackNumber, spotifyDiscNumber, filenameFormat, includeTrackNumber, position, useAlbumTrackNumber, isrcOverride) - outputFilename := filepath.Join(outputDir, filename) - - outputFilename, alreadyExists := ResolveOutputPathForDownload(outputFilename, GetRedownloadWithSuffixSetting()) if alreadyExists { fmt.Printf("File already exists: %s (%.2f MB)\n", outputFilename, float64(mustFileSize(outputFilename))/(1024*1024)) return "EXISTS:" + outputFilename, nil } - type mbResultFallback struct { - ISRC string - Metadata Metadata - } - - metaChan := make(chan mbResultFallback, 1) - if embedGenre && spotifyURL != "" { - go func() { - res := mbResultFallback{} - 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 - } - } - } - res.ISRC = isrc - if isrc != "" { - if ShouldSkipMusicBrainzMetadataFetch() { - fmt.Println("Skipping MusicBrainz metadata fetch because status check is offline.") - } else { - fmt.Println("Fetching MusicBrainz metadata...") - if fetchedMeta, err := FetchMusicBrainzMetadata(isrc, trackTitle, artistName, albumTitle, useSingleGenre, embedGenre); err == nil { - res.Metadata = fetchedMeta - fmt.Println("✓ MusicBrainz metadata fetched") - } else { - fmt.Printf("Warning: Failed to fetch MusicBrainz metadata: %v\n", err) - } - } - } - metaChan <- res - }() - } else { - close(metaChan) - } - fmt.Printf("Downloading to: %s\n", outputFilename) successAPI, err := t.downloadWithRotatingAPIs(trackID, outputFilename, quality, allowFallback) if err != nil { - return "", err + cleanupTidalDownloadArtifacts(outputFilename) + return outputFilename, err } fmt.Printf("✓ Downloaded using API: %s\n", successAPI) - isrc := strings.TrimSpace(isrcOverride) - var mbMeta Metadata - if spotifyURL != "" { - result := <-metaChan - if isrc == "" { - isrc = result.ISRC - } - mbMeta = result.Metadata - } - - fmt.Println("Adding metadata...") - - coverPath := "" - - if spotifyCoverURL != "" { - coverPath = outputFilename + ".cover.jpg" - coverClient := NewCoverClient() - if err := coverClient.DownloadCoverToPath(spotifyCoverURL, coverPath, embedMaxQualityCover); err != nil { - fmt.Printf("Warning: Failed to download Spotify cover: %v\n", err) - coverPath = "" - } else { - defer os.Remove(coverPath) - fmt.Println("Spotify cover downloaded") - } - } - - trackNumberToEmbed := spotifyTrackNumber - if trackNumberToEmbed == 0 { - trackNumberToEmbed = 1 - } - - metadata := Metadata{ - Title: trackTitle, - Artist: artistName, - Album: albumTitle, - AlbumArtist: spotifyAlbumArtist, - Date: spotifyReleaseDate, - TrackNumber: trackNumberToEmbed, - TotalTracks: spotifyTotalTracks, - DiscNumber: spotifyDiscNumber, - TotalDiscs: spotifyTotalDiscs, - URL: spotifyURL, - Comment: spotifyURL, - Copyright: spotifyCopyright, - Publisher: spotifyPublisher, - Composer: spotifyComposer, - Separator: metadataSeparator, - Description: "https://github.com/spotbye/SpotiFLAC", - ISRC: isrc, - Genre: mbMeta.Genre, - } - - if err := EmbedMetadata(outputFilename, metadata, coverPath); err != nil { - fmt.Printf("Tagging failed: %v\n", err) - } else { - fmt.Println("Metadata saved") - } + finalizeTidalDownload(outputFilename, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, spotifyCoverURL, embedMaxQualityCover, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks, spotifyTotalDiscs, spotifyCopyright, spotifyPublisher, spotifyComposer, metadataSeparator, isrcOverride, spotifyURL, useSingleGenre, embedGenre) fmt.Println("Done") fmt.Println("✓ Downloaded successfully from Tidal") @@ -723,7 +490,7 @@ func (t *TidalDownloader) Download(spotifyTrackID, outputDir, quality, filenameF tidalURL, err := t.GetTidalURLFromSpotify(spotifyTrackID) if err != nil { - return "", fmt.Errorf("songlink couldn't find Tidal URL: %w", err) + return "", fmt.Errorf("songlink/songstats 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, spotifyComposer, metadataSeparator, isrcOverride, spotifyURL, allowFallback, useFirstArtistOnly, useSingleGenre, embedGenre) diff --git a/backend/tidal_alt.go b/backend/tidal_alt.go new file mode 100644 index 0000000..6ad4bb9 --- /dev/null +++ b/backend/tidal_alt.go @@ -0,0 +1,238 @@ +package backend + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strings" +) + +const tidalAltDownloadAPIBaseURL = "https://tidal.spotbye.qzz.io/get" + +type TidalAltAPIResponse struct { + Title string `json:"title"` + Link string `json:"link"` +} + +func buildTidalOutputPath(outputDir, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate string, useAlbumTrackNumber bool, spotifyTrackNumber, spotifyDiscNumber int, isrcOverride string, useFirstArtistOnly bool) (string, bool, error) { + if outputDir != "." { + if err := os.MkdirAll(outputDir, 0755); err != nil { + return "", false, fmt.Errorf("directory error: %w", err) + } + } + + artistNameForFile := sanitizeFilename(spotifyArtistName) + albumArtistForFile := sanitizeFilename(spotifyAlbumArtist) + if useFirstArtistOnly { + artistNameForFile = sanitizeFilename(GetFirstArtist(spotifyArtistName)) + albumArtistForFile = sanitizeFilename(GetFirstArtist(spotifyAlbumArtist)) + } + + trackTitleForFile := sanitizeFilename(spotifyTrackName) + albumTitleForFile := sanitizeFilename(spotifyAlbumName) + + filename := buildTidalFilename(trackTitleForFile, artistNameForFile, albumTitleForFile, albumArtistForFile, spotifyReleaseDate, spotifyTrackNumber, spotifyDiscNumber, filenameFormat, includeTrackNumber, position, useAlbumTrackNumber, isrcOverride) + outputFilename := filepath.Join(outputDir, filename) + + outputFilename, alreadyExists := ResolveOutputPathForDownload(outputFilename, GetRedownloadWithSuffixSetting()) + return outputFilename, alreadyExists, nil +} + +func finalizeTidalDownload(outputFilename, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate string, spotifyCoverURL string, embedMaxQualityCover bool, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks, spotifyTotalDiscs int, spotifyCopyright, spotifyPublisher, spotifyComposer, metadataSeparator, isrcOverride, spotifyURL string, useSingleGenre bool, embedGenre bool) { + trackTitle := spotifyTrackName + artistName := spotifyArtistName + albumTitle := spotifyAlbumName + + type mbResult struct { + ISRC string + Metadata Metadata + } + + metaChan := make(chan mbResult, 1) + if embedGenre && spotifyURL != "" { + go func() { + res := mbResult{} + 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 + } + } + } + res.ISRC = isrc + if isrc != "" { + if ShouldSkipMusicBrainzMetadataFetch() { + fmt.Println("Skipping MusicBrainz metadata fetch because status check is offline.") + } else { + fmt.Println("Fetching MusicBrainz metadata...") + if fetchedMeta, err := FetchMusicBrainzMetadata(isrc, trackTitle, artistName, albumTitle, useSingleGenre, embedGenre); err == nil { + res.Metadata = fetchedMeta + fmt.Println("✓ MusicBrainz metadata fetched") + } else { + fmt.Printf("Warning: Failed to fetch MusicBrainz metadata: %v\n", err) + } + } + } + metaChan <- res + }() + } else { + close(metaChan) + } + + isrc := strings.TrimSpace(isrcOverride) + var mbMeta Metadata + if spotifyURL != "" { + result := <-metaChan + if isrc == "" { + isrc = result.ISRC + } + mbMeta = result.Metadata + } + + upc := "" + if spotifyURL != "" { + if identifiers, err := GetSpotifyTrackIdentifiersDirect(spotifyURL); err == nil || identifiers.ISRC != "" || identifiers.UPC != "" { + if strings.TrimSpace(isrc) == "" && strings.TrimSpace(identifiers.ISRC) != "" { + isrc = strings.TrimSpace(identifiers.ISRC) + } + upc = strings.TrimSpace(identifiers.UPC) + } + } + + fmt.Println("Adding metadata...") + + coverPath := "" + if spotifyCoverURL != "" { + coverPath = outputFilename + ".cover.jpg" + coverClient := NewCoverClient() + if err := coverClient.DownloadCoverToPath(spotifyCoverURL, coverPath, embedMaxQualityCover); err != nil { + fmt.Printf("Warning: Failed to download Spotify cover: %v\n", err) + coverPath = "" + } else { + defer os.Remove(coverPath) + fmt.Println("Spotify cover downloaded") + } + } + + trackNumberToEmbed := spotifyTrackNumber + if trackNumberToEmbed == 0 { + trackNumberToEmbed = 1 + } + + metadata := Metadata{ + Title: trackTitle, + Artist: artistName, + Album: albumTitle, + AlbumArtist: spotifyAlbumArtist, + Date: spotifyReleaseDate, + TrackNumber: trackNumberToEmbed, + TotalTracks: spotifyTotalTracks, + DiscNumber: spotifyDiscNumber, + TotalDiscs: spotifyTotalDiscs, + URL: spotifyURL, + Comment: spotifyURL, + Copyright: spotifyCopyright, + Publisher: spotifyPublisher, + Composer: spotifyComposer, + Separator: metadataSeparator, + Description: "https://github.com/spotbye/SpotiFLAC", + ISRC: isrc, + UPC: upc, + Genre: mbMeta.Genre, + } + + if err := EmbedMetadata(outputFilename, metadata, coverPath); err != nil { + fmt.Printf("Tagging failed: %v\n", err) + } else { + fmt.Println("Metadata saved") + } +} + +func (t *TidalDownloader) GetAltDownloadURLFromSpotify(spotifyTrackID string) (string, error) { + spotifyTrackID = strings.TrimSpace(spotifyTrackID) + if spotifyTrackID == "" { + return "", fmt.Errorf("spotify track ID is required") + } + + apiURL := fmt.Sprintf("%s/%s", tidalAltDownloadAPIBaseURL, spotifyTrackID) + fmt.Printf("Tidal Alt. API URL: %s\n", apiURL) + + req, err := NewRequestWithDefaultHeaders(http.MethodGet, apiURL, nil) + if err != nil { + return "", fmt.Errorf("failed to create Tidal Alt. request: %w", err) + } + + resp, err := t.client.Do(req) + if err != nil { + return "", fmt.Errorf("failed to get Tidal Alt. download URL: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", fmt.Errorf("failed to read Tidal Alt. response: %w", err) + } + + if resp.StatusCode != http.StatusOK { + preview := strings.TrimSpace(string(body)) + if len(preview) > 200 { + preview = preview[:200] + "..." + } + return "", fmt.Errorf("Tidal Alt. returned status %d: %s", resp.StatusCode, preview) + } + + var payload TidalAltAPIResponse + if err := json.Unmarshal(body, &payload); err != nil { + return "", fmt.Errorf("failed to decode Tidal Alt. response: %w", err) + } + + downloadURL := strings.TrimSpace(payload.Link) + if downloadURL == "" { + return "", fmt.Errorf("Tidal Alt. response did not include a download link") + } + + fmt.Println("✓ Tidal Alt. download URL found") + return downloadURL, nil +} + +func (t *TidalDownloader) DownloadAlt(spotifyTrackID, outputDir, 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, spotifyComposer, metadataSeparator, isrcOverride, spotifyURL string, useFirstArtistOnly bool, useSingleGenre bool, embedGenre bool) (string, error) { + spotifyTrackID = strings.TrimSpace(spotifyTrackID) + if spotifyTrackID == "" { + return "", fmt.Errorf("spotify track ID is required for Tidal Alt.") + } + + outputFilename, alreadyExists, err := buildTidalOutputPath(outputDir, filenameFormat, includeTrackNumber, position, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, useAlbumTrackNumber, spotifyTrackNumber, spotifyDiscNumber, isrcOverride, useFirstArtistOnly) + if err != nil { + return "", err + } + if alreadyExists { + fmt.Printf("File already exists: %s (%.2f MB)\n", outputFilename, float64(mustFileSize(outputFilename))/(1024*1024)) + return "EXISTS:" + outputFilename, nil + } + + fmt.Printf("Using Tidal Alt. for Spotify track: %s\n", spotifyTrackID) + + downloadURL, err := t.GetAltDownloadURLFromSpotify(spotifyTrackID) + if err != nil { + return outputFilename, err + } + + fmt.Printf("Downloading to: %s\n", outputFilename) + if err := t.DownloadFile(downloadURL, outputFilename); err != nil { + cleanupTidalDownloadArtifacts(outputFilename) + return outputFilename, err + } + + finalizeTidalDownload(outputFilename, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, spotifyCoverURL, embedMaxQualityCover, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks, spotifyTotalDiscs, spotifyCopyright, spotifyPublisher, spotifyComposer, metadataSeparator, isrcOverride, spotifyURL, useSingleGenre, embedGenre) + + fmt.Println("Done") + fmt.Println("✓ Downloaded successfully from Tidal Alt.") + return outputFilename, nil +} diff --git a/frontend/src/components/SettingsPage.tsx b/frontend/src/components/SettingsPage.tsx index 04e01e7..30e539a 100644 --- a/frontend/src/components/SettingsPage.tsx +++ b/frontend/src/components/SettingsPage.tsx @@ -102,6 +102,9 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin const handleTidalQualityChange = async (value: "LOSSLESS" | "HI_RES_LOSSLESS") => { setTempSettings((prev) => ({ ...prev, tidalQuality: value })); }; + const handleTidalVariantChange = (value: "tidal" | "alt") => { + setTempSettings((prev) => ({ ...prev, tidalVariant: value })); + }; const handleQobuzQualityChange = (value: "6" | "7" | "27") => { setTempSettings((prev) => ({ ...prev, qobuzQuality: value })); }; @@ -424,17 +427,19 @@ export function SettingsPage({ onUnsavedChangesChange, onResetRequest, }: Settin )} - {tempSettings.downloader === "tidal" && ()} + {tempSettings.downloader === "tidal" && (tempSettings.tidalVariant === "alt" ? (
+ 16-bit/44.1kHz +
) : ())} {tempSettings.downloader === "qobuz" && ( + + + + + Tidal + Tidal Alt. + + + )} + {((tempSettings.downloader === "tidal" && + tempSettings.tidalVariant !== "alt" && tempSettings.tidalQuality === "HI_RES_LOSSLESS") || (tempSettings.downloader === "qobuz" && tempSettings.qobuzQuality === "27") || diff --git a/frontend/src/hooks/useDownload.ts b/frontend/src/hooks/useDownload.ts index ea773c6..4e91dfc 100644 --- a/frontend/src/hooks/useDownload.ts +++ b/frontend/src/hooks/useDownload.ts @@ -52,6 +52,24 @@ async function resolveTemplateISRC(settings: { return ""; } } +function getTidalVariant(settings: any): "tidal" | "alt" { + return settings?.tidalVariant === "alt" ? "alt" : "tidal"; +} +function isTidalAltVariant(settings: any): boolean { + return getTidalVariant(settings) === "alt"; +} +function getTidalAudioFormat(settings: any, mode: "single" | "auto"): "LOSSLESS" | "HI_RES_LOSSLESS" { + if (isTidalAltVariant(settings)) { + return "LOSSLESS"; + } + if (mode === "auto") { + return (settings.autoQuality || "24") === "24" ? "HI_RES_LOSSLESS" : "LOSSLESS"; + } + return settings.tidalQuality || "LOSSLESS"; +} +function shouldFetchStreamingURLs(order: string[], settings: any): boolean { + return order.includes("amazon") || (order.includes("tidal") && !isTidalAltVariant(settings)); +} export function useDownload(region: string) { const [downloadProgress, setDownloadProgress] = useState(0); const [isDownloading, setIsDownloading] = useState(false); @@ -170,8 +188,11 @@ export function useDownload(region: string) { itemID = await AddToDownloadQueue(id, trackName || "", displayArtist || "", albumName || ""); } if (service === "auto") { + const order = (settings.autoOrder || "tidal-amazon-qobuz").split("-"); + const tidalVariant = getTidalVariant(settings); + const tidalLabel = tidalVariant === "alt" ? "Tidal Alt." : "Tidal"; let streamingURLs: any = null; - if (spotifyId) { + if (spotifyId && shouldFetchStreamingURLs(order, settings)) { try { const { GetStreamingURLs } = await import("../../wailsjs/go/main/App"); const urlsJson = await GetStreamingURLs(spotifyId, region); @@ -182,16 +203,15 @@ export function useDownload(region: string) { } } const durationSeconds = durationMs ? Math.round(durationMs / 1000) : undefined; - const order = (settings.autoOrder || "tidal-amazon-qobuz").split("-"); let lastResponse: any = { success: false, error: "No matching services found" }; const fallbackErrors: string[] = []; + const tidalQuality = getTidalAudioFormat(settings, "auto"); const is24Bit = (settings.autoQuality || "24") === "24"; - const tidalQuality = is24Bit ? "HI_RES_LOSSLESS" : "LOSSLESS"; const qobuzQuality = is24Bit ? "27" : "6"; for (const s of order) { - if (s === "tidal" && streamingURLs?.tidal_url) { + if (s === "tidal" && ((tidalVariant === "alt" && spotifyId) || streamingURLs?.tidal_url)) { try { - logger.debug(`trying tidal for: ${trackName} - ${artistName}`); + logger.debug(`trying ${tidalLabel} for: ${trackName} - ${artistName}`); const response = await downloadTrack({ service: "tidal", query, @@ -209,7 +229,8 @@ export function useDownload(region: string) { spotify_id: spotifyId, embed_lyrics: settings.embedLyrics, embed_max_quality_cover: settings.embedMaxQualityCover, - service_url: streamingURLs.tidal_url, + service_url: tidalVariant === "alt" ? undefined : streamingURLs?.tidal_url, + tidal_variant: tidalVariant, duration: durationSeconds, item_id: itemID, audio_format: tidalQuality, @@ -225,17 +246,17 @@ export function useDownload(region: string) { embed_genre: settings.embedGenre, }); if (response.success) { - logger.success(`tidal: ${trackName} - ${artistName}`); + logger.success(`${tidalLabel}: ${trackName} - ${artistName}`); return response; } const errMsg = response.error || response.message || "Failed"; - fallbackErrors.push(`[Tidal] ${errMsg}`); + fallbackErrors.push(`[${tidalLabel}] ${errMsg}`); lastResponse = response; - logger.warning(`tidal failed, trying next...`); + logger.warning(`${tidalLabel} failed, trying next...`); } catch (err) { - logger.error(`tidal error: ${err}`); - fallbackErrors.push(`[Tidal] ${String(err)}`); + logger.error(`${tidalLabel} error: ${err}`); + fallbackErrors.push(`[${tidalLabel}] ${String(err)}`); lastResponse = { success: false, error: String(err) }; } } @@ -344,7 +365,7 @@ export function useDownload(region: string) { const durationSecondsForFallback = durationMs ? Math.round(durationMs / 1000) : undefined; let audioFormat: string | undefined; if (service === "tidal") { - audioFormat = settings.tidalQuality || "LOSSLESS"; + audioFormat = getTidalAudioFormat(settings, "single"); } else if (service === "qobuz") { audioFormat = settings.qobuzQuality || "6"; @@ -373,6 +394,7 @@ export function useDownload(region: string) { duration: durationSecondsForFallback, item_id: itemID, audio_format: audioFormat, + tidal_variant: service === "tidal" ? getTidalVariant(settings) : undefined, spotify_track_number: spotifyTrackNumber, spotify_disc_number: spotifyDiscNumber, spotify_total_tracks: spotifyTotalTracks, @@ -380,6 +402,7 @@ export function useDownload(region: string) { isrc: resolvedTemplateISRC || undefined, copyright: copyright, publisher: publisher, + use_first_artist_only: settings.useFirstArtistOnly, use_single_genre: settings.useSingleGenre, embed_genre: settings.embedGenre, }); @@ -451,8 +474,11 @@ export function useDownload(region: string) { } } if (service === "auto") { + const order = (settings.autoOrder || "tidal-amazon-qobuz").split("-"); + const tidalVariant = getTidalVariant(settings); + const tidalLabel = tidalVariant === "alt" ? "Tidal Alt." : "Tidal"; let streamingURLs: any = null; - if (spotifyId) { + if (spotifyId && shouldFetchStreamingURLs(order, settings)) { try { const { GetStreamingURLs } = await import("../../wailsjs/go/main/App"); const urlsJson = await GetStreamingURLs(spotifyId, region); @@ -463,16 +489,15 @@ export function useDownload(region: string) { } } const durationSeconds = durationMs ? Math.round(durationMs / 1000) : undefined; - const order = (settings.autoOrder || "tidal-amazon-qobuz").split("-"); let lastResponse: any = { success: false, error: "No matching services found" }; const fallbackErrors: string[] = []; + const tidalQuality = getTidalAudioFormat(settings, "auto"); const is24Bit = (settings.autoQuality || "24") === "24"; - const tidalQuality = is24Bit ? "HI_RES_LOSSLESS" : "LOSSLESS"; const qobuzQuality = is24Bit ? "27" : "6"; for (const s of order) { - if (s === "tidal" && streamingURLs?.tidal_url) { + if (s === "tidal" && ((tidalVariant === "alt" && spotifyId) || streamingURLs?.tidal_url)) { try { - logger.debug(`trying tidal for: ${trackName} - ${artistName}`); + logger.debug(`trying ${tidalLabel} for: ${trackName} - ${artistName}`); const response = await downloadTrack({ service: "tidal", query, @@ -490,7 +515,8 @@ export function useDownload(region: string) { spotify_id: spotifyId, embed_lyrics: settings.embedLyrics, embed_max_quality_cover: settings.embedMaxQualityCover, - service_url: streamingURLs.tidal_url, + service_url: tidalVariant === "alt" ? undefined : streamingURLs?.tidal_url, + tidal_variant: tidalVariant, duration: durationSeconds, item_id: itemID, audio_format: tidalQuality, @@ -506,17 +532,17 @@ export function useDownload(region: string) { embed_genre: settings.embedGenre, }); if (response.success) { - logger.success(`tidal: ${trackName} - ${artistName}`); + logger.success(`${tidalLabel}: ${trackName} - ${artistName}`); return response; } const errMsg = response.error || response.message || "Failed"; - fallbackErrors.push(`[Tidal] ${errMsg}`); + fallbackErrors.push(`[${tidalLabel}] ${errMsg}`); lastResponse = response; - logger.warning(`tidal failed, trying next...`); + logger.warning(`${tidalLabel} failed, trying next...`); } catch (err) { - logger.error(`tidal error: ${err}`); - fallbackErrors.push(`[Tidal] ${String(err)}`); + logger.error(`${tidalLabel} error: ${err}`); + fallbackErrors.push(`[${tidalLabel}] ${String(err)}`); lastResponse = { success: false, error: String(err) }; } } @@ -628,7 +654,7 @@ export function useDownload(region: string) { const durationSecondsForFallback = durationMs ? Math.round(durationMs / 1000) : undefined; let audioFormat: string | undefined; if (service === "tidal") { - audioFormat = settings.tidalQuality || "LOSSLESS"; + audioFormat = getTidalAudioFormat(settings, "single"); } else if (service === "qobuz") { audioFormat = settings.qobuzQuality || "6"; @@ -653,6 +679,7 @@ export function useDownload(region: string) { duration: durationSecondsForFallback, item_id: itemID, audio_format: audioFormat, + tidal_variant: service === "tidal" ? getTidalVariant(settings) : undefined, spotify_track_number: spotifyTrackNumber, spotify_disc_number: spotifyDiscNumber, spotify_total_tracks: spotifyTotalTracks, diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index d2160f1..ef9c56a 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -13,6 +13,9 @@ export async function fetchSpotifyMetadata(url: string, batch: boolean = true, d } export async function downloadTrack(request: DownloadRequest): Promise { const req = new main.DownloadRequest(request); + if (request.tidal_variant !== undefined) { + (req as any).tidal_variant = request.tidal_variant; + } if (request.use_single_genre !== undefined) { (req as any).use_single_genre = request.use_single_genre; } diff --git a/frontend/src/lib/settings.ts b/frontend/src/lib/settings.ts index 291e6ec..a4b3d59 100644 --- a/frontend/src/lib/settings.ts +++ b/frontend/src/lib/settings.ts @@ -22,6 +22,7 @@ export interface Settings { embedLyrics: boolean; embedMaxQualityCover: boolean; operatingSystem: "Windows" | "linux/MacOS"; + tidalVariant: "tidal" | "alt"; tidalQuality: "LOSSLESS" | "HI_RES_LOSSLESS"; qobuzQuality: "6" | "7" | "27"; amazonQuality: "original"; @@ -110,6 +111,7 @@ export const DEFAULT_SETTINGS: Settings = { embedLyrics: false, embedMaxQualityCover: false, operatingSystem: detectOS(), + tidalVariant: "tidal", tidalQuality: "LOSSLESS", qobuzQuality: "6", amazonQuality: "original", @@ -215,6 +217,9 @@ function getSettingsFromLocalStorage(): Settings { if (!('tidalQuality' in parsed)) { parsed.tidalQuality = "LOSSLESS"; } + if (!('tidalVariant' in parsed)) { + parsed.tidalVariant = "tidal"; + } if (!('qobuzQuality' in parsed)) { parsed.qobuzQuality = "6"; } @@ -306,6 +311,9 @@ export async function loadSettings(): Promise { if (!('tidalQuality' in parsed)) { parsed.tidalQuality = "LOSSLESS"; } + if (!('tidalVariant' in parsed)) { + parsed.tidalVariant = "tidal"; + } if (!('qobuzQuality' in parsed)) { parsed.qobuzQuality = "6"; } diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts index 48dc659..6e0e007 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -120,6 +120,7 @@ export interface DownloadRequest { release_date?: string; cover_url?: string; tidal_api_url?: string; + tidal_variant?: "tidal" | "alt"; output_dir?: string; audio_format?: string; folder_name?: string;