mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-08-18 08:57:13 +02:00
v2.2.5: In-app logging, ISP blocking detection, Latin script fix
This commit is contained in:
+19
-19
@@ -88,7 +88,7 @@ func amazonArtistsMatch(expectedArtist, foundArtist string) bool {
|
||||
expectedASCII := amazonIsASCIIString(expectedArtist)
|
||||
foundASCII := amazonIsASCIIString(foundArtist)
|
||||
if expectedASCII != foundASCII {
|
||||
fmt.Printf("[Amazon] Artist names in different scripts, assuming match: '%s' vs '%s'\n", expectedArtist, foundArtist)
|
||||
GoLog("[Amazon] Artist names in different scripts, assuming match: '%s' vs '%s'\n", expectedArtist, foundArtist)
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -135,7 +135,7 @@ func (a *AmazonDownloader) waitForRateLimit() {
|
||||
if a.apiCallCount >= 9 {
|
||||
waitTime := time.Minute - now.Sub(a.apiCallResetTime)
|
||||
if waitTime > 0 {
|
||||
fmt.Printf("[Amazon] Rate limit reached, waiting %v...\n", waitTime.Round(time.Second))
|
||||
GoLog("[Amazon] Rate limit reached, waiting %v...\n", waitTime.Round(time.Second))
|
||||
time.Sleep(waitTime)
|
||||
a.apiCallCount = 0
|
||||
a.apiCallResetTime = time.Now()
|
||||
@@ -148,7 +148,7 @@ func (a *AmazonDownloader) waitForRateLimit() {
|
||||
minDelay := 7 * time.Second
|
||||
if timeSinceLastCall < minDelay {
|
||||
waitTime := minDelay - timeSinceLastCall
|
||||
fmt.Printf("[Amazon] Rate limiting: waiting %v...\n", waitTime.Round(time.Second))
|
||||
GoLog("[Amazon] Rate limiting: waiting %v...\n", waitTime.Round(time.Second))
|
||||
time.Sleep(waitTime)
|
||||
}
|
||||
}
|
||||
@@ -177,7 +177,7 @@ func (a *AmazonDownloader) downloadFromDoubleDoubleService(amazonURL, outputDir
|
||||
var lastError error
|
||||
|
||||
for _, region := range a.regions {
|
||||
fmt.Printf("[Amazon] Trying region: %s...\n", region)
|
||||
GoLog("[Amazon] Trying region: %s...\n", region)
|
||||
|
||||
// Build base URL for DoubleDouble service
|
||||
// Decode base64 service URL (same as PC)
|
||||
@@ -216,7 +216,7 @@ func (a *AmazonDownloader) downloadFromDoubleDoubleService(amazonURL, outputDir
|
||||
resp.Body.Close()
|
||||
if retry < maxRetries-1 {
|
||||
waitTime := 15 * time.Second
|
||||
fmt.Printf("[Amazon] Rate limited (429), waiting %v before retry %d/%d...\n", waitTime, retry+2, maxRetries)
|
||||
GoLog("[Amazon] Rate limited (429), waiting %v before retry %d/%d...\n", waitTime, retry+2, maxRetries)
|
||||
time.Sleep(waitTime)
|
||||
continue
|
||||
}
|
||||
@@ -255,7 +255,7 @@ func (a *AmazonDownloader) downloadFromDoubleDoubleService(amazonURL, outputDir
|
||||
}
|
||||
|
||||
downloadID := submitResp.ID
|
||||
fmt.Printf("[Amazon] Download ID: %s\n", downloadID)
|
||||
GoLog("[Amazon] Download ID: %s\n", downloadID)
|
||||
|
||||
// Step 2: Poll for completion
|
||||
statusURL := fmt.Sprintf("%s/dl/%s", baseURL, downloadID)
|
||||
@@ -310,7 +310,7 @@ func (a *AmazonDownloader) downloadFromDoubleDoubleService(amazonURL, outputDir
|
||||
trackName := status.Current.Name
|
||||
artist := status.Current.Artist
|
||||
|
||||
fmt.Printf("[Amazon] Downloading: %s - %s\n", artist, trackName)
|
||||
GoLog("[Amazon] Downloading: %s - %s\n", artist, trackName)
|
||||
return fileURL, trackName, artist, nil
|
||||
|
||||
} else if status.Status == "error" {
|
||||
@@ -454,7 +454,7 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
||||
if strings.HasPrefix(req.SpotifyID, "deezer:") {
|
||||
// Extract Deezer ID and use Deezer-based lookup
|
||||
deezerID := strings.TrimPrefix(req.SpotifyID, "deezer:")
|
||||
fmt.Printf("[Amazon] Using Deezer ID for SongLink lookup: %s\n", deezerID)
|
||||
GoLog("[Amazon] Using Deezer ID for SongLink lookup: %s\n", deezerID)
|
||||
availability, err = songlink.CheckAvailabilityFromDeezer(deezerID)
|
||||
} else if req.SpotifyID != "" {
|
||||
// Use Spotify ID
|
||||
@@ -486,12 +486,12 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
||||
|
||||
// Verify artist matches
|
||||
if artistName != "" && !amazonArtistsMatch(req.ArtistName, artistName) {
|
||||
fmt.Printf("[Amazon] Artist mismatch: expected '%s', got '%s'. Rejecting.\n", req.ArtistName, artistName)
|
||||
GoLog("[Amazon] Artist mismatch: expected '%s', got '%s'. Rejecting.\n", req.ArtistName, artistName)
|
||||
return AmazonDownloadResult{}, fmt.Errorf("artist mismatch: expected '%s', got '%s'", req.ArtistName, artistName)
|
||||
}
|
||||
|
||||
// Log match found
|
||||
fmt.Printf("[Amazon] Match found: '%s' by '%s'\n", trackName, artistName)
|
||||
GoLog("[Amazon] Match found: '%s' by '%s'\n", trackName, artistName)
|
||||
|
||||
// Build filename using Spotify metadata (more accurate)
|
||||
filename := buildFilenameFromTemplate(req.FilenameFormat, map[string]interface{}{
|
||||
@@ -542,7 +542,7 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
||||
|
||||
// Log track info from DoubleDouble (for debugging)
|
||||
if trackName != "" && artistName != "" {
|
||||
fmt.Printf("[Amazon] DoubleDouble returned: %s - %s\n", artistName, trackName)
|
||||
GoLog("[Amazon] DoubleDouble returned: %s - %s\n", artistName, trackName)
|
||||
}
|
||||
|
||||
// Read existing metadata from downloaded file BEFORE embedding
|
||||
@@ -555,11 +555,11 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
||||
// Use file metadata if it has valid track/disc numbers and request doesn't have them
|
||||
if existingMeta.TrackNumber > 0 && (req.TrackNumber == 0 || req.TrackNumber == 1) {
|
||||
actualTrackNum = existingMeta.TrackNumber
|
||||
fmt.Printf("[Amazon] Using track number from file: %d (request had: %d)\n", actualTrackNum, req.TrackNumber)
|
||||
GoLog("[Amazon] Using track number from file: %d (request had: %d)\n", actualTrackNum, req.TrackNumber)
|
||||
}
|
||||
if existingMeta.DiscNumber > 0 && (req.DiscNumber == 0 || req.DiscNumber == 1) {
|
||||
actualDiscNum = existingMeta.DiscNumber
|
||||
fmt.Printf("[Amazon] Using disc number from file: %d (request had: %d)\n", actualDiscNum, req.DiscNumber)
|
||||
GoLog("[Amazon] Using disc number from file: %d (request had: %d)\n", actualDiscNum, req.DiscNumber)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -581,7 +581,7 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
||||
var coverData []byte
|
||||
if parallelResult != nil && parallelResult.CoverData != nil {
|
||||
coverData = parallelResult.CoverData
|
||||
fmt.Printf("[Amazon] Using parallel-fetched cover (%d bytes)\n", len(coverData))
|
||||
GoLog("[Amazon] Using parallel-fetched cover (%d bytes)\n", len(coverData))
|
||||
}
|
||||
|
||||
if err := EmbedMetadataWithCoverData(outputPath, metadata, coverData); err != nil {
|
||||
@@ -590,9 +590,9 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
||||
|
||||
// Embed lyrics from parallel fetch
|
||||
if req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" {
|
||||
fmt.Printf("[Amazon] Embedding parallel-fetched lyrics (%d lines)...\n", len(parallelResult.LyricsData.Lines))
|
||||
GoLog("[Amazon] Embedding parallel-fetched lyrics (%d lines)...\n", len(parallelResult.LyricsData.Lines))
|
||||
if embedErr := EmbedLyrics(outputPath, parallelResult.LyricsLRC); embedErr != nil {
|
||||
fmt.Printf("[Amazon] Warning: failed to embed lyrics: %v\n", embedErr)
|
||||
GoLog("[Amazon] Warning: failed to embed lyrics: %v\n", embedErr)
|
||||
} else {
|
||||
fmt.Println("[Amazon] Lyrics embedded successfully")
|
||||
}
|
||||
@@ -606,16 +606,16 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
||||
// Amazon API doesn't provide quality info, but we can read it from the file itself
|
||||
quality, err := GetAudioQuality(outputPath)
|
||||
if err != nil {
|
||||
fmt.Printf("[Amazon] Warning: couldn't read quality from file: %v\n", err)
|
||||
GoLog("[Amazon] Warning: couldn't read quality from file: %v\n", err)
|
||||
} else {
|
||||
fmt.Printf("[Amazon] Actual quality: %d-bit/%dHz\n", quality.BitDepth, quality.SampleRate)
|
||||
GoLog("[Amazon] Actual quality: %d-bit/%dHz\n", quality.BitDepth, quality.SampleRate)
|
||||
}
|
||||
|
||||
// Read metadata from file AFTER embedding to get accurate values
|
||||
// This ensures we return what's actually in the file
|
||||
finalMeta, metaReadErr := ReadMetadata(outputPath)
|
||||
if metaReadErr == nil && finalMeta != nil {
|
||||
fmt.Printf("[Amazon] Final metadata from file - Track: %d, Disc: %d, Date: %s\n",
|
||||
GoLog("[Amazon] Final metadata from file - Track: %d, Disc: %d, Date: %s\n",
|
||||
finalMeta.TrackNumber, finalMeta.DiscNumber, finalMeta.Date)
|
||||
actualTrackNum = finalMeta.TrackNumber
|
||||
actualDiscNum = finalMeta.DiscNumber
|
||||
|
||||
+16
-12
@@ -286,9 +286,9 @@ func DownloadTrack(requestJSON string) (string, error) {
|
||||
if qErr == nil {
|
||||
result.BitDepth = quality.BitDepth
|
||||
result.SampleRate = quality.SampleRate
|
||||
fmt.Printf("[Download] Actual quality from file: %d-bit/%dHz\n", quality.BitDepth, quality.SampleRate)
|
||||
GoLog("[Download] Actual quality from file: %d-bit/%dHz\n", quality.BitDepth, quality.SampleRate)
|
||||
} else {
|
||||
fmt.Printf("[Download] Could not read quality from file: %v\n", qErr)
|
||||
GoLog("[Download] Could not read quality from file: %v\n", qErr)
|
||||
}
|
||||
|
||||
resp := DownloadResponse{
|
||||
@@ -333,7 +333,7 @@ func DownloadWithFallback(requestJSON string) (string, error) {
|
||||
preferredService = "tidal"
|
||||
}
|
||||
|
||||
fmt.Printf("[DownloadWithFallback] Preferred service from request: '%s'\n", req.Service)
|
||||
GoLog("[DownloadWithFallback] Preferred service from request: '%s'\n", req.Service)
|
||||
|
||||
// Create ordered list: preferred first, then others
|
||||
services := []string{preferredService}
|
||||
@@ -343,12 +343,12 @@ func DownloadWithFallback(requestJSON string) (string, error) {
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Printf("[DownloadWithFallback] Service order: %v\n", services)
|
||||
GoLog("[DownloadWithFallback] Service order: %v\n", services)
|
||||
|
||||
var lastErr error
|
||||
|
||||
for _, service := range services {
|
||||
fmt.Printf("[DownloadWithFallback] Trying service: %s\n", service)
|
||||
GoLog("[DownloadWithFallback] Trying service: %s\n", service)
|
||||
req.Service = service
|
||||
|
||||
var result DownloadResult
|
||||
@@ -371,7 +371,7 @@ func DownloadWithFallback(requestJSON string) (string, error) {
|
||||
ISRC: tidalResult.ISRC,
|
||||
}
|
||||
} else {
|
||||
fmt.Printf("[DownloadWithFallback] Tidal error: %v\n", tidalErr)
|
||||
GoLog("[DownloadWithFallback] Tidal error: %v\n", tidalErr)
|
||||
}
|
||||
err = tidalErr
|
||||
case "qobuz":
|
||||
@@ -390,7 +390,7 @@ func DownloadWithFallback(requestJSON string) (string, error) {
|
||||
ISRC: qobuzResult.ISRC,
|
||||
}
|
||||
} else {
|
||||
fmt.Printf("[DownloadWithFallback] Qobuz error: %v\n", qobuzErr)
|
||||
GoLog("[DownloadWithFallback] Qobuz error: %v\n", qobuzErr)
|
||||
}
|
||||
err = qobuzErr
|
||||
case "amazon":
|
||||
@@ -409,7 +409,7 @@ func DownloadWithFallback(requestJSON string) (string, error) {
|
||||
ISRC: amazonResult.ISRC,
|
||||
}
|
||||
} else {
|
||||
fmt.Printf("[DownloadWithFallback] Amazon error: %v\n", amazonErr)
|
||||
GoLog("[DownloadWithFallback] Amazon error: %v\n", amazonErr)
|
||||
}
|
||||
err = amazonErr
|
||||
}
|
||||
@@ -449,9 +449,9 @@ func DownloadWithFallback(requestJSON string) (string, error) {
|
||||
if qErr == nil {
|
||||
result.BitDepth = quality.BitDepth
|
||||
result.SampleRate = quality.SampleRate
|
||||
fmt.Printf("[Download] Actual quality from file: %d-bit/%dHz\n", quality.BitDepth, quality.SampleRate)
|
||||
GoLog("[Download] Actual quality from file: %d-bit/%dHz\n", quality.BitDepth, quality.SampleRate)
|
||||
} else {
|
||||
fmt.Printf("[Download] Could not read quality from file: %v\n", qErr)
|
||||
GoLog("[Download] Could not read quality from file: %v\n", qErr)
|
||||
}
|
||||
|
||||
resp := DownloadResponse{
|
||||
@@ -904,7 +904,7 @@ func GetSpotifyMetadataWithDeezerFallback(spotifyURL string) (string, error) {
|
||||
return "", fmt.Errorf("spotify rate limited and failed to parse URL: %w", parseErr)
|
||||
}
|
||||
|
||||
fmt.Printf("[Fallback] Spotify rate limited for %s, trying Deezer...\n", parsed.Type)
|
||||
GoLog("[Fallback] Spotify rate limited for %s, trying Deezer...\n", parsed.Type)
|
||||
|
||||
if parsed.Type == "track" || parsed.Type == "album" {
|
||||
// Convert to Deezer
|
||||
@@ -980,7 +980,11 @@ func errorResponse(msg string) (string, error) {
|
||||
errorType := "unknown"
|
||||
lowerMsg := strings.ToLower(msg)
|
||||
|
||||
if strings.Contains(lowerMsg, "not found") ||
|
||||
if strings.Contains(lowerMsg, "isp blocking") ||
|
||||
strings.Contains(lowerMsg, "try using vpn") ||
|
||||
strings.Contains(lowerMsg, "change dns") {
|
||||
errorType = "isp_blocked"
|
||||
} else if strings.Contains(lowerMsg, "not found") ||
|
||||
strings.Contains(lowerMsg, "not available") ||
|
||||
strings.Contains(lowerMsg, "no results") ||
|
||||
strings.Contains(lowerMsg, "track not found") ||
|
||||
|
||||
+218
-1
@@ -1,12 +1,17 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math/rand"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -134,9 +139,15 @@ func CloseIdleConnections() {
|
||||
}
|
||||
|
||||
// DoRequestWithUserAgent executes an HTTP request with a random User-Agent header
|
||||
// Also checks for ISP blocking on errors
|
||||
func DoRequestWithUserAgent(client *http.Client, req *http.Request) (*http.Response, error) {
|
||||
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||
return client.Do(req)
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
// Check for ISP blocking
|
||||
CheckAndLogISPBlocking(err, req.URL.String(), "HTTP")
|
||||
}
|
||||
return resp, err
|
||||
}
|
||||
|
||||
// RetryConfig holds configuration for retry logic
|
||||
@@ -159,9 +170,11 @@ func DefaultRetryConfig() RetryConfig {
|
||||
|
||||
// DoRequestWithRetry executes an HTTP request with retry logic and exponential backoff
|
||||
// Handles 429 (Too Many Requests) responses with Retry-After header
|
||||
// Also detects and logs ISP blocking
|
||||
func DoRequestWithRetry(client *http.Client, req *http.Request, config RetryConfig) (*http.Response, error) {
|
||||
var lastErr error
|
||||
delay := config.InitialDelay
|
||||
requestURL := req.URL.String()
|
||||
|
||||
for attempt := 0; attempt <= config.MaxRetries; attempt++ {
|
||||
// Clone request for retry (body needs to be re-readable)
|
||||
@@ -171,7 +184,16 @@ func DoRequestWithRetry(client *http.Client, req *http.Request, config RetryConf
|
||||
resp, err := client.Do(reqCopy)
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
|
||||
// Check for ISP blocking on network errors
|
||||
if CheckAndLogISPBlocking(err, requestURL, "HTTP") {
|
||||
// Don't retry if ISP blocking is detected - it won't help
|
||||
return nil, WrapErrorWithISPCheck(err, requestURL, "HTTP")
|
||||
}
|
||||
|
||||
if attempt < config.MaxRetries {
|
||||
GoLog("[HTTP] Request failed (attempt %d/%d): %v, retrying in %v...\n",
|
||||
attempt+1, config.MaxRetries+1, err, delay)
|
||||
time.Sleep(delay)
|
||||
delay = calculateNextDelay(delay, config)
|
||||
}
|
||||
@@ -192,17 +214,43 @@ func DoRequestWithRetry(client *http.Client, req *http.Request, config RetryConf
|
||||
}
|
||||
lastErr = fmt.Errorf("rate limited (429)")
|
||||
if attempt < config.MaxRetries {
|
||||
GoLog("[HTTP] Rate limited, waiting %v before retry...\n", delay)
|
||||
time.Sleep(delay)
|
||||
delay = calculateNextDelay(delay, config)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Check for ISP blocking via HTTP status codes
|
||||
// Some ISPs return 403 or 451 when blocking content
|
||||
if resp.StatusCode == 403 || resp.StatusCode == 451 {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
bodyStr := strings.ToLower(string(body))
|
||||
|
||||
// Check if response looks like ISP blocking page
|
||||
ispBlockingIndicators := []string{
|
||||
"blocked", "forbidden", "access denied", "not available in your",
|
||||
"restricted", "censored", "unavailable for legal", "blocked by",
|
||||
}
|
||||
|
||||
for _, indicator := range ispBlockingIndicators {
|
||||
if strings.Contains(bodyStr, indicator) {
|
||||
LogError("HTTP", "ISP BLOCKING DETECTED via HTTP %d response", resp.StatusCode)
|
||||
LogError("HTTP", "Domain: %s", req.URL.Host)
|
||||
LogError("HTTP", "Response contains: %s", indicator)
|
||||
LogError("HTTP", "Suggestion: Try using a VPN or changing your DNS to 1.1.1.1 or 8.8.8.8")
|
||||
return nil, fmt.Errorf("ISP blocking detected for %s (HTTP %d) - try using VPN or change DNS", req.URL.Host, resp.StatusCode)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Server errors (5xx) - retry
|
||||
if resp.StatusCode >= 500 {
|
||||
resp.Body.Close()
|
||||
lastErr = fmt.Errorf("server error: HTTP %d", resp.StatusCode)
|
||||
if attempt < config.MaxRetries {
|
||||
GoLog("[HTTP] Server error %d, retrying in %v...\n", resp.StatusCode, delay)
|
||||
time.Sleep(delay)
|
||||
delay = calculateNextDelay(delay, config)
|
||||
}
|
||||
@@ -296,3 +344,172 @@ func BuildErrorMessage(apiURL string, statusCode int, responsePreview string) st
|
||||
}
|
||||
return msg
|
||||
}
|
||||
|
||||
// ISPBlockingError represents an error caused by ISP blocking
|
||||
type ISPBlockingError struct {
|
||||
Domain string
|
||||
Reason string
|
||||
OriginalErr error
|
||||
}
|
||||
|
||||
func (e *ISPBlockingError) Error() string {
|
||||
return fmt.Sprintf("ISP blocking detected for %s: %s", e.Domain, e.Reason)
|
||||
}
|
||||
|
||||
// IsISPBlocking checks if an error is likely caused by ISP blocking
|
||||
// Returns the ISPBlockingError if detected, nil otherwise
|
||||
func IsISPBlocking(err error, requestURL string) *ISPBlockingError {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Extract domain from URL
|
||||
domain := extractDomain(requestURL)
|
||||
errStr := strings.ToLower(err.Error())
|
||||
|
||||
// Check for DNS resolution failure (common ISP blocking method)
|
||||
var dnsErr *net.DNSError
|
||||
if errors.As(err, &dnsErr) {
|
||||
if dnsErr.IsNotFound || dnsErr.IsTemporary {
|
||||
return &ISPBlockingError{
|
||||
Domain: domain,
|
||||
Reason: "DNS resolution failed - domain may be blocked by ISP",
|
||||
OriginalErr: err,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for connection refused (ISP firewall blocking)
|
||||
var opErr *net.OpError
|
||||
if errors.As(err, &opErr) {
|
||||
if opErr.Op == "dial" {
|
||||
// Check for specific syscall errors
|
||||
var syscallErr syscall.Errno
|
||||
if errors.As(opErr.Err, &syscallErr) {
|
||||
switch syscallErr {
|
||||
case syscall.ECONNREFUSED:
|
||||
return &ISPBlockingError{
|
||||
Domain: domain,
|
||||
Reason: "Connection refused - port may be blocked by ISP/firewall",
|
||||
OriginalErr: err,
|
||||
}
|
||||
case syscall.ECONNRESET:
|
||||
return &ISPBlockingError{
|
||||
Domain: domain,
|
||||
Reason: "Connection reset - ISP may be intercepting traffic",
|
||||
OriginalErr: err,
|
||||
}
|
||||
case syscall.ETIMEDOUT:
|
||||
return &ISPBlockingError{
|
||||
Domain: domain,
|
||||
Reason: "Connection timed out - ISP may be blocking access",
|
||||
OriginalErr: err,
|
||||
}
|
||||
case syscall.ENETUNREACH:
|
||||
return &ISPBlockingError{
|
||||
Domain: domain,
|
||||
Reason: "Network unreachable - ISP may be blocking route",
|
||||
OriginalErr: err,
|
||||
}
|
||||
case syscall.EHOSTUNREACH:
|
||||
return &ISPBlockingError{
|
||||
Domain: domain,
|
||||
Reason: "Host unreachable - ISP may be blocking destination",
|
||||
OriginalErr: err,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for TLS handshake failure (ISP MITM or blocking HTTPS)
|
||||
var tlsErr *tls.RecordHeaderError
|
||||
if errors.As(err, &tlsErr) {
|
||||
return &ISPBlockingError{
|
||||
Domain: domain,
|
||||
Reason: "TLS handshake failed - ISP may be intercepting HTTPS traffic",
|
||||
OriginalErr: err,
|
||||
}
|
||||
}
|
||||
|
||||
// Check error message patterns for common ISP blocking indicators
|
||||
blockingPatterns := []struct {
|
||||
pattern string
|
||||
reason string
|
||||
}{
|
||||
{"connection reset by peer", "Connection reset - ISP may be intercepting traffic"},
|
||||
{"connection refused", "Connection refused - port may be blocked"},
|
||||
{"no such host", "DNS lookup failed - domain may be blocked by ISP"},
|
||||
{"i/o timeout", "Connection timed out - ISP may be blocking access"},
|
||||
{"network is unreachable", "Network unreachable - ISP may be blocking route"},
|
||||
{"tls: ", "TLS error - ISP may be intercepting HTTPS traffic"},
|
||||
{"certificate", "Certificate error - ISP may be using MITM proxy"},
|
||||
{"eof", "Connection closed unexpectedly - ISP may be blocking"},
|
||||
{"context deadline exceeded", "Request timed out - ISP may be throttling"},
|
||||
}
|
||||
|
||||
for _, bp := range blockingPatterns {
|
||||
if strings.Contains(errStr, bp.pattern) {
|
||||
return &ISPBlockingError{
|
||||
Domain: domain,
|
||||
Reason: bp.reason,
|
||||
OriginalErr: err,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// CheckAndLogISPBlocking checks for ISP blocking and logs if detected
|
||||
// Returns true if ISP blocking was detected
|
||||
func CheckAndLogISPBlocking(err error, requestURL string, tag string) bool {
|
||||
ispErr := IsISPBlocking(err, requestURL)
|
||||
if ispErr != nil {
|
||||
LogError(tag, "ISP BLOCKING DETECTED: %s", ispErr.Error())
|
||||
LogError(tag, "Domain: %s", ispErr.Domain)
|
||||
LogError(tag, "Reason: %s", ispErr.Reason)
|
||||
LogError(tag, "Original error: %v", ispErr.OriginalErr)
|
||||
LogError(tag, "Suggestion: Try using a VPN or changing your DNS to 1.1.1.1 or 8.8.8.8")
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// extractDomain extracts the domain from a URL string
|
||||
func extractDomain(rawURL string) string {
|
||||
if rawURL == "" {
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
parsed, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
// Try to extract domain manually
|
||||
rawURL = strings.TrimPrefix(rawURL, "https://")
|
||||
rawURL = strings.TrimPrefix(rawURL, "http://")
|
||||
if idx := strings.Index(rawURL, "/"); idx > 0 {
|
||||
return rawURL[:idx]
|
||||
}
|
||||
return rawURL
|
||||
}
|
||||
|
||||
if parsed.Host != "" {
|
||||
return parsed.Host
|
||||
}
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
// WrapErrorWithISPCheck wraps an error with ISP blocking detection
|
||||
// If ISP blocking is detected, returns a more descriptive error
|
||||
func WrapErrorWithISPCheck(err error, requestURL string, tag string) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if CheckAndLogISPBlocking(err, requestURL, tag) {
|
||||
domain := extractDomain(requestURL)
|
||||
return fmt.Errorf("ISP blocking detected for %s - try using VPN or change DNS to 1.1.1.1/8.8.8.8: %w", domain, err)
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// LogEntry represents a single log entry
|
||||
type LogEntry struct {
|
||||
Timestamp string `json:"timestamp"`
|
||||
Level string `json:"level"`
|
||||
Tag string `json:"tag"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// LogBuffer stores logs in a circular buffer for retrieval by Flutter
|
||||
type LogBuffer struct {
|
||||
entries []LogEntry
|
||||
maxSize int
|
||||
mu sync.RWMutex
|
||||
loggingEnabled bool // Whether logging is enabled (controlled by Flutter)
|
||||
}
|
||||
|
||||
var (
|
||||
globalLogBuffer *LogBuffer
|
||||
logBufferOnce sync.Once
|
||||
)
|
||||
|
||||
// GetLogBuffer returns the singleton log buffer instance
|
||||
func GetLogBuffer() *LogBuffer {
|
||||
logBufferOnce.Do(func() {
|
||||
globalLogBuffer = &LogBuffer{
|
||||
entries: make([]LogEntry, 0, 500),
|
||||
maxSize: 500,
|
||||
loggingEnabled: false, // Default: disabled for performance
|
||||
}
|
||||
})
|
||||
return globalLogBuffer
|
||||
}
|
||||
|
||||
// SetLoggingEnabled enables or disables logging
|
||||
func (lb *LogBuffer) SetLoggingEnabled(enabled bool) {
|
||||
lb.mu.Lock()
|
||||
defer lb.mu.Unlock()
|
||||
lb.loggingEnabled = enabled
|
||||
}
|
||||
|
||||
// IsLoggingEnabled returns whether logging is enabled
|
||||
func (lb *LogBuffer) IsLoggingEnabled() bool {
|
||||
lb.mu.RLock()
|
||||
defer lb.mu.RUnlock()
|
||||
return lb.loggingEnabled
|
||||
}
|
||||
|
||||
// Add adds a log entry to the buffer
|
||||
func (lb *LogBuffer) Add(level, tag, message string) {
|
||||
lb.mu.Lock()
|
||||
defer lb.mu.Unlock()
|
||||
|
||||
// Skip if logging is disabled (except for errors which are always logged)
|
||||
if !lb.loggingEnabled && level != "ERROR" && level != "FATAL" {
|
||||
return
|
||||
}
|
||||
|
||||
entry := LogEntry{
|
||||
Timestamp: time.Now().Format("15:04:05.000"),
|
||||
Level: level,
|
||||
Tag: tag,
|
||||
Message: message,
|
||||
}
|
||||
|
||||
if len(lb.entries) >= lb.maxSize {
|
||||
// Remove oldest entry
|
||||
lb.entries = lb.entries[1:]
|
||||
}
|
||||
lb.entries = append(lb.entries, entry)
|
||||
|
||||
// Also print to logcat for debugging
|
||||
fmt.Printf("[%s] %s\n", tag, message)
|
||||
}
|
||||
|
||||
// GetAll returns all log entries as JSON
|
||||
func (lb *LogBuffer) GetAll() string {
|
||||
lb.mu.RLock()
|
||||
defer lb.mu.RUnlock()
|
||||
|
||||
jsonBytes, _ := json.Marshal(lb.entries)
|
||||
return string(jsonBytes)
|
||||
}
|
||||
|
||||
// getSince returns log entries since the given index (internal use)
|
||||
func (lb *LogBuffer) getSince(index int) ([]LogEntry, int) {
|
||||
lb.mu.RLock()
|
||||
defer lb.mu.RUnlock()
|
||||
|
||||
if index < 0 {
|
||||
index = 0
|
||||
}
|
||||
if index >= len(lb.entries) {
|
||||
return []LogEntry{}, len(lb.entries)
|
||||
}
|
||||
|
||||
entries := lb.entries[index:]
|
||||
return entries, len(lb.entries)
|
||||
}
|
||||
|
||||
// Clear clears all log entries
|
||||
func (lb *LogBuffer) Clear() {
|
||||
lb.mu.Lock()
|
||||
defer lb.mu.Unlock()
|
||||
lb.entries = lb.entries[:0]
|
||||
}
|
||||
|
||||
// Count returns the number of log entries
|
||||
func (lb *LogBuffer) Count() int {
|
||||
lb.mu.RLock()
|
||||
defer lb.mu.RUnlock()
|
||||
return len(lb.entries)
|
||||
}
|
||||
|
||||
// Helper functions for logging with different levels
|
||||
func LogDebug(tag, format string, args ...interface{}) {
|
||||
GetLogBuffer().Add("DEBUG", tag, fmt.Sprintf(format, args...))
|
||||
}
|
||||
|
||||
func LogInfo(tag, format string, args ...interface{}) {
|
||||
GetLogBuffer().Add("INFO", tag, fmt.Sprintf(format, args...))
|
||||
}
|
||||
|
||||
func LogWarn(tag, format string, args ...interface{}) {
|
||||
GetLogBuffer().Add("WARN", tag, fmt.Sprintf(format, args...))
|
||||
}
|
||||
|
||||
func LogError(tag, format string, args ...interface{}) {
|
||||
GetLogBuffer().Add("ERROR", tag, fmt.Sprintf(format, args...))
|
||||
}
|
||||
|
||||
// GoLog is a drop-in replacement for fmt.Printf that also logs to buffer
|
||||
// It parses the tag from the format string if it starts with [Tag]
|
||||
func GoLog(format string, args ...interface{}) {
|
||||
message := fmt.Sprintf(format, args...)
|
||||
message = strings.TrimSuffix(message, "\n")
|
||||
|
||||
// Extract tag from message if present (e.g., "[Tidal] message")
|
||||
tag := "Go"
|
||||
level := "INFO"
|
||||
|
||||
if strings.HasPrefix(message, "[") {
|
||||
endBracket := strings.Index(message, "]")
|
||||
if endBracket > 1 {
|
||||
tag = message[1:endBracket]
|
||||
message = strings.TrimSpace(message[endBracket+1:])
|
||||
}
|
||||
}
|
||||
|
||||
// Determine level from message content
|
||||
msgLower := strings.ToLower(message)
|
||||
if strings.Contains(msgLower, "error") || strings.Contains(msgLower, "failed") || strings.HasPrefix(message, "✗") {
|
||||
level = "ERROR"
|
||||
} else if strings.Contains(msgLower, "warning") || strings.Contains(msgLower, "warn") {
|
||||
level = "WARN"
|
||||
} else if strings.HasPrefix(message, "✓") || strings.Contains(msgLower, "success") || strings.Contains(msgLower, "match found") {
|
||||
level = "INFO"
|
||||
} else if strings.Contains(msgLower, "searching") || strings.Contains(msgLower, "trying") || strings.Contains(msgLower, "found") {
|
||||
level = "DEBUG"
|
||||
}
|
||||
|
||||
GetLogBuffer().Add(level, tag, message)
|
||||
}
|
||||
|
||||
// Exported functions for Flutter
|
||||
|
||||
// GetLogs returns all logs as JSON array
|
||||
func GetLogs() string {
|
||||
return GetLogBuffer().GetAll()
|
||||
}
|
||||
|
||||
// GetLogsSince returns logs since the given index
|
||||
// Returns JSON: {"logs": [...], "next_index": N}
|
||||
func GetLogsSince(index int) string {
|
||||
entries, nextIndex := GetLogBuffer().getSince(index)
|
||||
logsJson, _ := json.Marshal(entries)
|
||||
result := fmt.Sprintf(`{"logs":%s,"next_index":%d}`, string(logsJson), nextIndex)
|
||||
return result
|
||||
}
|
||||
|
||||
// ClearLogs clears all logs
|
||||
func ClearLogs() {
|
||||
GetLogBuffer().Clear()
|
||||
}
|
||||
|
||||
// GetLogCount returns the number of log entries
|
||||
func GetLogCount() int {
|
||||
return GetLogBuffer().Count()
|
||||
}
|
||||
|
||||
// SetLoggingEnabled enables or disables logging from Flutter
|
||||
func SetLoggingEnabled(enabled bool) {
|
||||
GetLogBuffer().SetLoggingEnabled(enabled)
|
||||
}
|
||||
+255
-42
@@ -83,18 +83,193 @@ func qobuzArtistsMatch(expectedArtist, foundArtist string) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// If scripts are different (one is ASCII, one is non-ASCII like Japanese/Chinese/Korean),
|
||||
// assume they're the same artist with different transliteration
|
||||
expectedASCII := qobuzIsASCIIString(expectedArtist)
|
||||
foundASCII := qobuzIsASCIIString(foundArtist)
|
||||
if expectedASCII != foundASCII {
|
||||
fmt.Printf("[Qobuz] Artist names in different scripts, assuming match: '%s' vs '%s'\n", expectedArtist, foundArtist)
|
||||
// If scripts are TRULY different (Latin vs CJK/Arabic/Cyrillic), assume match (transliteration)
|
||||
// Don't treat Latin Extended (Polish, French, etc.) as different script
|
||||
expectedLatin := qobuzIsLatinScript(expectedArtist)
|
||||
foundLatin := qobuzIsLatinScript(foundArtist)
|
||||
if expectedLatin != foundLatin {
|
||||
GoLog("[Qobuz] Artist names in different scripts, assuming match: '%s' vs '%s'\n", expectedArtist, foundArtist)
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// qobuzTitlesMatch checks if track titles are similar enough
|
||||
func qobuzTitlesMatch(expectedTitle, foundTitle string) bool {
|
||||
normExpected := strings.ToLower(strings.TrimSpace(expectedTitle))
|
||||
normFound := strings.ToLower(strings.TrimSpace(foundTitle))
|
||||
|
||||
// Exact match
|
||||
if normExpected == normFound {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check if one contains the other
|
||||
if strings.Contains(normExpected, normFound) || strings.Contains(normFound, normExpected) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Clean BOTH titles and compare (removes suffixes like remaster, remix, etc)
|
||||
cleanExpected := qobuzCleanTitle(normExpected)
|
||||
cleanFound := qobuzCleanTitle(normFound)
|
||||
|
||||
if cleanExpected == cleanFound {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check if cleaned versions contain each other
|
||||
if cleanExpected != "" && cleanFound != "" {
|
||||
if strings.Contains(cleanExpected, cleanFound) || strings.Contains(cleanFound, cleanExpected) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// Extract core title (before any parentheses/brackets)
|
||||
coreExpected := qobuzExtractCoreTitle(normExpected)
|
||||
coreFound := qobuzExtractCoreTitle(normFound)
|
||||
|
||||
if coreExpected != "" && coreFound != "" && coreExpected == coreFound {
|
||||
return true
|
||||
}
|
||||
|
||||
// If scripts are TRULY different (Latin vs CJK/Arabic/Cyrillic), assume match (transliteration)
|
||||
// Don't treat Latin Extended (Polish, French, etc.) as different script
|
||||
expectedLatin := qobuzIsLatinScript(expectedTitle)
|
||||
foundLatin := qobuzIsLatinScript(foundTitle)
|
||||
if expectedLatin != foundLatin {
|
||||
GoLog("[Qobuz] Titles in different scripts, assuming match: '%s' vs '%s'\n", expectedTitle, foundTitle)
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// qobuzExtractCoreTitle extracts the main title before any parentheses or brackets
|
||||
func qobuzExtractCoreTitle(title string) string {
|
||||
// Find first occurrence of ( or [
|
||||
parenIdx := strings.Index(title, "(")
|
||||
bracketIdx := strings.Index(title, "[")
|
||||
dashIdx := strings.Index(title, " - ")
|
||||
|
||||
cutIdx := len(title)
|
||||
if parenIdx > 0 && parenIdx < cutIdx {
|
||||
cutIdx = parenIdx
|
||||
}
|
||||
if bracketIdx > 0 && bracketIdx < cutIdx {
|
||||
cutIdx = bracketIdx
|
||||
}
|
||||
if dashIdx > 0 && dashIdx < cutIdx {
|
||||
cutIdx = dashIdx
|
||||
}
|
||||
|
||||
return strings.TrimSpace(title[:cutIdx])
|
||||
}
|
||||
|
||||
// qobuzCleanTitle removes common suffixes from track titles for comparison
|
||||
func qobuzCleanTitle(title string) string {
|
||||
cleaned := title
|
||||
|
||||
// Remove content in parentheses/brackets that are version indicators
|
||||
// This helps match "Song (Remastered)" with "Song" or "Song (2024 Remaster)"
|
||||
versionPatterns := []string{
|
||||
"remaster", "remastered", "deluxe", "bonus", "single",
|
||||
"album version", "radio edit", "original mix", "extended",
|
||||
"club mix", "remix", "live", "acoustic", "demo",
|
||||
}
|
||||
|
||||
// Remove parenthetical content if it contains version indicators
|
||||
for {
|
||||
startParen := strings.LastIndex(cleaned, "(")
|
||||
endParen := strings.LastIndex(cleaned, ")")
|
||||
if startParen >= 0 && endParen > startParen {
|
||||
content := strings.ToLower(cleaned[startParen+1 : endParen])
|
||||
isVersionIndicator := false
|
||||
for _, pattern := range versionPatterns {
|
||||
if strings.Contains(content, pattern) {
|
||||
isVersionIndicator = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if isVersionIndicator {
|
||||
cleaned = strings.TrimSpace(cleaned[:startParen]) + cleaned[endParen+1:]
|
||||
continue
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
// Same for brackets
|
||||
for {
|
||||
startBracket := strings.LastIndex(cleaned, "[")
|
||||
endBracket := strings.LastIndex(cleaned, "]")
|
||||
if startBracket >= 0 && endBracket > startBracket {
|
||||
content := strings.ToLower(cleaned[startBracket+1 : endBracket])
|
||||
isVersionIndicator := false
|
||||
for _, pattern := range versionPatterns {
|
||||
if strings.Contains(content, pattern) {
|
||||
isVersionIndicator = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if isVersionIndicator {
|
||||
cleaned = strings.TrimSpace(cleaned[:startBracket]) + cleaned[endBracket+1:]
|
||||
continue
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
// Remove trailing " - version" patterns
|
||||
dashPatterns := []string{
|
||||
" - remaster", " - remastered", " - single version", " - radio edit",
|
||||
" - live", " - acoustic", " - demo", " - remix",
|
||||
}
|
||||
for _, pattern := range dashPatterns {
|
||||
if strings.HasSuffix(strings.ToLower(cleaned), pattern) {
|
||||
cleaned = cleaned[:len(cleaned)-len(pattern)]
|
||||
}
|
||||
}
|
||||
|
||||
// Remove multiple spaces
|
||||
for strings.Contains(cleaned, " ") {
|
||||
cleaned = strings.ReplaceAll(cleaned, " ", " ")
|
||||
}
|
||||
|
||||
return strings.TrimSpace(cleaned)
|
||||
}
|
||||
|
||||
// qobuzIsLatinScript checks if a string is primarily Latin script
|
||||
// Returns true for ASCII and Latin Extended characters (European languages)
|
||||
// Returns false for CJK, Arabic, Cyrillic, etc.
|
||||
func qobuzIsLatinScript(s string) bool {
|
||||
for _, r := range s {
|
||||
// Skip common punctuation and numbers
|
||||
if r < 128 {
|
||||
continue
|
||||
}
|
||||
// Latin Extended-A: U+0100 to U+017F (Polish, Czech, etc.)
|
||||
// Latin Extended-B: U+0180 to U+024F
|
||||
// Latin Extended Additional: U+1E00 to U+1EFF
|
||||
// Latin Extended-C/D/E: various ranges
|
||||
if (r >= 0x0100 && r <= 0x024F) || // Latin Extended A & B
|
||||
(r >= 0x1E00 && r <= 0x1EFF) || // Latin Extended Additional
|
||||
(r >= 0x00C0 && r <= 0x00FF) { // Latin-1 Supplement (accented chars)
|
||||
continue
|
||||
}
|
||||
// CJK ranges - definitely different script
|
||||
if (r >= 0x4E00 && r <= 0x9FFF) || // CJK Unified Ideographs
|
||||
(r >= 0x3040 && r <= 0x309F) || // Hiragana
|
||||
(r >= 0x30A0 && r <= 0x30FF) || // Katakana
|
||||
(r >= 0xAC00 && r <= 0xD7AF) || // Hangul (Korean)
|
||||
(r >= 0x0600 && r <= 0x06FF) || // Arabic
|
||||
(r >= 0x0400 && r <= 0x04FF) { // Cyrillic
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// qobuzIsASCIIString checks if a string contains only ASCII characters
|
||||
func qobuzIsASCIIString(s string) bool {
|
||||
for _, r := range s {
|
||||
@@ -194,7 +369,7 @@ func (q *QobuzDownloader) SearchTrackByISRC(isrc string) (*QobuzTrack, error) {
|
||||
// SearchTrackByISRCWithTitle searches for a track by ISRC with duration verification
|
||||
// expectedDurationSec is the expected duration in seconds (0 to skip verification)
|
||||
func (q *QobuzDownloader) SearchTrackByISRCWithDuration(isrc string, expectedDurationSec int) (*QobuzTrack, error) {
|
||||
fmt.Printf("[Qobuz] Searching by ISRC: %s\n", isrc)
|
||||
GoLog("[Qobuz] Searching by ISRC: %s\n", isrc)
|
||||
|
||||
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly93d3cucW9idXouY29tL2FwaS5qc29uLzAuMi90cmFjay9zZWFyY2g/cXVlcnk9")
|
||||
searchURL := fmt.Sprintf("%s%s&limit=50&app_id=%s", string(apiBase), url.QueryEscape(isrc), q.appID)
|
||||
@@ -223,7 +398,7 @@ func (q *QobuzDownloader) SearchTrackByISRCWithDuration(isrc string, expectedDur
|
||||
return nil, err
|
||||
}
|
||||
|
||||
fmt.Printf("[Qobuz] ISRC search returned %d results\n", len(result.Tracks.Items))
|
||||
GoLog("[Qobuz] ISRC search returned %d results\n", len(result.Tracks.Items))
|
||||
|
||||
// Find ISRC matches
|
||||
var isrcMatches []*QobuzTrack
|
||||
@@ -233,7 +408,7 @@ func (q *QobuzDownloader) SearchTrackByISRCWithDuration(isrc string, expectedDur
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Printf("[Qobuz] Found %d exact ISRC matches\n", len(isrcMatches))
|
||||
GoLog("[Qobuz] Found %d exact ISRC matches\n", len(isrcMatches))
|
||||
|
||||
if len(isrcMatches) > 0 {
|
||||
// Verify duration if provided
|
||||
@@ -251,20 +426,20 @@ func (q *QobuzDownloader) SearchTrackByISRCWithDuration(isrc string, expectedDur
|
||||
}
|
||||
|
||||
if len(durationVerifiedMatches) > 0 {
|
||||
fmt.Printf("[Qobuz] ISRC match with duration verification: '%s' (expected %ds, found %ds)\n",
|
||||
GoLog("[Qobuz] ISRC match with duration verification: '%s' (expected %ds, found %ds)\n",
|
||||
durationVerifiedMatches[0].Title, expectedDurationSec, durationVerifiedMatches[0].Duration)
|
||||
return durationVerifiedMatches[0], nil
|
||||
}
|
||||
|
||||
// ISRC matches but duration doesn't
|
||||
fmt.Printf("[Qobuz] WARNING: ISRC %s found but duration mismatch. Expected=%ds, Found=%ds. Rejecting.\n",
|
||||
GoLog("[Qobuz] WARNING: ISRC %s found but duration mismatch. Expected=%ds, Found=%ds. Rejecting.\n",
|
||||
isrc, expectedDurationSec, isrcMatches[0].Duration)
|
||||
return nil, fmt.Errorf("ISRC found but duration mismatch: expected %ds, found %ds (likely different version)",
|
||||
expectedDurationSec, isrcMatches[0].Duration)
|
||||
}
|
||||
|
||||
// No duration to verify, return first match
|
||||
fmt.Printf("[Qobuz] ISRC match (no duration verification): '%s'\n", isrcMatches[0].Title)
|
||||
GoLog("[Qobuz] ISRC match (no duration verification): '%s'\n", isrcMatches[0].Title)
|
||||
return isrcMatches[0], nil
|
||||
}
|
||||
|
||||
@@ -287,6 +462,7 @@ func (q *QobuzDownloader) SearchTrackByMetadata(trackName, artistName string) (*
|
||||
|
||||
// SearchTrackByMetadataWithDuration searches for a track with duration verification
|
||||
// Now includes romaji conversion for Japanese text (same as Tidal)
|
||||
// Also includes title verification to prevent wrong song downloads
|
||||
func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistName string, expectedDurationSec int) (*QobuzTrack, error) {
|
||||
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly93d3cucW9idXouY29tL2FwaS5qc29uLzAuMi90cmFjay9zZWFyY2g/cXVlcnk9")
|
||||
|
||||
@@ -318,7 +494,7 @@ func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistNam
|
||||
romajiQuery := cleanRomajiArtist + " " + cleanRomajiTrack
|
||||
if !containsQueryQobuz(queries, romajiQuery) {
|
||||
queries = append(queries, romajiQuery)
|
||||
fmt.Printf("[Qobuz] Japanese detected, adding romaji query: %s\n", romajiQuery)
|
||||
GoLog("[Qobuz] Japanese detected, adding romaji query: %s\n", romajiQuery)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -348,7 +524,7 @@ func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistNam
|
||||
}
|
||||
searchedQueries[cleanQuery] = true
|
||||
|
||||
fmt.Printf("[Qobuz] Searching for: %s\n", cleanQuery)
|
||||
GoLog("[Qobuz] Searching for: %s\n", cleanQuery)
|
||||
|
||||
searchURL := fmt.Sprintf("%s%s&limit=50&app_id=%s", string(apiBase), url.QueryEscape(cleanQuery), q.appID)
|
||||
|
||||
@@ -359,7 +535,7 @@ func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistNam
|
||||
|
||||
resp, err := DoRequestWithUserAgent(q.client, req)
|
||||
if err != nil {
|
||||
fmt.Printf("[Qobuz] Search error for '%s': %v\n", cleanQuery, err)
|
||||
GoLog("[Qobuz] Search error for '%s': %v\n", cleanQuery, err)
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -380,7 +556,7 @@ func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistNam
|
||||
resp.Body.Close()
|
||||
|
||||
if len(result.Tracks.Items) > 0 {
|
||||
fmt.Printf("[Qobuz] Found %d results for '%s'\n", len(result.Tracks.Items), cleanQuery)
|
||||
GoLog("[Qobuz] Found %d results for '%s'\n", len(result.Tracks.Items), cleanQuery)
|
||||
allTracks = append(allTracks, result.Tracks.Items...)
|
||||
}
|
||||
}
|
||||
@@ -389,11 +565,30 @@ func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistNam
|
||||
return nil, fmt.Errorf("no tracks found for: %s - %s", artistName, trackName)
|
||||
}
|
||||
|
||||
// Filter by title match first (NEW - like Tidal)
|
||||
var titleMatches []*QobuzTrack
|
||||
for i := range allTracks {
|
||||
track := &allTracks[i]
|
||||
if qobuzTitlesMatch(trackName, track.Title) {
|
||||
titleMatches = append(titleMatches, track)
|
||||
}
|
||||
}
|
||||
|
||||
GoLog("[Qobuz] Title matches: %d out of %d results\n", len(titleMatches), len(allTracks))
|
||||
|
||||
// If no title matches, log warning but continue with all tracks
|
||||
tracksToCheck := titleMatches
|
||||
if len(titleMatches) == 0 {
|
||||
GoLog("[Qobuz] WARNING: No title matches for '%s', checking all %d results\n", trackName, len(allTracks))
|
||||
for i := range allTracks {
|
||||
tracksToCheck = append(tracksToCheck, &allTracks[i])
|
||||
}
|
||||
}
|
||||
|
||||
// If duration verification is requested
|
||||
if expectedDurationSec > 0 {
|
||||
var durationMatches []*QobuzTrack
|
||||
for i := range allTracks {
|
||||
track := &allTracks[i]
|
||||
for _, track := range tracksToCheck {
|
||||
durationDiff := track.Duration - expectedDurationSec
|
||||
if durationDiff < 0 {
|
||||
durationDiff = -durationDiff
|
||||
@@ -407,24 +602,36 @@ func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistNam
|
||||
// Return best quality among duration matches
|
||||
for _, track := range durationMatches {
|
||||
if track.MaximumBitDepth >= 24 {
|
||||
GoLog("[Qobuz] ✓ Match found: '%s' by '%s' (title+duration verified, hi-res)\n",
|
||||
track.Title, track.Performer.Name)
|
||||
return track, nil
|
||||
}
|
||||
}
|
||||
GoLog("[Qobuz] ✓ Match found: '%s' by '%s' (title+duration verified)\n",
|
||||
durationMatches[0].Title, durationMatches[0].Performer.Name)
|
||||
return durationMatches[0], nil
|
||||
}
|
||||
|
||||
// No duration match found
|
||||
return nil, fmt.Errorf("no tracks found with matching duration (expected %ds)", expectedDurationSec)
|
||||
return nil, fmt.Errorf("no tracks found with matching title and duration (expected '%s', %ds)", trackName, expectedDurationSec)
|
||||
}
|
||||
|
||||
// No duration verification, return best quality
|
||||
for i := range allTracks {
|
||||
track := &allTracks[i]
|
||||
// No duration verification, return best quality from title matches
|
||||
for _, track := range tracksToCheck {
|
||||
if track.MaximumBitDepth >= 24 {
|
||||
GoLog("[Qobuz] ✓ Match found: '%s' by '%s' (title verified, hi-res)\n",
|
||||
track.Title, track.Performer.Name)
|
||||
return track, nil
|
||||
}
|
||||
}
|
||||
return &allTracks[0], nil
|
||||
|
||||
if len(tracksToCheck) > 0 {
|
||||
GoLog("[Qobuz] ✓ Match found: '%s' by '%s' (title verified)\n",
|
||||
tracksToCheck[0].Title, tracksToCheck[0].Performer.Name)
|
||||
return tracksToCheck[0], nil
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("no matching track found for: %s - %s", artistName, trackName)
|
||||
}
|
||||
|
||||
// getQobuzDownloadURLSequential requests download URL from APIs sequentially
|
||||
@@ -443,7 +650,7 @@ func getQobuzDownloadURLSequential(apis []string, trackID int64, quality string)
|
||||
// The apiURL already includes the path, just append trackID and quality
|
||||
reqURL := fmt.Sprintf("%s%d&quality=%s", apiURL, trackID, quality)
|
||||
|
||||
fmt.Printf("[Qobuz] Trying: %s\n", reqURL)
|
||||
GoLog("[Qobuz] Trying: %s\n", reqURL)
|
||||
|
||||
req, err := http.NewRequest("GET", reqURL, nil)
|
||||
if err != nil {
|
||||
@@ -488,7 +695,7 @@ func getQobuzDownloadURLSequential(apis []string, trackID int64, quality string)
|
||||
}
|
||||
|
||||
if result.URL != "" {
|
||||
fmt.Printf("[Qobuz] Got download URL from: %s\n", apiURL)
|
||||
GoLog("[Qobuz] Got download URL from: %s\n", apiURL)
|
||||
return apiURL, result.URL, nil
|
||||
}
|
||||
|
||||
@@ -619,11 +826,11 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
|
||||
// OPTIMIZATION: Check cache first for track ID
|
||||
if req.ISRC != "" {
|
||||
if cached := GetTrackIDCache().Get(req.ISRC); cached != nil && cached.QobuzTrackID > 0 {
|
||||
fmt.Printf("[Qobuz] Cache hit! Using cached track ID: %d\n", cached.QobuzTrackID)
|
||||
GoLog("[Qobuz] Cache hit! Using cached track ID: %d\n", cached.QobuzTrackID)
|
||||
// For Qobuz we need to search again to get full track info, but we can use the ID
|
||||
track, err = downloader.SearchTrackByISRC(req.ISRC)
|
||||
if err != nil {
|
||||
fmt.Printf("[Qobuz] Cache hit but search failed: %v\n", err)
|
||||
GoLog("[Qobuz] Cache hit but search failed: %v\n", err)
|
||||
track = nil
|
||||
}
|
||||
}
|
||||
@@ -631,22 +838,28 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
|
||||
|
||||
// Strategy 1: Search by ISRC with duration verification
|
||||
if track == nil && req.ISRC != "" {
|
||||
fmt.Printf("[Qobuz] Trying ISRC search: %s\n", req.ISRC)
|
||||
GoLog("[Qobuz] Trying ISRC search: %s\n", req.ISRC)
|
||||
track, err = downloader.SearchTrackByISRCWithDuration(req.ISRC, expectedDurationSec)
|
||||
// Verify artist
|
||||
if track != nil && !qobuzArtistsMatch(req.ArtistName, track.Performer.Name) {
|
||||
fmt.Printf("[Qobuz] Artist mismatch from ISRC search: expected '%s', got '%s'. Rejecting.\n",
|
||||
req.ArtistName, track.Performer.Name)
|
||||
track = nil
|
||||
// Verify artist AND title
|
||||
if track != nil {
|
||||
if !qobuzArtistsMatch(req.ArtistName, track.Performer.Name) {
|
||||
GoLog("[Qobuz] Artist mismatch from ISRC search: expected '%s', got '%s'. Rejecting.\n",
|
||||
req.ArtistName, track.Performer.Name)
|
||||
track = nil
|
||||
} else if !qobuzTitlesMatch(req.TrackName, track.Title) {
|
||||
GoLog("[Qobuz] Title mismatch from ISRC search: expected '%s', got '%s'. Rejecting.\n",
|
||||
req.TrackName, track.Title)
|
||||
track = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Strategy 2: Search by metadata with duration verification
|
||||
// Strategy 2: Search by metadata with duration verification (includes title verification)
|
||||
if track == nil {
|
||||
track, err = downloader.SearchTrackByMetadataWithDuration(req.TrackName, req.ArtistName, expectedDurationSec)
|
||||
// Verify artist
|
||||
// Verify artist (title already verified in SearchTrackByMetadataWithDuration)
|
||||
if track != nil && !qobuzArtistsMatch(req.ArtistName, track.Performer.Name) {
|
||||
fmt.Printf("[Qobuz] Artist mismatch from metadata search: expected '%s', got '%s'. Rejecting.\n",
|
||||
GoLog("[Qobuz] Artist mismatch from metadata search: expected '%s', got '%s'. Rejecting.\n",
|
||||
req.ArtistName, track.Performer.Name)
|
||||
track = nil
|
||||
}
|
||||
@@ -661,7 +874,7 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
|
||||
}
|
||||
|
||||
// Log match found and cache the track ID
|
||||
fmt.Printf("[Qobuz] Match found: '%s' by '%s' (duration: %ds)\n", track.Title, track.Performer.Name, track.Duration)
|
||||
GoLog("[Qobuz] Match found: '%s' by '%s' (duration: %ds)\n", track.Title, track.Performer.Name, track.Duration)
|
||||
if req.ISRC != "" {
|
||||
GetTrackIDCache().SetQobuz(req.ISRC, track.ID)
|
||||
}
|
||||
@@ -695,12 +908,12 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
|
||||
case "HI_RES_LOSSLESS":
|
||||
qobuzQuality = "27" // 24-bit 192kHz
|
||||
}
|
||||
fmt.Printf("[Qobuz] Using quality: %s (mapped from %s)\n", qobuzQuality, req.Quality)
|
||||
GoLog("[Qobuz] Using quality: %s (mapped from %s)\n", qobuzQuality, req.Quality)
|
||||
|
||||
// Get actual quality from track metadata
|
||||
actualBitDepth := track.MaximumBitDepth
|
||||
actualSampleRate := int(track.MaximumSamplingRate * 1000) // Convert kHz to Hz
|
||||
fmt.Printf("[Qobuz] Actual quality: %d-bit/%.1fkHz\n", actualBitDepth, track.MaximumSamplingRate)
|
||||
GoLog("[Qobuz] Actual quality: %d-bit/%.1fkHz\n", actualBitDepth, track.MaximumSamplingRate)
|
||||
|
||||
// Get download URL using parallel API requests
|
||||
downloadURL, err := downloader.GetDownloadURL(track.ID, qobuzQuality)
|
||||
@@ -762,7 +975,7 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
|
||||
var coverData []byte
|
||||
if parallelResult != nil && parallelResult.CoverData != nil {
|
||||
coverData = parallelResult.CoverData
|
||||
fmt.Printf("[Qobuz] Using parallel-fetched cover (%d bytes)\n", len(coverData))
|
||||
GoLog("[Qobuz] Using parallel-fetched cover (%d bytes)\n", len(coverData))
|
||||
}
|
||||
|
||||
if err := EmbedMetadataWithCoverData(outputPath, metadata, coverData); err != nil {
|
||||
@@ -771,9 +984,9 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
|
||||
|
||||
// Embed lyrics from parallel fetch
|
||||
if req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" {
|
||||
fmt.Printf("[Qobuz] Embedding parallel-fetched lyrics (%d lines)...\n", len(parallelResult.LyricsData.Lines))
|
||||
GoLog("[Qobuz] Embedding parallel-fetched lyrics (%d lines)...\n", len(parallelResult.LyricsData.Lines))
|
||||
if embedErr := EmbedLyrics(outputPath, parallelResult.LyricsLRC); embedErr != nil {
|
||||
fmt.Printf("[Qobuz] Warning: failed to embed lyrics: %v\n", embedErr)
|
||||
GoLog("[Qobuz] Warning: failed to embed lyrics: %v\n", embedErr)
|
||||
} else {
|
||||
fmt.Println("[Qobuz] Lyrics embedded successfully")
|
||||
}
|
||||
|
||||
+259
-80
@@ -403,7 +403,7 @@ func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, s
|
||||
romajiQuery := cleanRomajiArtist + " " + cleanRomajiTrack
|
||||
if !containsQuery(queries, romajiQuery) {
|
||||
queries = append(queries, romajiQuery)
|
||||
fmt.Printf("[Tidal] Japanese detected, adding romaji query: %s\n", romajiQuery)
|
||||
GoLog("[Tidal] Japanese detected, adding romaji query: %s\n", romajiQuery)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -444,7 +444,7 @@ func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, s
|
||||
}
|
||||
searchedQueries[cleanQuery] = true
|
||||
|
||||
fmt.Printf("[Tidal] Searching for: %s\n", cleanQuery)
|
||||
GoLog("[Tidal] Searching for: %s\n", cleanQuery)
|
||||
|
||||
searchURL := fmt.Sprintf("%s%s&limit=100&countryCode=US", string(searchBase), url.QueryEscape(cleanQuery))
|
||||
|
||||
@@ -457,7 +457,7 @@ func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, s
|
||||
|
||||
resp, err := DoRequestWithUserAgent(t.client, req)
|
||||
if err != nil {
|
||||
fmt.Printf("[Tidal] Search error for '%s': %v\n", cleanQuery, err)
|
||||
GoLog("[Tidal] Search error for '%s': %v\n", cleanQuery, err)
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -476,7 +476,7 @@ func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, s
|
||||
resp.Body.Close()
|
||||
|
||||
if len(result.Items) > 0 {
|
||||
fmt.Printf("[Tidal] Found %d results for '%s'\n", len(result.Items), cleanQuery)
|
||||
GoLog("[Tidal] Found %d results for '%s'\n", len(result.Items), cleanQuery)
|
||||
|
||||
// OPTIMIZATION: If ISRC provided, check for match immediately and return early
|
||||
if spotifyISRC != "" {
|
||||
@@ -490,14 +490,14 @@ func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, s
|
||||
durationDiff = -durationDiff
|
||||
}
|
||||
if durationDiff <= 3 {
|
||||
fmt.Printf("[Tidal] ✓ ISRC match: '%s' (duration verified)\n", track.Title)
|
||||
GoLog("[Tidal] ✓ ISRC match: '%s' (duration verified)\n", track.Title)
|
||||
return track, nil
|
||||
}
|
||||
// Duration mismatch, continue searching
|
||||
fmt.Printf("[Tidal] ISRC match but duration mismatch (expected %ds, got %ds), continuing...\n",
|
||||
GoLog("[Tidal] ISRC match but duration mismatch (expected %ds, got %ds), continuing...\n",
|
||||
expectedDuration, track.Duration)
|
||||
} else {
|
||||
fmt.Printf("[Tidal] ✓ ISRC match: '%s'\n", track.Title)
|
||||
GoLog("[Tidal] ✓ ISRC match: '%s'\n", track.Title)
|
||||
return track, nil
|
||||
}
|
||||
}
|
||||
@@ -514,7 +514,7 @@ func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, s
|
||||
|
||||
// Priority 1: Match by ISRC (exact match) WITH title verification
|
||||
if spotifyISRC != "" {
|
||||
fmt.Printf("[Tidal] Looking for ISRC match: %s\n", spotifyISRC)
|
||||
GoLog("[Tidal] Looking for ISRC match: %s\n", spotifyISRC)
|
||||
var isrcMatches []*TidalTrack
|
||||
for i := range allTracks {
|
||||
track := &allTracks[i]
|
||||
@@ -540,25 +540,25 @@ func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, s
|
||||
|
||||
if len(durationVerifiedMatches) > 0 {
|
||||
// Return first duration-verified match
|
||||
fmt.Printf("[Tidal] ✓ ISRC match with duration verification: '%s' (expected %ds, found %ds)\n",
|
||||
GoLog("[Tidal] ✓ ISRC match with duration verification: '%s' (expected %ds, found %ds)\n",
|
||||
durationVerifiedMatches[0].Title, expectedDuration, durationVerifiedMatches[0].Duration)
|
||||
return durationVerifiedMatches[0], nil
|
||||
}
|
||||
|
||||
// ISRC matches but duration doesn't - this is likely wrong version
|
||||
fmt.Printf("[Tidal] WARNING: ISRC %s found but duration mismatch. Expected=%ds, Found=%ds. Rejecting.\n",
|
||||
GoLog("[Tidal] WARNING: ISRC %s found but duration mismatch. Expected=%ds, Found=%ds. Rejecting.\n",
|
||||
spotifyISRC, expectedDuration, isrcMatches[0].Duration)
|
||||
return nil, fmt.Errorf("ISRC found but duration mismatch: expected %ds, found %ds (likely different version/edit)",
|
||||
expectedDuration, isrcMatches[0].Duration)
|
||||
}
|
||||
|
||||
// No duration to verify, just return first ISRC match
|
||||
fmt.Printf("[Tidal] ✓ ISRC match (no duration verification): '%s'\n", isrcMatches[0].Title)
|
||||
GoLog("[Tidal] ✓ ISRC match (no duration verification): '%s'\n", isrcMatches[0].Title)
|
||||
return isrcMatches[0], nil
|
||||
}
|
||||
|
||||
// If ISRC was provided but no match found, return error
|
||||
fmt.Printf("[Tidal] ✗ No ISRC match found for: %s\n", spotifyISRC)
|
||||
GoLog("[Tidal] ✗ No ISRC match found for: %s\n", spotifyISRC)
|
||||
return nil, fmt.Errorf("ISRC mismatch: no track found with ISRC %s on Tidal", spotifyISRC)
|
||||
}
|
||||
|
||||
@@ -589,7 +589,7 @@ func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, s
|
||||
}
|
||||
}
|
||||
}
|
||||
fmt.Printf("[Tidal] Found via duration match: %s - %s (%s)\n",
|
||||
GoLog("[Tidal] Found via duration match: %s - %s (%s)\n",
|
||||
bestMatch.Artist.Name, bestMatch.Title, bestMatch.AudioQuality)
|
||||
return bestMatch, nil
|
||||
}
|
||||
@@ -610,7 +610,7 @@ func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, s
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Printf("[Tidal] Found via search (no ISRC provided): %s - %s (ISRC: %s, Quality: %s)\n",
|
||||
GoLog("[Tidal] Found via search (no ISRC provided): %s - %s (ISRC: %s, Quality: %s)\n",
|
||||
bestMatch.Artist.Name, bestMatch.Title, bestMatch.ISRC, bestMatch.AudioQuality)
|
||||
|
||||
return bestMatch, nil
|
||||
@@ -651,7 +651,7 @@ func getDownloadURLSequential(apis []string, trackID int64, quality string) (str
|
||||
|
||||
for _, apiURL := range apis {
|
||||
reqURL := fmt.Sprintf("%s/track/?id=%d&quality=%s", apiURL, trackID, quality)
|
||||
fmt.Printf("[Tidal] Trying API: %s\n", reqURL)
|
||||
GoLog("[Tidal] Trying API: %s\n", reqURL)
|
||||
|
||||
req, err := http.NewRequest("GET", reqURL, nil)
|
||||
if err != nil {
|
||||
@@ -661,7 +661,7 @@ func getDownloadURLSequential(apis []string, trackID int64, quality string) (str
|
||||
|
||||
resp, err := DoRequestWithRetry(client, req, retryConfig)
|
||||
if err != nil {
|
||||
fmt.Printf("[Tidal] API error: %v\n", err)
|
||||
GoLog("[Tidal] API error: %v\n", err)
|
||||
errors = append(errors, BuildErrorMessage(apiURL, 0, err.Error()))
|
||||
continue
|
||||
}
|
||||
@@ -669,7 +669,7 @@ func getDownloadURLSequential(apis []string, trackID int64, quality string) (str
|
||||
body, err := ReadResponseBody(resp)
|
||||
resp.Body.Close()
|
||||
if err != nil {
|
||||
fmt.Printf("[Tidal] Read body error: %v\n", err)
|
||||
GoLog("[Tidal] Read body error: %v\n", err)
|
||||
errors = append(errors, BuildErrorMessage(apiURL, resp.StatusCode, err.Error()))
|
||||
continue
|
||||
}
|
||||
@@ -679,22 +679,22 @@ func getDownloadURLSequential(apis []string, trackID int64, quality string) (str
|
||||
if len(bodyPreview) > 300 {
|
||||
bodyPreview = bodyPreview[:300] + "..."
|
||||
}
|
||||
fmt.Printf("[Tidal] API response (HTTP %d): %s\n", resp.StatusCode, bodyPreview)
|
||||
GoLog("[Tidal] API response (HTTP %d): %s\n", resp.StatusCode, bodyPreview)
|
||||
|
||||
// Try v2 format first (object with manifest)
|
||||
var v2Response TidalAPIResponseV2
|
||||
if err := json.Unmarshal(body, &v2Response); err == nil && v2Response.Data.Manifest != "" {
|
||||
fmt.Printf("[Tidal] Got v2 response from %s - Quality: %d-bit/%dHz, AssetPresentation: %s\n",
|
||||
GoLog("[Tidal] Got v2 response from %s - Quality: %d-bit/%dHz, AssetPresentation: %s\n",
|
||||
apiURL, v2Response.Data.BitDepth, v2Response.Data.SampleRate, v2Response.Data.AssetPresentation)
|
||||
|
||||
// IMPORTANT: Reject PREVIEW responses - we need FULL tracks
|
||||
if v2Response.Data.AssetPresentation == "PREVIEW" {
|
||||
fmt.Printf("[Tidal] ✗ Rejecting PREVIEW response from %s, trying next API...\n", apiURL)
|
||||
GoLog("[Tidal] ✗ Rejecting PREVIEW response from %s, trying next API...\n", apiURL)
|
||||
errors = append(errors, BuildErrorMessage(apiURL, resp.StatusCode, "returned PREVIEW instead of FULL"))
|
||||
continue
|
||||
}
|
||||
|
||||
fmt.Printf("[Tidal] ✓ Got FULL track from %s\n", apiURL)
|
||||
GoLog("[Tidal] ✓ Got FULL track from %s\n", apiURL)
|
||||
info := TidalDownloadInfo{
|
||||
URL: "MANIFEST:" + v2Response.Data.Manifest,
|
||||
BitDepth: v2Response.Data.BitDepth,
|
||||
@@ -756,7 +756,7 @@ func parseManifest(manifestB64 string) (directURL string, initURL string, mediaU
|
||||
if len(manifestPreview) > 500 {
|
||||
manifestPreview = manifestPreview[:500] + "..."
|
||||
}
|
||||
fmt.Printf("[Tidal] Manifest content: %s\n", manifestPreview)
|
||||
GoLog("[Tidal] Manifest content: %s\n", manifestPreview)
|
||||
|
||||
// Check if it's BTS format (JSON) or DASH format (XML)
|
||||
if strings.HasPrefix(manifestStr, "{") {
|
||||
@@ -806,12 +806,12 @@ func parseManifest(manifestB64 string) (directURL string, initURL string, mediaU
|
||||
|
||||
// Calculate segment count from timeline
|
||||
segmentCount := 0
|
||||
fmt.Printf("[Tidal] XML parsed segments: %d entries in timeline\n", len(segTemplate.Timeline.Segments))
|
||||
GoLog("[Tidal] XML parsed segments: %d entries in timeline\n", len(segTemplate.Timeline.Segments))
|
||||
for i, seg := range segTemplate.Timeline.Segments {
|
||||
fmt.Printf("[Tidal] Segment[%d]: d=%d, r=%d\n", i, seg.Duration, seg.Repeat)
|
||||
GoLog("[Tidal] Segment[%d]: d=%d, r=%d\n", i, seg.Duration, seg.Repeat)
|
||||
segmentCount += seg.Repeat + 1
|
||||
}
|
||||
fmt.Printf("[Tidal] Segment count from XML: %d\n", segmentCount)
|
||||
GoLog("[Tidal] Segment count from XML: %d\n", segmentCount)
|
||||
|
||||
// If no segments found via XML, try regex
|
||||
if segmentCount == 0 {
|
||||
@@ -819,18 +819,18 @@ func parseManifest(manifestB64 string) (directURL string, initURL string, mediaU
|
||||
// Match <S d="..." /> or <S d="..." r="..." />
|
||||
segRe := regexp.MustCompile(`<S\s+d="(\d+)"(?:\s+r="(\d+)")?`)
|
||||
matches := segRe.FindAllStringSubmatch(manifestStr, -1)
|
||||
fmt.Printf("[Tidal] Regex found %d segment entries\n", len(matches))
|
||||
GoLog("[Tidal] Regex found %d segment entries\n", len(matches))
|
||||
for i, match := range matches {
|
||||
repeat := 0
|
||||
if len(match) > 2 && match[2] != "" {
|
||||
fmt.Sscanf(match[2], "%d", &repeat)
|
||||
}
|
||||
if i < 5 || i == len(matches)-1 {
|
||||
fmt.Printf("[Tidal] Regex segment[%d]: d=%s, r=%d\n", i, match[1], repeat)
|
||||
GoLog("[Tidal] Regex segment[%d]: d=%s, r=%d\n", i, match[1], repeat)
|
||||
}
|
||||
segmentCount += repeat + 1
|
||||
}
|
||||
fmt.Printf("[Tidal] Total segments from regex: %d\n", segmentCount)
|
||||
GoLog("[Tidal] Total segments from regex: %d\n", segmentCount)
|
||||
}
|
||||
|
||||
// Generate media URLs for each segment
|
||||
@@ -930,10 +930,10 @@ func (t *TidalDownloader) downloadFromManifest(manifestB64, outputPath, itemID s
|
||||
fmt.Println("[Tidal] Parsing manifest...")
|
||||
directURL, initURL, mediaURLs, err := parseManifest(manifestB64)
|
||||
if err != nil {
|
||||
fmt.Printf("[Tidal] Manifest parse error: %v\n", err)
|
||||
GoLog("[Tidal] Manifest parse error: %v\n", err)
|
||||
return fmt.Errorf("failed to parse manifest: %w", err)
|
||||
}
|
||||
fmt.Printf("[Tidal] Manifest parsed - directURL: %v, initURL: %v, mediaURLs count: %d\n",
|
||||
GoLog("[Tidal] Manifest parsed - directURL: %v, initURL: %v, mediaURLs count: %d\n",
|
||||
directURL != "", initURL != "", len(mediaURLs))
|
||||
|
||||
client := &http.Client{
|
||||
@@ -942,27 +942,27 @@ func (t *TidalDownloader) downloadFromManifest(manifestB64, outputPath, itemID s
|
||||
|
||||
// If we have a direct URL (BTS format), download directly with progress tracking
|
||||
if directURL != "" {
|
||||
fmt.Printf("[Tidal] BTS format - downloading from direct URL: %s...\n", directURL[:min(80, len(directURL))])
|
||||
GoLog("[Tidal] BTS format - downloading from direct URL: %s...\n", directURL[:min(80, len(directURL))])
|
||||
// Note: Progress tracking is initialized by the caller (DownloadFile)
|
||||
|
||||
req, err := http.NewRequest("GET", directURL, nil)
|
||||
if err != nil {
|
||||
fmt.Printf("[Tidal] BTS request creation failed: %v\n", err)
|
||||
GoLog("[Tidal] BTS request creation failed: %v\n", err)
|
||||
return fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
fmt.Printf("[Tidal] BTS download failed: %v\n", err)
|
||||
GoLog("[Tidal] BTS download failed: %v\n", err)
|
||||
return fmt.Errorf("failed to download file: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
fmt.Printf("[Tidal] BTS download HTTP error: %d\n", resp.StatusCode)
|
||||
GoLog("[Tidal] BTS download HTTP error: %d\n", resp.StatusCode)
|
||||
return fmt.Errorf("download failed with status %d", resp.StatusCode)
|
||||
}
|
||||
fmt.Printf("[Tidal] BTS response OK, Content-Length: %d\n", resp.ContentLength)
|
||||
GoLog("[Tidal] BTS response OK, Content-Length: %d\n", resp.ContentLength)
|
||||
|
||||
expectedSize := resp.ContentLength
|
||||
// Set total bytes for progress tracking
|
||||
@@ -1007,31 +1007,31 @@ func (t *TidalDownloader) downloadFromManifest(manifestB64, outputPath, itemID s
|
||||
// DASH format - download segments directly to M4A file (no temp file to avoid Android permission issues)
|
||||
// On Android, we can't use ffmpeg, so we save as M4A directly
|
||||
m4aPath := strings.TrimSuffix(outputPath, ".flac") + ".m4a"
|
||||
fmt.Printf("[Tidal] DASH format - downloading %d segments directly to: %s\n", len(mediaURLs), m4aPath)
|
||||
GoLog("[Tidal] DASH format - downloading %d segments directly to: %s\n", len(mediaURLs), m4aPath)
|
||||
|
||||
// Note: Progress tracking is initialized by the caller (DownloadFile or downloadFromTidal)
|
||||
// We just update progress here based on segment count
|
||||
|
||||
out, err := os.Create(m4aPath)
|
||||
if err != nil {
|
||||
fmt.Printf("[Tidal] Failed to create M4A file: %v\n", err)
|
||||
GoLog("[Tidal] Failed to create M4A file: %v\n", err)
|
||||
return fmt.Errorf("failed to create M4A file: %w", err)
|
||||
}
|
||||
|
||||
// Download initialization segment
|
||||
fmt.Printf("[Tidal] Downloading init segment...\n")
|
||||
GoLog("[Tidal] Downloading init segment...\n")
|
||||
resp, err := client.Get(initURL)
|
||||
if err != nil {
|
||||
out.Close()
|
||||
os.Remove(m4aPath)
|
||||
fmt.Printf("[Tidal] Init segment download failed: %v\n", err)
|
||||
GoLog("[Tidal] Init segment download failed: %v\n", err)
|
||||
return fmt.Errorf("failed to download init segment: %w", err)
|
||||
}
|
||||
if resp.StatusCode != 200 {
|
||||
resp.Body.Close()
|
||||
out.Close()
|
||||
os.Remove(m4aPath)
|
||||
fmt.Printf("[Tidal] Init segment HTTP error: %d\n", resp.StatusCode)
|
||||
GoLog("[Tidal] Init segment HTTP error: %d\n", resp.StatusCode)
|
||||
return fmt.Errorf("init segment download failed with status %d", resp.StatusCode)
|
||||
}
|
||||
_, err = io.Copy(out, resp.Body)
|
||||
@@ -1039,7 +1039,7 @@ func (t *TidalDownloader) downloadFromManifest(manifestB64, outputPath, itemID s
|
||||
if err != nil {
|
||||
out.Close()
|
||||
os.Remove(m4aPath)
|
||||
fmt.Printf("[Tidal] Init segment write failed: %v\n", err)
|
||||
GoLog("[Tidal] Init segment write failed: %v\n", err)
|
||||
return fmt.Errorf("failed to write init segment: %w", err)
|
||||
}
|
||||
|
||||
@@ -1047,7 +1047,7 @@ func (t *TidalDownloader) downloadFromManifest(manifestB64, outputPath, itemID s
|
||||
totalSegments := len(mediaURLs)
|
||||
for i, mediaURL := range mediaURLs {
|
||||
if i%10 == 0 || i == totalSegments-1 {
|
||||
fmt.Printf("[Tidal] Downloading segment %d/%d...\n", i+1, totalSegments)
|
||||
GoLog("[Tidal] Downloading segment %d/%d...\n", i+1, totalSegments)
|
||||
}
|
||||
|
||||
// Update progress based on segment count
|
||||
@@ -1060,14 +1060,14 @@ func (t *TidalDownloader) downloadFromManifest(manifestB64, outputPath, itemID s
|
||||
if err != nil {
|
||||
out.Close()
|
||||
os.Remove(m4aPath)
|
||||
fmt.Printf("[Tidal] Segment %d download failed: %v\n", i+1, err)
|
||||
GoLog("[Tidal] Segment %d download failed: %v\n", i+1, err)
|
||||
return fmt.Errorf("failed to download segment %d: %w", i+1, err)
|
||||
}
|
||||
if resp.StatusCode != 200 {
|
||||
resp.Body.Close()
|
||||
out.Close()
|
||||
os.Remove(m4aPath)
|
||||
fmt.Printf("[Tidal] Segment %d HTTP error: %d\n", i+1, resp.StatusCode)
|
||||
GoLog("[Tidal] Segment %d HTTP error: %d\n", i+1, resp.StatusCode)
|
||||
return fmt.Errorf("segment %d download failed with status %d", i+1, resp.StatusCode)
|
||||
}
|
||||
_, err = io.Copy(out, resp.Body)
|
||||
@@ -1075,18 +1075,18 @@ func (t *TidalDownloader) downloadFromManifest(manifestB64, outputPath, itemID s
|
||||
if err != nil {
|
||||
out.Close()
|
||||
os.Remove(m4aPath)
|
||||
fmt.Printf("[Tidal] Segment %d write failed: %v\n", i+1, err)
|
||||
GoLog("[Tidal] Segment %d write failed: %v\n", i+1, err)
|
||||
return fmt.Errorf("failed to write segment %d: %w", i+1, err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := out.Close(); err != nil {
|
||||
os.Remove(m4aPath)
|
||||
fmt.Printf("[Tidal] Failed to close M4A file: %v\n", err)
|
||||
GoLog("[Tidal] Failed to close M4A file: %v\n", err)
|
||||
return fmt.Errorf("failed to close M4A file: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("[Tidal] DASH download completed: %s\n", m4aPath)
|
||||
GoLog("[Tidal] DASH download completed: %s\n", m4aPath)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1139,19 +1139,192 @@ func artistsMatch(spotifyArtist, tidalArtist string) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// If scripts are different (one is ASCII, one is non-ASCII like Japanese/Chinese/Korean),
|
||||
// assume they're the same artist with different transliteration
|
||||
// If scripts are TRULY different (Latin vs CJK/Arabic/Cyrillic), assume match (transliteration)
|
||||
// Don't treat Latin Extended (Polish, French, etc.) as different script
|
||||
// This handles cases like "鈴木雅之" vs "Masayuki Suzuki"
|
||||
spotifyASCII := isASCIIString(spotifyArtist)
|
||||
tidalASCII := isASCIIString(tidalArtist)
|
||||
if spotifyASCII != tidalASCII {
|
||||
fmt.Printf("[Tidal] Artist names in different scripts, assuming match: '%s' vs '%s'\n", spotifyArtist, tidalArtist)
|
||||
spotifyLatin := isLatinScript(spotifyArtist)
|
||||
tidalLatin := isLatinScript(tidalArtist)
|
||||
if spotifyLatin != tidalLatin {
|
||||
GoLog("[Tidal] Artist names in different scripts, assuming match: '%s' vs '%s'\n", spotifyArtist, tidalArtist)
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// titlesMatch checks if track titles are similar enough
|
||||
func titlesMatch(expectedTitle, foundTitle string) bool {
|
||||
normExpected := strings.ToLower(strings.TrimSpace(expectedTitle))
|
||||
normFound := strings.ToLower(strings.TrimSpace(foundTitle))
|
||||
|
||||
// Exact match
|
||||
if normExpected == normFound {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check if one contains the other
|
||||
if strings.Contains(normExpected, normFound) || strings.Contains(normFound, normExpected) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Clean both titles and compare
|
||||
cleanExpected := cleanTitle(normExpected)
|
||||
cleanFound := cleanTitle(normFound)
|
||||
|
||||
if cleanExpected == cleanFound {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check if cleaned versions contain each other
|
||||
if cleanExpected != "" && cleanFound != "" {
|
||||
if strings.Contains(cleanExpected, cleanFound) || strings.Contains(cleanFound, cleanExpected) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// Extract core title (before any parentheses/brackets)
|
||||
coreExpected := extractCoreTitle(normExpected)
|
||||
coreFound := extractCoreTitle(normFound)
|
||||
|
||||
if coreExpected != "" && coreFound != "" && coreExpected == coreFound {
|
||||
return true
|
||||
}
|
||||
|
||||
// If scripts are TRULY different (Latin vs CJK/Arabic/Cyrillic), assume match (transliteration)
|
||||
// Don't treat Latin Extended (Polish, French, etc.) as different script
|
||||
expectedLatin := isLatinScript(expectedTitle)
|
||||
foundLatin := isLatinScript(foundTitle)
|
||||
if expectedLatin != foundLatin {
|
||||
GoLog("[Tidal] Titles in different scripts, assuming match: '%s' vs '%s'\n", expectedTitle, foundTitle)
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// extractCoreTitle extracts the main title before any parentheses or brackets
|
||||
func extractCoreTitle(title string) string {
|
||||
// Find first occurrence of ( or [
|
||||
parenIdx := strings.Index(title, "(")
|
||||
bracketIdx := strings.Index(title, "[")
|
||||
dashIdx := strings.Index(title, " - ")
|
||||
|
||||
cutIdx := len(title)
|
||||
if parenIdx > 0 && parenIdx < cutIdx {
|
||||
cutIdx = parenIdx
|
||||
}
|
||||
if bracketIdx > 0 && bracketIdx < cutIdx {
|
||||
cutIdx = bracketIdx
|
||||
}
|
||||
if dashIdx > 0 && dashIdx < cutIdx {
|
||||
cutIdx = dashIdx
|
||||
}
|
||||
|
||||
return strings.TrimSpace(title[:cutIdx])
|
||||
}
|
||||
|
||||
// cleanTitle removes common suffixes from track titles for comparison
|
||||
func cleanTitle(title string) string {
|
||||
cleaned := title
|
||||
|
||||
// Version indicators to remove from parentheses/brackets
|
||||
versionPatterns := []string{
|
||||
"remaster", "remastered", "deluxe", "bonus", "single",
|
||||
"album version", "radio edit", "original mix", "extended",
|
||||
"club mix", "remix", "live", "acoustic", "demo",
|
||||
}
|
||||
|
||||
// Remove parenthetical content if it contains version indicators
|
||||
for {
|
||||
startParen := strings.LastIndex(cleaned, "(")
|
||||
endParen := strings.LastIndex(cleaned, ")")
|
||||
if startParen >= 0 && endParen > startParen {
|
||||
content := strings.ToLower(cleaned[startParen+1 : endParen])
|
||||
isVersionIndicator := false
|
||||
for _, pattern := range versionPatterns {
|
||||
if strings.Contains(content, pattern) {
|
||||
isVersionIndicator = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if isVersionIndicator {
|
||||
cleaned = strings.TrimSpace(cleaned[:startParen]) + cleaned[endParen+1:]
|
||||
continue
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
// Same for brackets
|
||||
for {
|
||||
startBracket := strings.LastIndex(cleaned, "[")
|
||||
endBracket := strings.LastIndex(cleaned, "]")
|
||||
if startBracket >= 0 && endBracket > startBracket {
|
||||
content := strings.ToLower(cleaned[startBracket+1 : endBracket])
|
||||
isVersionIndicator := false
|
||||
for _, pattern := range versionPatterns {
|
||||
if strings.Contains(content, pattern) {
|
||||
isVersionIndicator = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if isVersionIndicator {
|
||||
cleaned = strings.TrimSpace(cleaned[:startBracket]) + cleaned[endBracket+1:]
|
||||
continue
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
// Remove trailing " - version" patterns
|
||||
dashPatterns := []string{
|
||||
" - remaster", " - remastered", " - single version", " - radio edit",
|
||||
" - live", " - acoustic", " - demo", " - remix",
|
||||
}
|
||||
for _, pattern := range dashPatterns {
|
||||
if strings.HasSuffix(strings.ToLower(cleaned), pattern) {
|
||||
cleaned = cleaned[:len(cleaned)-len(pattern)]
|
||||
}
|
||||
}
|
||||
|
||||
// Remove multiple spaces
|
||||
for strings.Contains(cleaned, " ") {
|
||||
cleaned = strings.ReplaceAll(cleaned, " ", " ")
|
||||
}
|
||||
|
||||
return strings.TrimSpace(cleaned)
|
||||
}
|
||||
|
||||
// isLatinScript checks if a string is primarily Latin script
|
||||
// Returns true for ASCII and Latin Extended characters (European languages)
|
||||
// Returns false for CJK, Arabic, Cyrillic, etc.
|
||||
func isLatinScript(s string) bool {
|
||||
for _, r := range s {
|
||||
// Skip common punctuation and numbers
|
||||
if r < 128 {
|
||||
continue
|
||||
}
|
||||
// Latin Extended-A: U+0100 to U+017F (Polish, Czech, etc.)
|
||||
// Latin Extended-B: U+0180 to U+024F
|
||||
// Latin Extended Additional: U+1E00 to U+1EFF
|
||||
if (r >= 0x0100 && r <= 0x024F) || // Latin Extended A & B
|
||||
(r >= 0x1E00 && r <= 0x1EFF) || // Latin Extended Additional
|
||||
(r >= 0x00C0 && r <= 0x00FF) { // Latin-1 Supplement (accented chars)
|
||||
continue
|
||||
}
|
||||
// CJK ranges - definitely different script
|
||||
if (r >= 0x4E00 && r <= 0x9FFF) || // CJK Unified Ideographs
|
||||
(r >= 0x3040 && r <= 0x309F) || // Hiragana
|
||||
(r >= 0x30A0 && r <= 0x30FF) || // Katakana
|
||||
(r >= 0xAC00 && r <= 0xD7AF) || // Hangul (Korean)
|
||||
(r >= 0x0600 && r <= 0x06FF) || // Arabic
|
||||
(r >= 0x0400 && r <= 0x04FF) { // Cyrillic
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// isASCIIString checks if a string contains only ASCII characters
|
||||
func isASCIIString(s string) bool {
|
||||
for _, r := range s {
|
||||
@@ -1180,10 +1353,10 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
||||
// OPTIMIZATION: Check cache first for track ID
|
||||
if req.ISRC != "" {
|
||||
if cached := GetTrackIDCache().Get(req.ISRC); cached != nil && cached.TidalTrackID > 0 {
|
||||
fmt.Printf("[Tidal] Cache hit! Using cached track ID: %d\n", cached.TidalTrackID)
|
||||
GoLog("[Tidal] Cache hit! Using cached track ID: %d\n", cached.TidalTrackID)
|
||||
track, err = downloader.GetTrackInfoByID(cached.TidalTrackID)
|
||||
if err != nil {
|
||||
fmt.Printf("[Tidal] Cache hit but failed to get track info: %v\n", err)
|
||||
GoLog("[Tidal] Cache hit but failed to get track info: %v\n", err)
|
||||
track = nil // Fall through to normal search
|
||||
}
|
||||
}
|
||||
@@ -1192,10 +1365,10 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
||||
// OPTIMIZED: Try ISRC search with metadata (search by name, filter by ISRC)
|
||||
// Strategy 1: Search by metadata, match by ISRC (most accurate)
|
||||
if track == nil && req.ISRC != "" {
|
||||
fmt.Printf("[Tidal] Trying ISRC search: %s\n", req.ISRC)
|
||||
GoLog("[Tidal] Trying ISRC search: %s\n", req.ISRC)
|
||||
track, err = downloader.SearchTrackByMetadataWithISRC(req.TrackName, req.ArtistName, req.ISRC, expectedDurationSec)
|
||||
if track != nil {
|
||||
// Verify artist
|
||||
// Verify artist only (ISRC match is already accurate)
|
||||
tidalArtist := track.Artist.Name
|
||||
if len(track.Artists) > 0 {
|
||||
var artistNames []string
|
||||
@@ -1205,7 +1378,7 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
||||
tidalArtist = strings.Join(artistNames, ", ")
|
||||
}
|
||||
if !artistsMatch(req.ArtistName, tidalArtist) {
|
||||
fmt.Printf("[Tidal] Artist mismatch from ISRC search: expected '%s', got '%s'. Rejecting.\n",
|
||||
GoLog("[Tidal] Artist mismatch from ISRC search: expected '%s', got '%s'. Rejecting.\n",
|
||||
req.ArtistName, tidalArtist)
|
||||
track = nil
|
||||
}
|
||||
@@ -1214,14 +1387,14 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
||||
|
||||
// Strategy 2: Try SongLink only if ISRC search failed (slower but more accurate)
|
||||
if track == nil && req.SpotifyID != "" {
|
||||
fmt.Printf("[Tidal] ISRC search failed, trying SongLink...\n")
|
||||
GoLog("[Tidal] ISRC search failed, trying SongLink...\n")
|
||||
var tidalURL string
|
||||
var slErr error
|
||||
|
||||
// Check if SpotifyID is actually a Deezer ID (format: "deezer:xxxxx")
|
||||
if strings.HasPrefix(req.SpotifyID, "deezer:") {
|
||||
deezerID := strings.TrimPrefix(req.SpotifyID, "deezer:")
|
||||
fmt.Printf("[Tidal] Using Deezer ID for SongLink lookup: %s\n", deezerID)
|
||||
GoLog("[Tidal] Using Deezer ID for SongLink lookup: %s\n", deezerID)
|
||||
songlink := NewSongLinkClient()
|
||||
tidalURL, slErr = songlink.GetTidalURLFromDeezer(deezerID)
|
||||
} else {
|
||||
@@ -1244,9 +1417,9 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
||||
tidalArtist = strings.Join(artistNames, ", ")
|
||||
}
|
||||
|
||||
// Verify artist matches
|
||||
// Verify artist matches (SongLink is already accurate, no title check needed)
|
||||
if !artistsMatch(req.ArtistName, tidalArtist) {
|
||||
fmt.Printf("[Tidal] Artist mismatch from SongLink: expected '%s', got '%s'. Rejecting.\n",
|
||||
GoLog("[Tidal] Artist mismatch from SongLink: expected '%s', got '%s'. Rejecting.\n",
|
||||
req.ArtistName, tidalArtist)
|
||||
track = nil
|
||||
}
|
||||
@@ -1259,7 +1432,7 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
||||
}
|
||||
// Allow 3 seconds tolerance (same as PC version)
|
||||
if durationDiff > 3 {
|
||||
fmt.Printf("[Tidal] Duration mismatch from SongLink: expected %ds, got %ds. Rejecting.\n",
|
||||
GoLog("[Tidal] Duration mismatch from SongLink: expected %ds, got %ds. Rejecting.\n",
|
||||
expectedDurationSec, track.Duration)
|
||||
track = nil // Reject this match
|
||||
}
|
||||
@@ -1271,9 +1444,9 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
||||
|
||||
// Strategy 3: Search by metadata only (no ISRC requirement) - last resort
|
||||
if track == nil {
|
||||
fmt.Printf("[Tidal] Trying metadata search as last resort...\n")
|
||||
GoLog("[Tidal] Trying metadata search as last resort...\n")
|
||||
track, err = downloader.SearchTrackByMetadataWithISRC(req.TrackName, req.ArtistName, "", expectedDurationSec)
|
||||
// Verify artist for metadata search too
|
||||
// Verify artist AND title for metadata search
|
||||
if track != nil {
|
||||
tidalArtist := track.Artist.Name
|
||||
if len(track.Artists) > 0 {
|
||||
@@ -1283,8 +1456,14 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
||||
}
|
||||
tidalArtist = strings.Join(artistNames, ", ")
|
||||
}
|
||||
if !artistsMatch(req.ArtistName, tidalArtist) {
|
||||
fmt.Printf("[Tidal] Artist mismatch from metadata search: expected '%s', got '%s'. Rejecting.\n",
|
||||
|
||||
// Verify title first
|
||||
if !titlesMatch(req.TrackName, track.Title) {
|
||||
GoLog("[Tidal] Title mismatch from metadata search: expected '%s', got '%s'. Rejecting.\n",
|
||||
req.TrackName, track.Title)
|
||||
track = nil
|
||||
} else if !artistsMatch(req.ArtistName, tidalArtist) {
|
||||
GoLog("[Tidal] Artist mismatch from metadata search: expected '%s', got '%s'. Rejecting.\n",
|
||||
req.ArtistName, tidalArtist)
|
||||
track = nil
|
||||
}
|
||||
@@ -1308,7 +1487,7 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
||||
}
|
||||
tidalArtist = strings.Join(artistNames, ", ")
|
||||
}
|
||||
fmt.Printf("[Tidal] Match found: '%s' by '%s' (duration: %ds)\n", track.Title, tidalArtist, track.Duration)
|
||||
GoLog("[Tidal] Match found: '%s' by '%s' (duration: %ds)\n", track.Title, tidalArtist, track.Duration)
|
||||
|
||||
// Cache the track ID for future use
|
||||
if req.ISRC != "" {
|
||||
@@ -1339,7 +1518,7 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
||||
// Clean up any leftover .tmp files from previous failed downloads
|
||||
tmpPath := outputPath + ".m4a.tmp"
|
||||
if _, err := os.Stat(tmpPath); err == nil {
|
||||
fmt.Printf("[Tidal] Cleaning up leftover temp file: %s\n", tmpPath)
|
||||
GoLog("[Tidal] Cleaning up leftover temp file: %s\n", tmpPath)
|
||||
os.Remove(tmpPath)
|
||||
}
|
||||
|
||||
@@ -1348,7 +1527,7 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
||||
if quality == "" {
|
||||
quality = "LOSSLESS"
|
||||
}
|
||||
fmt.Printf("[Tidal] Using quality: %s\n", quality)
|
||||
GoLog("[Tidal] Using quality: %s\n", quality)
|
||||
|
||||
// Get download URL using parallel API requests
|
||||
downloadInfo, err := downloader.GetDownloadURL(track.ID, quality)
|
||||
@@ -1357,7 +1536,7 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
||||
}
|
||||
|
||||
// Log actual quality received
|
||||
fmt.Printf("[Tidal] Actual quality: %d-bit/%dHz\n", downloadInfo.BitDepth, downloadInfo.SampleRate)
|
||||
GoLog("[Tidal] Actual quality: %d-bit/%dHz\n", downloadInfo.BitDepth, downloadInfo.SampleRate)
|
||||
|
||||
// START PARALLEL: Fetch cover and lyrics while downloading audio
|
||||
var parallelResult *ParallelDownloadResult
|
||||
@@ -1375,8 +1554,8 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
||||
}()
|
||||
|
||||
// Download audio file with item ID for progress tracking
|
||||
fmt.Printf("[Tidal] Starting download to: %s\n", outputPath)
|
||||
fmt.Printf("[Tidal] Download URL type: %s\n", func() string {
|
||||
GoLog("[Tidal] Starting download to: %s\n", outputPath)
|
||||
GoLog("[Tidal] Download URL type: %s\n", func() string {
|
||||
if strings.HasPrefix(downloadInfo.URL, "MANIFEST:") {
|
||||
return "MANIFEST (DASH/BTS)"
|
||||
}
|
||||
@@ -1384,7 +1563,7 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
||||
}())
|
||||
|
||||
if err := downloader.DownloadFile(downloadInfo.URL, outputPath, req.ItemID); err != nil {
|
||||
fmt.Printf("[Tidal] Download failed with error: %v\n", err)
|
||||
GoLog("[Tidal] Download failed with error: %v\n", err)
|
||||
return TidalDownloadResult{}, fmt.Errorf("download failed: %w", err)
|
||||
}
|
||||
fmt.Println("[Tidal] Download completed successfully")
|
||||
@@ -1405,7 +1584,7 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
||||
if _, err := os.Stat(m4aPath); err == nil {
|
||||
// File was saved as M4A, use that path
|
||||
actualOutputPath = m4aPath
|
||||
fmt.Printf("[Tidal] File saved as M4A (DASH stream): %s\n", actualOutputPath)
|
||||
GoLog("[Tidal] File saved as M4A (DASH stream): %s\n", actualOutputPath)
|
||||
} else if _, err := os.Stat(outputPath); err != nil {
|
||||
// Neither FLAC nor M4A exists
|
||||
return TidalDownloadResult{}, fmt.Errorf("download completed but file not found at %s or %s", outputPath, m4aPath)
|
||||
@@ -1428,7 +1607,7 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
||||
var coverData []byte
|
||||
if parallelResult != nil && parallelResult.CoverData != nil {
|
||||
coverData = parallelResult.CoverData
|
||||
fmt.Printf("[Tidal] Using parallel-fetched cover (%d bytes)\n", len(coverData))
|
||||
GoLog("[Tidal] Using parallel-fetched cover (%d bytes)\n", len(coverData))
|
||||
}
|
||||
|
||||
// Embed metadata based on file type
|
||||
@@ -1439,9 +1618,9 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
||||
|
||||
// Embed lyrics from parallel fetch
|
||||
if req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" {
|
||||
fmt.Printf("[Tidal] Embedding parallel-fetched lyrics (%d lines)...\n", len(parallelResult.LyricsData.Lines))
|
||||
GoLog("[Tidal] Embedding parallel-fetched lyrics (%d lines)...\n", len(parallelResult.LyricsData.Lines))
|
||||
if embedErr := EmbedLyrics(actualOutputPath, parallelResult.LyricsLRC); embedErr != nil {
|
||||
fmt.Printf("[Tidal] Warning: failed to embed lyrics: %v\n", embedErr)
|
||||
GoLog("[Tidal] Warning: failed to embed lyrics: %v\n", embedErr)
|
||||
} else {
|
||||
fmt.Println("[Tidal] Lyrics embedded successfully")
|
||||
}
|
||||
@@ -1450,7 +1629,7 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
||||
}
|
||||
} else if strings.HasSuffix(actualOutputPath, ".m4a") {
|
||||
// Embed metadata to M4A file
|
||||
// fmt.Printf("[Tidal] Embedding metadata to M4A file...\n")
|
||||
// GoLog("[Tidal] Embedding metadata to M4A file...\n")
|
||||
|
||||
// Add lyrics to metadata if available
|
||||
// if req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" {
|
||||
@@ -1464,7 +1643,7 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
||||
fmt.Println("[Tidal] Skipping metadata embedding for M4A file (will be handled after FFmpeg conversion)")
|
||||
|
||||
// if err := EmbedM4AMetadata(actualOutputPath, metadata, coverData); err != nil {
|
||||
// fmt.Printf("[Tidal] Warning: failed to embed M4A metadata: %v\n", err)
|
||||
// GoLog("[Tidal] Warning: failed to embed M4A metadata: %v\n", err)
|
||||
// } else {
|
||||
// fmt.Println("[Tidal] M4A metadata embedded successfully")
|
||||
// }
|
||||
|
||||
Reference in New Issue
Block a user