1050 lines
31 KiB
Go
1050 lines
31 KiB
Go
package backend
|
|
|
|
import (
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"encoding/xml"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
type TidalDownloader struct {
|
|
client *http.Client
|
|
timeout time.Duration
|
|
maxRetries int
|
|
apiURL string
|
|
}
|
|
|
|
type TidalAPIResponse struct {
|
|
OriginalTrackURL string `json:"OriginalTrackUrl"`
|
|
}
|
|
|
|
type TidalAPIResponseV2 struct {
|
|
Version string `json:"version"`
|
|
Data struct {
|
|
TrackID int64 `json:"trackId"`
|
|
AssetPresentation string `json:"assetPresentation"`
|
|
AudioMode string `json:"audioMode"`
|
|
AudioQuality string `json:"audioQuality"`
|
|
ManifestMimeType string `json:"manifestMimeType"`
|
|
ManifestHash string `json:"manifestHash"`
|
|
Manifest string `json:"manifest"`
|
|
BitDepth int `json:"bitDepth"`
|
|
SampleRate int `json:"sampleRate"`
|
|
} `json:"data"`
|
|
}
|
|
|
|
type TidalBTSManifest struct {
|
|
MimeType string `json:"mimeType"`
|
|
Codecs string `json:"codecs"`
|
|
EncryptionType string `json:"encryptionType"`
|
|
URLs []string `json:"urls"`
|
|
}
|
|
|
|
func NewTidalDownloader(apiURL string) *TidalDownloader {
|
|
apiURL = strings.TrimRight(strings.TrimSpace(apiURL), "/")
|
|
if apiURL == "" {
|
|
apis, err := GetRotatedTidalAPIList()
|
|
if err == nil && len(apis) > 0 {
|
|
apiURL = apis[0]
|
|
}
|
|
}
|
|
|
|
return &TidalDownloader{
|
|
client: &http.Client{
|
|
Timeout: 5 * time.Second,
|
|
},
|
|
timeout: 5 * time.Second,
|
|
maxRetries: 3,
|
|
apiURL: apiURL,
|
|
}
|
|
}
|
|
|
|
func (t *TidalDownloader) GetAvailableAPIs() ([]string, error) {
|
|
apis, err := GetRotatedTidalAPIList()
|
|
if err == nil && len(apis) > 0 {
|
|
return apis, nil
|
|
}
|
|
|
|
return nil, err
|
|
}
|
|
|
|
func (t *TidalDownloader) GetTidalURLFromSpotify(spotifyTrackID string) (string, error) {
|
|
fmt.Println("Getting Tidal URL...")
|
|
client := NewSongLinkClient()
|
|
urls, err := client.GetAllURLsFromSpotify(spotifyTrackID, "")
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to get Tidal URL: %w", err)
|
|
}
|
|
|
|
tidalURL := urls.TidalURL
|
|
if tidalURL == "" {
|
|
return "", fmt.Errorf("tidal link not found")
|
|
}
|
|
fmt.Printf("Found Tidal URL: %s\n", tidalURL)
|
|
return tidalURL, nil
|
|
}
|
|
|
|
func (t *TidalDownloader) GetTrackIDFromURL(tidalURL string) (int64, error) {
|
|
|
|
parts := strings.Split(tidalURL, "/track/")
|
|
if len(parts) < 2 {
|
|
return 0, fmt.Errorf("invalid tidal URL format")
|
|
}
|
|
|
|
trackIDStr := strings.Split(parts[1], "?")[0]
|
|
trackIDStr = strings.TrimSpace(trackIDStr)
|
|
|
|
var trackID int64
|
|
_, err := fmt.Sscanf(trackIDStr, "%d", &trackID)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("failed to parse track ID: %w", err)
|
|
}
|
|
|
|
return trackID, nil
|
|
}
|
|
|
|
func (t *TidalDownloader) GetDownloadURL(trackID int64, quality string) (string, error) {
|
|
fmt.Println("Fetching URL...")
|
|
|
|
url := fmt.Sprintf("%s/track/?id=%d&quality=%s", t.apiURL, trackID, quality)
|
|
fmt.Printf("Tidal API URL: %s\n", url)
|
|
|
|
req, err := NewRequestWithDefaultHeaders(http.MethodGet, url, nil)
|
|
if err != nil {
|
|
fmt.Printf("✗ failed to create request: %v\n", err)
|
|
return "", fmt.Errorf("failed to create request: %w", err)
|
|
}
|
|
|
|
resp, err := t.client.Do(req)
|
|
if err != nil {
|
|
fmt.Printf("✗ Tidal API request failed: %v\n", err)
|
|
return "", fmt.Errorf("failed to get download URL: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != 200 {
|
|
fmt.Printf("✗ Tidal API returned status code: %d\n", resp.StatusCode)
|
|
return "", fmt.Errorf("API returned status code: %d", resp.StatusCode)
|
|
}
|
|
|
|
body, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
fmt.Printf("✗ Failed to read response body: %v\n", err)
|
|
return "", fmt.Errorf("failed to read response: %w", err)
|
|
}
|
|
|
|
var v2Response TidalAPIResponseV2
|
|
if err := json.Unmarshal(body, &v2Response); err == nil && v2Response.Data.Manifest != "" {
|
|
fmt.Println("✓ Tidal manifest found (v2 API)")
|
|
return "MANIFEST:" + v2Response.Data.Manifest, nil
|
|
}
|
|
|
|
var apiResponses []TidalAPIResponse
|
|
if err := json.Unmarshal(body, &apiResponses); err != nil {
|
|
|
|
bodyStr := string(body)
|
|
if len(bodyStr) > 200 {
|
|
bodyStr = bodyStr[:200] + "..."
|
|
}
|
|
fmt.Printf("✗ Failed to decode Tidal API response: %v (response: %s)\n", err, bodyStr)
|
|
return "", fmt.Errorf("failed to decode response: %w (response: %s)", err, bodyStr)
|
|
}
|
|
|
|
if len(apiResponses) == 0 {
|
|
fmt.Println("✗ Tidal API returned empty response")
|
|
return "", fmt.Errorf("no download URL in response")
|
|
}
|
|
|
|
for _, item := range apiResponses {
|
|
if item.OriginalTrackURL != "" {
|
|
fmt.Println("✓ Tidal download URL found")
|
|
return item.OriginalTrackURL, nil
|
|
}
|
|
}
|
|
|
|
fmt.Println("✗ No valid download URL in Tidal API response")
|
|
return "", fmt.Errorf("download URL not found in response")
|
|
}
|
|
|
|
func (t *TidalDownloader) DownloadFile(url, filepath string) error {
|
|
|
|
if strings.HasPrefix(url, "MANIFEST:") {
|
|
return t.DownloadFromManifest(strings.TrimPrefix(url, "MANIFEST:"), filepath)
|
|
}
|
|
|
|
req, err := NewRequestWithDefaultHeaders(http.MethodGet, url, nil)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create request: %w", err)
|
|
}
|
|
|
|
resp, err := t.client.Do(req)
|
|
|
|
if err != nil {
|
|
return fmt.Errorf("failed to download file: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != 200 {
|
|
return fmt.Errorf("download failed with status %d", resp.StatusCode)
|
|
}
|
|
|
|
out, err := os.Create(filepath)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create file: %w", err)
|
|
}
|
|
defer out.Close()
|
|
|
|
pw := NewProgressWriter(out)
|
|
_, err = io.Copy(pw, resp.Body)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to write file: %w", err)
|
|
}
|
|
|
|
fmt.Printf("\rDownloaded: %.2f MB (Complete)\n", float64(pw.GetTotal())/(1024*1024))
|
|
|
|
fmt.Println("Download complete")
|
|
return nil
|
|
}
|
|
|
|
func (t *TidalDownloader) DownloadFromManifest(manifestB64, outputPath string) error {
|
|
directURL, initURL, mediaURLs, mimeType, err := parseManifest(manifestB64)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to parse manifest: %w", err)
|
|
}
|
|
|
|
client := &http.Client{
|
|
Timeout: 120 * time.Second,
|
|
}
|
|
|
|
doRequest := func(url string) (*http.Response, error) {
|
|
req, err := NewRequestWithDefaultHeaders(http.MethodGet, url, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return client.Do(req)
|
|
}
|
|
|
|
if directURL != "" && (strings.Contains(strings.ToLower(mimeType), "flac") || mimeType == "") {
|
|
fmt.Println("Downloading file...")
|
|
|
|
resp, err := doRequest(directURL)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to download file: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != 200 {
|
|
return fmt.Errorf("download failed with status %d", resp.StatusCode)
|
|
}
|
|
|
|
out, err := os.Create(outputPath)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create file: %w", err)
|
|
}
|
|
defer out.Close()
|
|
|
|
pw := NewProgressWriter(out)
|
|
_, err = io.Copy(pw, resp.Body)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to write file: %w", err)
|
|
}
|
|
|
|
fmt.Printf("\rDownloaded: %.2f MB (Complete)\n", float64(pw.GetTotal())/(1024*1024))
|
|
fmt.Println("Download complete")
|
|
return nil
|
|
}
|
|
|
|
tempPath := outputPath + ".m4a.tmp"
|
|
|
|
if directURL != "" {
|
|
fmt.Printf("Downloading non-FLAC file (%s)...\n", mimeType)
|
|
|
|
resp, err := doRequest(directURL)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to download file: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != 200 {
|
|
return fmt.Errorf("download failed with status %d", resp.StatusCode)
|
|
}
|
|
|
|
out, err := os.Create(tempPath)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create temp file: %w", err)
|
|
}
|
|
|
|
pw := NewProgressWriter(out)
|
|
_, err = io.Copy(pw, resp.Body)
|
|
out.Close()
|
|
|
|
if err != nil {
|
|
os.Remove(tempPath)
|
|
return fmt.Errorf("failed to write temp file: %w", err)
|
|
}
|
|
|
|
fmt.Printf("\rDownloaded: %.2f MB (Complete)\n", float64(pw.GetTotal())/(1024*1024))
|
|
|
|
} else {
|
|
|
|
fmt.Printf("Downloading %d segments...\n", len(mediaURLs)+1)
|
|
|
|
out, err := os.Create(tempPath)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create temp file: %w", err)
|
|
}
|
|
|
|
fmt.Print("Downloading init segment... ")
|
|
resp, err := doRequest(initURL)
|
|
if err != nil {
|
|
out.Close()
|
|
os.Remove(tempPath)
|
|
return fmt.Errorf("failed to download init segment: %w", err)
|
|
}
|
|
if resp.StatusCode != 200 {
|
|
resp.Body.Close()
|
|
out.Close()
|
|
os.Remove(tempPath)
|
|
return fmt.Errorf("init segment download failed with status %d", resp.StatusCode)
|
|
}
|
|
_, err = io.Copy(out, resp.Body)
|
|
resp.Body.Close()
|
|
if err != nil {
|
|
out.Close()
|
|
os.Remove(tempPath)
|
|
return fmt.Errorf("failed to write init segment: %w", err)
|
|
}
|
|
fmt.Println("OK")
|
|
|
|
totalSegments := len(mediaURLs)
|
|
var totalBytes int64
|
|
lastTime := time.Now()
|
|
var lastBytes int64
|
|
for i, mediaURL := range mediaURLs {
|
|
resp, err := doRequest(mediaURL)
|
|
if err != nil {
|
|
out.Close()
|
|
os.Remove(tempPath)
|
|
return fmt.Errorf("failed to download segment %d: %w", i+1, err)
|
|
}
|
|
if resp.StatusCode != 200 {
|
|
resp.Body.Close()
|
|
out.Close()
|
|
os.Remove(tempPath)
|
|
return fmt.Errorf("segment %d download failed with status %d", i+1, resp.StatusCode)
|
|
}
|
|
n, err := io.Copy(out, resp.Body)
|
|
totalBytes += n
|
|
resp.Body.Close()
|
|
if err != nil {
|
|
out.Close()
|
|
os.Remove(tempPath)
|
|
return fmt.Errorf("failed to write segment %d: %w", i+1, err)
|
|
}
|
|
|
|
mbDownloaded := float64(totalBytes) / (1024 * 1024)
|
|
now := time.Now()
|
|
timeDiff := now.Sub(lastTime).Seconds()
|
|
var speedMBps float64
|
|
if timeDiff > 0.1 {
|
|
bytesDiff := float64(totalBytes - lastBytes)
|
|
speedMBps = (bytesDiff / (1024 * 1024)) / timeDiff
|
|
SetDownloadSpeed(speedMBps)
|
|
lastTime = now
|
|
lastBytes = totalBytes
|
|
}
|
|
SetDownloadProgress(mbDownloaded)
|
|
|
|
fmt.Printf("\rDownloading: %.2f MB (%d/%d segments)", mbDownloaded, i+1, totalSegments)
|
|
}
|
|
|
|
out.Close()
|
|
|
|
tempInfo, _ := os.Stat(tempPath)
|
|
fmt.Printf("\rDownloaded: %.2f MB (Complete) \n", float64(tempInfo.Size())/(1024*1024))
|
|
}
|
|
|
|
fmt.Println("Converting to FLAC...")
|
|
ffmpegPath, err := GetFFmpegPath()
|
|
if err != nil {
|
|
return fmt.Errorf("ffmpeg not found: %w", err)
|
|
}
|
|
|
|
if err := ValidateExecutable(ffmpegPath); err != nil {
|
|
return fmt.Errorf("invalid ffmpeg executable: %w", err)
|
|
}
|
|
|
|
cmd := exec.Command(ffmpegPath, "-y", "-i", tempPath, "-vn", "-c:a", "flac", outputPath)
|
|
setHideWindow(cmd)
|
|
var stderr strings.Builder
|
|
cmd.Stderr = &stderr
|
|
if err := cmd.Run(); err != nil {
|
|
|
|
m4aPath := strings.TrimSuffix(outputPath, ".flac") + ".m4a"
|
|
os.Rename(tempPath, m4aPath)
|
|
return fmt.Errorf("ffmpeg conversion failed (M4A saved as %s): %w - %s", m4aPath, err, stderr.String())
|
|
}
|
|
|
|
os.Remove(tempPath)
|
|
fmt.Println("Download complete")
|
|
|
|
return nil
|
|
}
|
|
|
|
func (t *TidalDownloader) DownloadByURL(tidalURL, outputDir, quality, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate string, useAlbumTrackNumber bool, spotifyCoverURL string, embedMaxQualityCover bool, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int, 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)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
if trackID == 0 {
|
|
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))
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
downloadURL, err := t.GetDownloadURL(trackID, quality)
|
|
if err != nil {
|
|
if isTidalHiResQuality(quality) && allowFallback {
|
|
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)
|
|
}
|
|
} else {
|
|
return "", 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
|
|
}
|
|
if t.apiURL != "" {
|
|
if err := RememberTidalAPIUsage(t.apiURL); err != nil {
|
|
fmt.Printf("Warning: failed to persist last used Tidal API: %v\n", err)
|
|
}
|
|
}
|
|
|
|
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")
|
|
}
|
|
|
|
fmt.Println("Done")
|
|
fmt.Println("✓ Downloaded successfully from Tidal")
|
|
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, 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)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
if trackID == 0 {
|
|
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))
|
|
}
|
|
|
|
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
|
|
}
|
|
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")
|
|
}
|
|
|
|
fmt.Println("Done")
|
|
fmt.Println("✓ Downloaded successfully from Tidal")
|
|
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, spotifyComposer, metadataSeparator, isrcOverride, spotifyURL string, allowFallback bool, useFirstArtistOnly bool, useSingleGenre bool, embedGenre 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, spotifyComposer, metadataSeparator, isrcOverride, spotifyURL, allowFallback, useFirstArtistOnly, useSingleGenre, embedGenre)
|
|
}
|
|
|
|
type SegmentTemplate struct {
|
|
Initialization string `xml:"initialization,attr"`
|
|
Media string `xml:"media,attr"`
|
|
Timeline struct {
|
|
Segments []struct {
|
|
Duration int64 `xml:"d,attr"`
|
|
Repeat int `xml:"r,attr"`
|
|
} `xml:"S"`
|
|
} `xml:"SegmentTimeline"`
|
|
}
|
|
|
|
type MPD struct {
|
|
XMLName xml.Name `xml:"MPD"`
|
|
Period struct {
|
|
AdaptationSets []struct {
|
|
MimeType string `xml:"mimeType,attr"`
|
|
Codecs string `xml:"codecs,attr"`
|
|
Representations []struct {
|
|
ID string `xml:"id,attr"`
|
|
Codecs string `xml:"codecs,attr"`
|
|
Bandwidth int `xml:"bandwidth,attr"`
|
|
SegmentTemplate *SegmentTemplate `xml:"SegmentTemplate"`
|
|
} `xml:"Representation"`
|
|
SegmentTemplate *SegmentTemplate `xml:"SegmentTemplate"`
|
|
} `xml:"AdaptationSet"`
|
|
} `xml:"Period"`
|
|
}
|
|
|
|
func parseManifest(manifestB64 string) (directURL string, initURL string, mediaURLs []string, mimeType string, err error) {
|
|
manifestBytes, err := base64.StdEncoding.DecodeString(manifestB64)
|
|
if err != nil {
|
|
return "", "", nil, "", fmt.Errorf("failed to decode manifest: %w", err)
|
|
}
|
|
|
|
manifestStr := string(manifestBytes)
|
|
|
|
if strings.HasPrefix(strings.TrimSpace(manifestStr), "{") {
|
|
var btsManifest TidalBTSManifest
|
|
if err := json.Unmarshal(manifestBytes, &btsManifest); err != nil {
|
|
return "", "", nil, "", fmt.Errorf("failed to parse BTS manifest: %w", err)
|
|
}
|
|
|
|
if len(btsManifest.URLs) == 0 {
|
|
return "", "", nil, "", fmt.Errorf("no URLs in BTS manifest")
|
|
}
|
|
|
|
fmt.Printf("Manifest: BTS format (%s, %s)\n", btsManifest.MimeType, btsManifest.Codecs)
|
|
return btsManifest.URLs[0], "", nil, btsManifest.MimeType, nil
|
|
}
|
|
|
|
fmt.Println("Manifest: DASH format")
|
|
|
|
var mpd MPD
|
|
var segTemplate *SegmentTemplate
|
|
|
|
if err := xml.Unmarshal(manifestBytes, &mpd); err == nil {
|
|
var selectedBandwidth int
|
|
var selectedCodecs string
|
|
|
|
for _, as := range mpd.Period.AdaptationSets {
|
|
|
|
if as.SegmentTemplate != nil {
|
|
|
|
if segTemplate == nil {
|
|
segTemplate = as.SegmentTemplate
|
|
selectedCodecs = as.Codecs
|
|
}
|
|
}
|
|
|
|
for _, rep := range as.Representations {
|
|
if rep.SegmentTemplate != nil {
|
|
if rep.Bandwidth > selectedBandwidth {
|
|
selectedBandwidth = rep.Bandwidth
|
|
segTemplate = rep.SegmentTemplate
|
|
|
|
if rep.Codecs != "" {
|
|
selectedCodecs = rep.Codecs
|
|
} else {
|
|
selectedCodecs = as.Codecs
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if selectedBandwidth > 0 {
|
|
fmt.Printf("Selected stream: Codec=%s, Bandwidth=%d bps\n", selectedCodecs, selectedBandwidth)
|
|
}
|
|
}
|
|
|
|
var mediaTemplate string
|
|
segmentCount := 0
|
|
|
|
if segTemplate != nil {
|
|
initURL = segTemplate.Initialization
|
|
mediaTemplate = segTemplate.Media
|
|
|
|
for _, seg := range segTemplate.Timeline.Segments {
|
|
segmentCount += seg.Repeat + 1
|
|
}
|
|
}
|
|
|
|
if segmentCount > 0 && initURL != "" && mediaTemplate != "" {
|
|
initURL = strings.ReplaceAll(initURL, "&", "&")
|
|
mediaTemplate = strings.ReplaceAll(mediaTemplate, "&", "&")
|
|
|
|
fmt.Printf("Parsed manifest via XML: %d segments\n", segmentCount)
|
|
|
|
for i := 1; i <= segmentCount; i++ {
|
|
mediaURL := strings.ReplaceAll(mediaTemplate, "$Number$", fmt.Sprintf("%d", i))
|
|
mediaURLs = append(mediaURLs, mediaURL)
|
|
}
|
|
return "", initURL, mediaURLs, "", nil
|
|
}
|
|
|
|
fmt.Println("Using regex fallback for DASH manifest...")
|
|
|
|
initRe := regexp.MustCompile(`initialization="([^"]+)"`)
|
|
mediaRe := regexp.MustCompile(`media="([^"]+)"`)
|
|
|
|
if match := initRe.FindStringSubmatch(manifestStr); len(match) > 1 {
|
|
initURL = match[1]
|
|
}
|
|
if match := mediaRe.FindStringSubmatch(manifestStr); len(match) > 1 {
|
|
mediaTemplate = match[1]
|
|
}
|
|
|
|
if initURL == "" {
|
|
return "", "", nil, "", fmt.Errorf("no initialization URL found in manifest")
|
|
}
|
|
|
|
initURL = strings.ReplaceAll(initURL, "&", "&")
|
|
mediaTemplate = strings.ReplaceAll(mediaTemplate, "&", "&")
|
|
|
|
segmentCount = 0
|
|
|
|
segTagRe := regexp.MustCompile(`<S\s+[^>]*>`)
|
|
matches := segTagRe.FindAllString(manifestStr, -1)
|
|
|
|
for _, match := range matches {
|
|
repeat := 0
|
|
rRe := regexp.MustCompile(`r="(\d+)"`)
|
|
if rMatch := rRe.FindStringSubmatch(match); len(rMatch) > 1 {
|
|
fmt.Sscanf(rMatch[1], "%d", &repeat)
|
|
}
|
|
segmentCount += repeat + 1
|
|
}
|
|
|
|
if segmentCount == 0 {
|
|
return "", "", nil, "", fmt.Errorf("no segments found in manifest (XML: %d, Regex: 0)", len(matches))
|
|
}
|
|
|
|
fmt.Printf("Parsed manifest via Regex: %d segments\n", segmentCount)
|
|
|
|
for i := 1; i <= segmentCount; i++ {
|
|
mediaURL := strings.ReplaceAll(mediaTemplate, "$Number$", fmt.Sprintf("%d", i))
|
|
mediaURLs = append(mediaURLs, mediaURL)
|
|
}
|
|
|
|
return "", initURL, mediaURLs, "", nil
|
|
}
|
|
|
|
func (t *TidalDownloader) downloadWithRotatingAPIs(trackID int64, outputFilename string, quality string, allowFallback bool) (string, error) {
|
|
qualities := []string{quality}
|
|
if isTidalHiResQuality(quality) && allowFallback {
|
|
qualities = append(qualities, "LOSSLESS")
|
|
}
|
|
|
|
var lastErr error
|
|
for idx, candidateQuality := range qualities {
|
|
if idx > 0 {
|
|
fmt.Printf("⚠ %s unavailable/failed on all APIs, falling back to %s...\n", quality, candidateQuality)
|
|
}
|
|
|
|
apiURL, err := t.tryDownloadAcrossTidalAPIs(trackID, outputFilename, candidateQuality, false)
|
|
if err == nil {
|
|
return apiURL, nil
|
|
}
|
|
lastErr = err
|
|
}
|
|
|
|
if lastErr == nil {
|
|
lastErr = fmt.Errorf("no tidal api succeeded")
|
|
}
|
|
return "", lastErr
|
|
}
|
|
|
|
func (t *TidalDownloader) tryDownloadAcrossTidalAPIs(trackID int64, outputFilename string, quality string, refreshed bool) (string, error) {
|
|
apis, err := GetRotatedTidalAPIList()
|
|
if err != nil && len(apis) == 0 {
|
|
return "", fmt.Errorf("failed to load tidal api list: %w", err)
|
|
}
|
|
if len(apis) == 0 {
|
|
return "", fmt.Errorf("no tidal apis available")
|
|
}
|
|
|
|
var lastErr error
|
|
errors := make([]string, 0, len(apis))
|
|
|
|
for _, apiURL := range apis {
|
|
fmt.Printf("Trying Tidal API: %s\n", apiURL)
|
|
|
|
downloader := NewTidalDownloader(apiURL)
|
|
downloadURL, err := downloader.GetDownloadURL(trackID, quality)
|
|
if err != nil {
|
|
lastErr = err
|
|
errors = append(errors, fmt.Sprintf("%s: %v", apiURL, err))
|
|
continue
|
|
}
|
|
|
|
if err := downloader.DownloadFile(downloadURL, outputFilename); err != nil {
|
|
lastErr = err
|
|
cleanupTidalDownloadArtifacts(outputFilename)
|
|
errors = append(errors, fmt.Sprintf("%s: %v", apiURL, err))
|
|
continue
|
|
}
|
|
|
|
if err := RememberTidalAPIUsage(apiURL); err != nil {
|
|
fmt.Printf("Warning: failed to persist last used Tidal API: %v\n", err)
|
|
}
|
|
|
|
return apiURL, nil
|
|
}
|
|
|
|
if !refreshed {
|
|
if _, refreshErr := RefreshTidalAPIList(true); refreshErr != nil {
|
|
errors = append(errors, fmt.Sprintf("gist refresh failed: %v", refreshErr))
|
|
} else {
|
|
fmt.Println("All cached Tidal APIs failed, refreshed gist list and retrying...")
|
|
return t.tryDownloadAcrossTidalAPIs(trackID, outputFilename, quality, true)
|
|
}
|
|
}
|
|
|
|
if lastErr == nil {
|
|
lastErr = fmt.Errorf("all tidal apis failed")
|
|
}
|
|
|
|
fmt.Println("All Tidal APIs failed:")
|
|
for _, item := range errors {
|
|
fmt.Printf(" ✗ %s\n", item)
|
|
}
|
|
|
|
return "", fmt.Errorf("all tidal apis failed for quality %s: %w", quality, lastErr)
|
|
}
|
|
|
|
func cleanupTidalDownloadArtifacts(outputPath string) {
|
|
if outputPath == "" {
|
|
return
|
|
}
|
|
|
|
_ = os.Remove(outputPath)
|
|
_ = os.Remove(outputPath + ".m4a.tmp")
|
|
}
|
|
|
|
func isTidalHiResQuality(quality string) bool {
|
|
normalized := strings.TrimSpace(strings.ToUpper(quality))
|
|
return normalized == "HI_RES" || normalized == "HI_RES_LOSSLESS"
|
|
}
|
|
|
|
func buildTidalFilename(title, artist, album, albumArtist, releaseDate string, trackNumber, discNumber int, format string, includeTrackNumber bool, position int, useAlbumTrackNumber bool, extra ...string) string {
|
|
var filename string
|
|
isrc := ""
|
|
if len(extra) > 0 {
|
|
isrc = SanitizeOptionalFilename(extra[0])
|
|
}
|
|
|
|
numberToUse := position
|
|
if useAlbumTrackNumber && trackNumber > 0 {
|
|
numberToUse = trackNumber
|
|
}
|
|
|
|
year := ""
|
|
if len(releaseDate) >= 4 {
|
|
year = releaseDate[:4]
|
|
}
|
|
|
|
if strings.Contains(format, "{") {
|
|
filename = format
|
|
filename = strings.ReplaceAll(filename, "{title}", title)
|
|
filename = strings.ReplaceAll(filename, "{artist}", artist)
|
|
filename = strings.ReplaceAll(filename, "{album}", album)
|
|
filename = strings.ReplaceAll(filename, "{album_artist}", albumArtist)
|
|
filename = strings.ReplaceAll(filename, "{year}", year)
|
|
filename = strings.ReplaceAll(filename, "{date}", SanitizeFilename(releaseDate))
|
|
filename = strings.ReplaceAll(filename, "{isrc}", isrc)
|
|
|
|
if discNumber > 0 {
|
|
filename = strings.ReplaceAll(filename, "{disc}", fmt.Sprintf("%d", discNumber))
|
|
} else {
|
|
filename = strings.ReplaceAll(filename, "{disc}", "")
|
|
}
|
|
|
|
if numberToUse > 0 {
|
|
filename = strings.ReplaceAll(filename, "{track}", fmt.Sprintf("%02d", numberToUse))
|
|
} else {
|
|
|
|
filename = regexp.MustCompile(`\{track\}\.\s*`).ReplaceAllString(filename, "")
|
|
filename = regexp.MustCompile(`\{track\}\s*-\s*`).ReplaceAllString(filename, "")
|
|
filename = regexp.MustCompile(`\{track\}\s*`).ReplaceAllString(filename, "")
|
|
}
|
|
} else {
|
|
|
|
switch format {
|
|
case "artist-title":
|
|
filename = fmt.Sprintf("%s - %s", artist, title)
|
|
case "title":
|
|
filename = title
|
|
default:
|
|
filename = fmt.Sprintf("%s - %s", title, artist)
|
|
}
|
|
|
|
if includeTrackNumber && position > 0 {
|
|
filename = fmt.Sprintf("%02d. %s", numberToUse, filename)
|
|
}
|
|
}
|
|
|
|
return filename + ".flac"
|
|
}
|