refactor: more code cleanup

This commit is contained in:
zarzet
2026-01-17 10:04:21 +07:00
parent b99764b1ad
commit 8d92d22fda
37 changed files with 33 additions and 356 deletions
+1 -5
View File
@@ -27,10 +27,9 @@ type AmazonDownloader struct {
}
var (
// Global Amazon downloader instance for connection reuse
globalAmazonDownloader *AmazonDownloader
amazonDownloaderOnce sync.Once
amazonRateLimitMu sync.Mutex // Mutex for rate limiting
amazonRateLimitMu sync.Mutex
)
// DoubleDoubleSubmitResponse is the response from DoubleDouble submit endpoint
@@ -55,7 +54,6 @@ func amazonArtistsMatch(expectedArtist, foundArtist string) bool {
normExpected := strings.ToLower(strings.TrimSpace(expectedArtist))
normFound := strings.ToLower(strings.TrimSpace(foundArtist))
// Exact match
if normExpected == normFound {
return true
}
@@ -82,8 +80,6 @@ func amazonArtistsMatch(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 := amazonIsASCIIString(expectedArtist)
foundASCII := amazonIsASCIIString(foundArtist)
if expectedASCII != foundASCII {
-1
View File
@@ -52,7 +52,6 @@ func cancelDownload(itemID string) {
}
cancelMu.Unlock()
// Hide progress for cancelled items.
RemoveItemProgress(itemID)
}
-9
View File
@@ -32,13 +32,11 @@ func downloadCoverToMemory(coverURL string, maxQuality bool) ([]byte, error) {
GoLog("[Cover] Original URL: %s", coverURL)
// First upgrade small (300) to medium (640) - always do this
downloadURL := convertSmallToMedium(coverURL)
if downloadURL != coverURL {
GoLog("[Cover] Upgraded 300x300 → 640x640")
}
// Then upgrade to max quality if requested
if maxQuality {
maxURL := upgradeToMaxQuality(downloadURL)
if maxURL != downloadURL {
@@ -53,7 +51,6 @@ func downloadCoverToMemory(coverURL string, maxQuality bool) ([]byte, error) {
client := NewHTTPClientWithTimeout(DefaultTimeout)
// Create request with User-Agent (required by Spotify CDN)
req, err := http.NewRequest("GET", downloadURL, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
@@ -74,8 +71,6 @@ func downloadCoverToMemory(coverURL string, maxQuality bool) ([]byte, error) {
return nil, fmt.Errorf("failed to read cover data: %w", err)
}
// Calculate approximate resolution from file size
// JPEG ~2000x2000 is typically 300-600KB, 640x640 is ~50-100KB
sizeKB := len(data) / 1024
var resolution string
if sizeKB > 200 {
@@ -94,10 +89,6 @@ func downloadCoverToMemory(coverURL string, maxQuality bool) ([]byte, error) {
// Same logic as PC version - directly replaces 640x640 size code with max resolution
// No HEAD verification needed - Spotify CDN always serves max resolution if available
func upgradeToMaxQuality(coverURL string) string {
// Spotify image URLs can be upgraded by changing the size parameter
// Format: https://i.scdn.co/image/ab67616d0000b273...
// ab67616d0000b273 = 640x640
// ab67616d000082c1 = Max resolution (~2000x2000)
if strings.Contains(coverURL, spotifySize640) {
return strings.Replace(coverURL, spotifySize640, spotifySizeMax, 1)
+1 -3
View File
@@ -22,8 +22,7 @@ const (
deezerCacheTTL = 10 * time.Minute
// Parallel ISRC fetching settings
deezerMaxParallelISRC = 10 // Max concurrent ISRC fetches
deezerMaxParallelISRC = 10
)
// DeezerClient handles Deezer API interactions (no auth required)
@@ -36,7 +35,6 @@ type DeezerClient struct {
cacheMu sync.RWMutex
}
// Singleton instance
var (
deezerClient *DeezerClient
deezerClientOnce sync.Once
+1 -7
View File
@@ -18,11 +18,10 @@ type ISRCIndex struct {
mu sync.RWMutex
}
// Global ISRC index cache (per output directory)
var (
isrcIndexCache = make(map[string]*ISRCIndex)
isrcIndexCacheMu sync.RWMutex
isrcIndexTTL = 5 * time.Minute // Cache TTL - rebuild after 5 minutes
isrcIndexTTL = 5 * time.Minute
)
// GetISRCIndex returns or builds an ISRC index for the given directory
@@ -31,7 +30,6 @@ func GetISRCIndex(outputDir string) *ISRCIndex {
idx, exists := isrcIndexCache[outputDir]
isrcIndexCacheMu.RUnlock()
// Return cached index if still valid
if exists && time.Since(idx.buildTime) < isrcIndexTTL {
return idx
}
@@ -40,7 +38,6 @@ func GetISRCIndex(outputDir string) *ISRCIndex {
}
// buildISRCIndex scans a directory and builds a map of ISRC -> file path
// Same implementation as PC version for consistency
func buildISRCIndex(outputDir string) *ISRCIndex {
idx := &ISRCIndex{
index: make(map[string]string),
@@ -85,7 +82,6 @@ func buildISRCIndex(outputDir string) *ISRCIndex {
return idx
}
// lookup checks if an ISRC exists in the index (internal, returns bool)
func (idx *ISRCIndex) lookup(isrc string) (string, bool) {
if isrc == "" {
return "", false
@@ -188,7 +184,6 @@ type FileExistenceResult struct {
// It builds an ISRC index from the output directory once, then checks all tracks against it
// Same implementation as PC version for consistency
func CheckFilesExistParallel(outputDir string, tracksJSON string) (string, error) {
// Parse input JSON
var tracks []struct {
ISRC string `json:"isrc"`
TrackName string `json:"track_name"`
@@ -232,7 +227,6 @@ func CheckFilesExistParallel(outputDir string, tracksJSON string) (string, error
wg.Wait()
// Return results as JSON
resultJSON, err := json.Marshal(results)
if err != nil {
return "", fmt.Errorf("failed to marshal results: %w", err)
-23
View File
@@ -184,7 +184,6 @@ type DownloadResponse struct {
SkipMetadataEnrichment bool `json:"skip_metadata_enrichment,omitempty"`
}
// DownloadResult is a generic result type for all downloaders
// DownloadResult is a generic result type for all downloaders
type DownloadResult struct {
FilePath string
@@ -531,7 +530,6 @@ func InitItemProgress(itemID string) {
// FinishItemProgress marks a download item as complete and removes tracking
func FinishItemProgress(itemID string) {
CompleteItemProgress(itemID)
// Don't remove immediately - let Flutter poll one more time to see 100%
}
// ClearItemProgress removes progress tracking for a specific item
@@ -579,7 +577,6 @@ func ReadFileMetadata(filePath string) (string, error) {
"duration": duration,
}
// Add quality info if available
if qualityErr == nil {
result["bit_depth"] = quality.BitDepth
result["sample_rate"] = quality.SampleRate
@@ -677,7 +674,6 @@ func FetchLyrics(spotifyID, trackName, artistName string) (string, error) {
// GetLyricsLRC fetches lyrics and converts to LRC format string with metadata headers
// First tries to extract from file, then falls back to fetching from internet
func GetLyricsLRC(spotifyID, trackName, artistName string, filePath string) (string, error) {
// Try to extract from file first (much faster)
if filePath != "" {
lyrics, err := ExtractLyrics(filePath)
if err == nil && lyrics != "" {
@@ -685,7 +681,6 @@ func GetLyricsLRC(spotifyID, trackName, artistName string, filePath string) (str
}
}
// Fallback to fetching from internet
client := NewLyricsClient()
lyricsData, err := client.FetchLyricsAllSources(spotifyID, trackName, artistName)
if err != nil {
@@ -739,7 +734,6 @@ func PreWarmTrackCacheJSON(tracksJSON string) (string, error) {
}
}
// Run in background
go PreWarmTrackCache(requests)
resp := map[string]interface{}{
@@ -873,7 +867,6 @@ func ConvertSpotifyToDeezer(resourceType, spotifyID string) (string, error) {
return "", fmt.Errorf("could not find Deezer equivalent: %w", err)
}
// Fetch metadata from Deezer
trackResp, err := deezerClient.GetTrack(ctx, deezerID)
if err != nil {
return "", fmt.Errorf("failed to fetch Deezer metadata: %w", err)
@@ -893,7 +886,6 @@ func ConvertSpotifyToDeezer(resourceType, spotifyID string) (string, error) {
return "", fmt.Errorf("could not find Deezer album: %w", err)
}
// Fetch album metadata from Deezer
albumResp, err := deezerClient.GetAlbum(ctx, deezerID)
if err != nil {
return "", fmt.Errorf("failed to fetch Deezer album metadata: %w", err)
@@ -916,10 +908,8 @@ func GetSpotifyMetadataWithDeezerFallback(spotifyURL string) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
// Try Spotify first
client, err := NewSpotifyMetadataClient()
if err != nil {
// No Spotify credentials - fall through to Deezer fallback
LogWarn("Spotify", "Credentials not configured, falling back to Deezer")
} else {
data, err := client.GetFilteredData(ctx, spotifyURL, false, 0)
@@ -933,12 +923,10 @@ func GetSpotifyMetadataWithDeezerFallback(spotifyURL string) (string, error) {
errStr := strings.ToLower(err.Error())
if !strings.Contains(errStr, "429") && !strings.Contains(errStr, "rate") && !strings.Contains(errStr, "limit") {
// Not a rate limit error, return original error
return "", err
}
}
// Rate limited - try Deezer fallback for tracks and albums
parsed, parseErr := parseSpotifyURI(spotifyURL)
if parseErr != nil {
return "", fmt.Errorf("spotify rate limited and failed to parse URL: %w", parseErr)
@@ -950,7 +938,6 @@ func GetSpotifyMetadataWithDeezerFallback(spotifyURL string) (string, error) {
return ConvertSpotifyToDeezer(parsed.Type, parsed.ID)
}
// Artist and playlist not supported for fallback
if parsed.Type == "artist" {
return "", fmt.Errorf("spotify rate limited. Artist pages require Spotify API - please try again later")
}
@@ -1015,7 +1002,6 @@ func GetAmazonURLFromDeezerTrack(deezerTrackID string) (string, error) {
}
func errorResponse(msg string) (string, error) {
// Determine error type based on message
errorType := "unknown"
lowerMsg := strings.ToLower(msg)
@@ -1104,7 +1090,6 @@ func LoadExtensionFromPath(filePath string) (string, error) {
return "", err
}
// Initialize with saved settings
settingsStore := GetExtensionSettingsStore()
settings := settingsStore.GetAll(ext.ID)
if len(settings) > 0 {
@@ -1255,7 +1240,6 @@ func SetExtensionSettingsJSON(extensionID, settingsJSON string) error {
return err
}
// Re-initialize extension with new settings
manager := GetExtensionManager()
return manager.InitializeExtension(extensionID, settings)
}
@@ -1450,7 +1434,6 @@ func EnrichTrackWithExtensionJSON(extensionID, trackJSON string) (string, error)
}
if !ext.Manifest.IsMetadataProvider() {
// Not a metadata provider, return original
return trackJSON, nil
}
@@ -1462,7 +1445,6 @@ func EnrichTrackWithExtensionJSON(extensionID, trackJSON string) (string, error)
provider := NewExtensionProviderWrapper(ext)
enrichedTrack, err := provider.EnrichTrack(&track)
if err != nil {
// Error enriching, return original
return trackJSON, nil
}
@@ -1576,7 +1558,6 @@ func HandleURLWithExtensionJSON(url string) (string, error) {
"cover_url": result.CoverURL,
}
// Add track if single track
if result.Track != nil {
response["track"] = map[string]interface{}{
"id": result.Track.ID,
@@ -1594,7 +1575,6 @@ func HandleURLWithExtensionJSON(url string) (string, error) {
}
}
// Add tracks if multiple
if len(result.Tracks) > 0 {
tracks := make([]map[string]interface{}, len(result.Tracks))
for i, track := range result.Tracks {
@@ -1632,7 +1612,6 @@ func HandleURLWithExtensionJSON(url string) (string, error) {
}
}
// Add artist info if present
if result.Artist != nil {
artistResponse := map[string]interface{}{
"id": result.Artist.ID,
@@ -1643,7 +1622,6 @@ func HandleURLWithExtensionJSON(url string) (string, error) {
"provider_id": result.Artist.ProviderID,
}
// Add albums if present
if len(result.Artist.Albums) > 0 {
albums := make([]map[string]interface{}, len(result.Artist.Albums))
for i, album := range result.Artist.Albums {
@@ -1666,7 +1644,6 @@ func HandleURLWithExtensionJSON(url string) (string, error) {
artistResponse["albums"] = albums
}
// Add top tracks if present
if len(result.Artist.TopTracks) > 0 {
topTracks := make([]map[string]interface{}, len(result.Artist.TopTracks))
for i, track := range result.Artist.TopTracks {
+4 -7
View File
@@ -18,11 +18,9 @@ import (
// compareVersions compares two semantic version strings
// Returns: -1 if v1 < v2, 0 if v1 == v2, 1 if v1 > v2
func compareVersions(v1, v2 string) int {
// Parse version parts
parts1 := strings.Split(strings.TrimPrefix(v1, "v"), ".")
parts2 := strings.Split(strings.TrimPrefix(v2, "v"), ".")
// Pad shorter version with zeros
maxLen := len(parts1)
if len(parts2) > maxLen {
maxLen = len(parts2)
@@ -52,12 +50,12 @@ func compareVersions(v1, v2 string) int {
type LoadedExtension struct {
ID string `json:"id"`
Manifest *ExtensionManifest `json:"manifest"`
VM *goja.Runtime `json:"-"` // Goja VM instance (not serialized)
VM *goja.Runtime `json:"-"`
Enabled bool `json:"enabled"`
Error string `json:"error,omitempty"`
DataDir string `json:"data_dir"` // Extension's data directory
SourceDir string `json:"source_dir"` // Where extension files are extracted
IconPath string `json:"icon_path"` // Full path to icon file (if exists)
DataDir string `json:"data_dir"`
SourceDir string `json:"source_dir"`
IconPath string `json:"icon_path"`
}
// ExtensionManager manages all loaded extensions
@@ -68,7 +66,6 @@ type ExtensionManager struct {
dataDir string // Base directory for extension data
}
// Global extension manager instance
var (
globalExtManager *ExtensionManager
globalExtManagerOnce sync.Once
-5
View File
@@ -10,10 +10,8 @@ import (
"github.com/dop251/goja"
)
// Default timeout for JS execution (30 seconds)
const DefaultJSTimeout = 30 * time.Second
// Global auth state for extensions (stores pending auth codes)
var (
extensionAuthState = make(map[string]*ExtensionAuthState)
extensionAuthStateMu sync.RWMutex
@@ -39,7 +37,6 @@ type PendingAuthRequest struct {
CallbackURL string
}
// Global pending auth requests (Flutter polls this)
var (
pendingAuthRequests = make(map[string]*PendingAuthRequest)
pendingAuthRequestsMu sync.RWMutex
@@ -52,7 +49,6 @@ func GetPendingAuthRequest(extensionID string) *PendingAuthRequest {
return pendingAuthRequests[extensionID]
}
// ClearPendingAuthRequest clears pending auth request (called from Flutter after opening URL)
func ClearPendingAuthRequest(extensionID string) {
pendingAuthRequestsMu.Lock()
defer pendingAuthRequestsMu.Unlock()
@@ -101,7 +97,6 @@ type ExtensionRuntime struct {
// NewExtensionRuntime creates a new runtime for an extension
func NewExtensionRuntime(ext *LoadedExtension) *ExtensionRuntime {
// Create a cookie jar for this extension
jar, _ := newSimpleCookieJar()
runtime := &ExtensionRuntime{
-7
View File
@@ -11,23 +11,18 @@ var invalidChars = regexp.MustCompile(`[<>:"/\\|?*\x00-\x1f]`)
// sanitizeFilename removes invalid characters from filename
func sanitizeFilename(filename string) string {
// Replace invalid characters with underscore
sanitized := invalidChars.ReplaceAllString(filename, "_")
// Remove leading/trailing spaces and dots
sanitized = strings.TrimSpace(sanitized)
sanitized = strings.Trim(sanitized, ".")
// Collapse multiple underscores
multiUnderscore := regexp.MustCompile(`_+`)
sanitized = multiUnderscore.ReplaceAllString(sanitized, "_")
// Limit length (Android has 255 byte limit for filenames)
if len(sanitized) > 200 {
sanitized = sanitized[:200]
}
// Ensure not empty
if sanitized == "" {
sanitized = "untitled"
}
@@ -43,7 +38,6 @@ func buildFilenameFromTemplate(template string, metadata map[string]interface{})
result := template
// Replace placeholders
placeholders := map[string]string{
"{title}": getString(metadata, "title"),
"{artist}": getString(metadata, "artist"),
@@ -63,7 +57,6 @@ func buildFilenameFromTemplate(template string, metadata map[string]interface{})
func getString(m map[string]interface{}, key string) string {
if v, ok := m[key]; ok {
if s, ok := v.(string); ok {
// Trim leading/trailing whitespace to prevent filename issues
return strings.TrimSpace(s)
}
}
+13 -26
View File
@@ -20,13 +20,11 @@ import (
// getRandomUserAgent generates a random Windows Chrome User-Agent string
// Uses same format as PC version (referensi/backend/spotify_metadata.go) for better API compatibility
func getRandomUserAgent() string {
// Windows 10/11 Chrome format - same as PC version for maximum compatibility
// Some APIs may block mobile User-Agents, so we use desktop format
winMajor := rand.Intn(2) + 10 // Windows 10 or 11
winMajor := rand.Intn(2) + 10
chromeVersion := rand.Intn(25) + 100 // Chrome 100-124
chromeBuild := rand.Intn(1500) + 3000 // Build 3000-4500
chromePatch := rand.Intn(65) + 60 // Patch 60-125
chromeVersion := rand.Intn(25) + 100
chromeBuild := rand.Intn(1500) + 3000
chromePatch := rand.Intn(65) + 60
return fmt.Sprintf(
"Mozilla/5.0 (Windows NT %d.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/%d.0.%d.%d Safari/537.36",
@@ -39,7 +37,6 @@ func getRandomUserAgent() string {
// getRandomMacUserAgent generates a random Mac Chrome User-Agent string
// Alternative format matching referensi/backend/spotify_metadata.go exactly
// Kept for potential future use
// func getRandomMacUserAgent() string {
// macMajor := rand.Intn(4) + 11 // macOS 11-14
// macMinor := rand.Intn(5) + 4 // Minor 4-8
@@ -66,7 +63,6 @@ func getRandomUserAgent() string {
// }
// getRandomDesktopUserAgent randomly picks between Windows and Mac User-Agent
// Kept for potential future use
// func getRandomDesktopUserAgent() string {
// if rand.Intn(2) == 0 {
// return getRandomUserAgent() // Windows
@@ -74,17 +70,15 @@ func getRandomUserAgent() string {
// return getRandomMacUserAgent() // Mac
// }
// Default timeout values
const (
DefaultTimeout = 60 * time.Second // Default HTTP timeout
DownloadTimeout = 120 * time.Second // Timeout for file downloads
SongLinkTimeout = 30 * time.Second // Timeout for SongLink API
DefaultMaxRetries = 3 // Default retry count
DefaultRetryDelay = 1 * time.Second // Initial retry delay
DefaultTimeout = 60 * time.Second
DownloadTimeout = 120 * time.Second
SongLinkTimeout = 30 * time.Second
DefaultMaxRetries = 3
DefaultRetryDelay = 1 * time.Second
)
// Shared transport with connection pooling to prevent TCP exhaustion
// Optimized for large file downloads (FLAC ~30-50MB)
var sharedTransport = &http.Transport{
DialContext: (&net.Dialer{
Timeout: 30 * time.Second,
@@ -96,27 +90,24 @@ var sharedTransport = &http.Transport{
IdleConnTimeout: 90 * time.Second,
TLSHandshakeTimeout: 10 * time.Second,
ExpectContinueTimeout: 1 * time.Second,
DisableKeepAlives: false, // Enable keep-alives for connection reuse
DisableKeepAlives: false,
ForceAttemptHTTP2: true,
WriteBufferSize: 64 * 1024, // 64KB write buffer
ReadBufferSize: 64 * 1024, // 64KB read buffer
DisableCompression: true, // FLAC is already compressed
WriteBufferSize: 64 * 1024,
ReadBufferSize: 64 * 1024,
DisableCompression: true,
}
// Shared HTTP client for general requests (reuses connections)
var sharedClient = &http.Client{
Transport: sharedTransport,
Timeout: DefaultTimeout,
}
// Shared HTTP client for downloads (longer timeout, reuses connections)
var downloadClient = &http.Client{
Transport: sharedTransport,
Timeout: DownloadTimeout,
}
// NewHTTPClientWithTimeout creates an HTTP client with specified timeout
// Uses shared transport for connection reuse
func NewHTTPClientWithTimeout(timeout time.Duration) *http.Client {
return &http.Client{
Transport: sharedTransport,
@@ -124,18 +115,15 @@ func NewHTTPClientWithTimeout(timeout time.Duration) *http.Client {
}
}
// GetSharedClient returns the shared HTTP client for general requests
func GetSharedClient() *http.Client {
return sharedClient
}
// GetDownloadClient returns the shared HTTP client for downloads
func GetDownloadClient() *http.Client {
return downloadClient
}
// CloseIdleConnections closes idle connections in the shared transport
// Call this periodically during large batch downloads to prevent connection buildup
func CloseIdleConnections() {
sharedTransport.CloseIdleConnections()
}
@@ -146,7 +134,6 @@ func DoRequestWithUserAgent(client *http.Client, req *http.Request) (*http.Respo
req.Header.Set("User-Agent", getRandomUserAgent())
resp, err := client.Do(req)
if err != nil {
// Check for ISP blocking
CheckAndLogISPBlocking(err, req.URL.String(), "HTTP")
}
return resp, err
+1 -3
View File
@@ -21,7 +21,7 @@ type LogBuffer struct {
entries []LogEntry
maxSize int
mu sync.RWMutex
loggingEnabled bool // Whether logging is enabled (controlled by Flutter)
loggingEnabled bool
}
var (
@@ -60,7 +60,6 @@ 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
}
@@ -89,7 +88,6 @@ func (lb *LogBuffer) GetAll() string {
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()
-4
View File
@@ -128,14 +128,12 @@ func (c *LyricsClient) FetchLyricsFromLRCLibSearch(query string) (*LyricsRespons
}
func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName string) (*LyricsResponse, error) {
// Strategy 1: Direct match with artist and track name
lyrics, err := c.FetchLyricsWithMetadata(artistName, trackName)
if err == nil && lyrics != nil && len(lyrics.Lines) > 0 {
lyrics.Source = "LRCLIB"
return lyrics, nil
}
// Strategy 2: Try with simplified track name
simplifiedTrack := simplifyTrackName(trackName)
if simplifiedTrack != trackName {
lyrics, err = c.FetchLyricsWithMetadata(artistName, simplifiedTrack)
@@ -145,7 +143,6 @@ func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName st
}
}
// Strategy 3: Search with full query
query := artistName + " " + trackName
lyrics, err = c.FetchLyricsFromLRCLibSearch(query)
if err == nil && lyrics != nil && len(lyrics.Lines) > 0 {
@@ -153,7 +150,6 @@ func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName st
return lyrics, nil
}
// Strategy 4: Search with simplified query
if simplifiedTrack != trackName {
query = artistName + " " + simplifiedTrack
lyrics, err = c.FetchLyricsFromLRCLibSearch(query)
+3 -11
View File
@@ -35,7 +35,7 @@ func GetTrackIDCache() *TrackIDCache {
trackIDCacheOnce.Do(func() {
globalTrackIDCache = &TrackIDCache{
cache: make(map[string]*TrackIDCacheEntry),
ttl: 30 * time.Minute, // Cache for 30 minutes
ttl: 30 * time.Minute,
}
})
return globalTrackIDCache
@@ -135,7 +135,6 @@ func FetchCoverAndLyricsParallel(
result := &ParallelDownloadResult{}
var wg sync.WaitGroup
// Download cover in parallel
if coverURL != "" {
wg.Add(1)
go func() {
@@ -165,7 +164,6 @@ func FetchCoverAndLyricsParallel(
fmt.Printf("[Parallel] Lyrics fetch failed: %v\n", err)
} else if lyrics != nil && len(lyrics.Lines) > 0 {
result.LyricsData = lyrics
// Use LRC with metadata headers (like PC version)
result.LyricsLRC = convertToLRCWithMetadata(lyrics, trackName, artistName)
fmt.Printf("[Parallel] Lyrics fetched: %d lines\n", len(lyrics.Lines))
} else {
@@ -202,12 +200,10 @@ func PreWarmTrackCache(requests []PreWarmCacheRequest) {
fmt.Printf("[Cache] Pre-warming cache for %d tracks...\n", len(requests))
cache := GetTrackIDCache()
// Limit concurrent pre-warm requests
semaphore := make(chan struct{}, 3) // Max 3 concurrent
semaphore := make(chan struct{}, 3)
var wg sync.WaitGroup
for _, req := range requests {
// Skip if already cached
if cached := cache.Get(req.ISRC); cached != nil {
continue
}
@@ -252,11 +248,9 @@ func preWarmQobuzCache(isrc string) {
}
func preWarmAmazonCache(isrc, spotifyID string) {
// Amazon uses SongLink to get URL, so we pre-warm by checking availability
client := NewSongLinkClient()
availability, err := client.CheckTrackAvailability(spotifyID, isrc)
if err == nil && availability != nil && availability.Amazon {
// Store Amazon URL in cache (using ISRC as key)
GetTrackIDCache().SetAmazon(isrc, availability.AmazonURL)
fmt.Printf("[Cache] Cached Amazon URL for ISRC %s\n", isrc)
}
@@ -270,10 +264,8 @@ func preWarmAmazonCache(isrc, spotifyID string) {
// tracksJSON is a JSON array of {isrc, track_name, artist_name, service}
func PreWarmCache(tracksJSON string) error {
var requests []PreWarmCacheRequest
// Parse JSON (simplified - in production use proper JSON parsing)
// For now, this is called from exports.go with proper parsing
go PreWarmTrackCache(requests) // Run in background
go PreWarmTrackCache(requests)
return nil
}
+1 -2
View File
@@ -44,7 +44,6 @@ var (
)
// getProgress returns current download progress from multi-progress system
// Returns first active item's progress for backward compatibility
func getProgress() DownloadProgress {
multiMu.RLock()
defer multiMu.RUnlock()
@@ -52,7 +51,7 @@ func getProgress() DownloadProgress {
for _, item := range multiProgress.Items {
return DownloadProgress{
CurrentFile: item.ItemID,
Progress: item.Progress * 100, // Convert to percentage
Progress: item.Progress * 100,
BytesTotal: item.BytesTotal,
BytesReceived: item.BytesReceived,
IsDownloading: item.IsDownloading,
-8
View File
@@ -25,7 +25,6 @@ type QobuzDownloader struct {
}
var (
// Global Qobuz downloader instance for connection reuse
globalQobuzDownloader *QobuzDownloader
qobuzDownloaderOnce sync.Once
)
@@ -66,22 +65,17 @@ func qobuzArtistsMatch(expectedArtist, foundArtist string) bool {
return true
}
// Split expected artists by common separators (comma, feat, ft., &, and)
// e.g., "RADWIMPS, Toko Miura" or "RADWIMPS feat. Toko Miura"
expectedArtists := qobuzSplitArtists(normExpected)
foundArtists := qobuzSplitArtists(normFound)
// Check if ANY expected artist matches ANY found artist
for _, exp := range expectedArtists {
for _, fnd := range foundArtists {
if exp == fnd {
return true
}
// Also check contains for partial matches
if strings.Contains(exp, fnd) || strings.Contains(fnd, exp) {
return true
}
// Check same words different order
if qobuzSameWordsUnordered(exp, fnd) {
GoLog("[Qobuz] Artist names have same words in different order: '%s' vs '%s'\n", exp, fnd)
return true
@@ -89,8 +83,6 @@ func qobuzArtistsMatch(expectedArtist, foundArtist string) bool {
}
}
// 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 {
-6
View File
@@ -30,31 +30,25 @@ func (r *RateLimiter) WaitForSlot() {
now := time.Now()
// Remove timestamps outside the window
r.cleanOldTimestamps(now)
// If under limit, record and return immediately
if len(r.timestamps) < r.maxRequests {
r.timestamps = append(r.timestamps, now)
return
}
// Calculate wait time until oldest timestamp expires
oldestTimestamp := r.timestamps[0]
waitUntil := oldestTimestamp.Add(r.window)
waitDuration := waitUntil.Sub(now)
if waitDuration > 0 {
// Release lock while waiting
r.mu.Unlock()
time.Sleep(waitDuration)
r.mu.Lock()
// Clean again after waiting
r.cleanOldTimestamps(time.Now())
}
// Record this request
r.timestamps = append(r.timestamps, time.Now())
}
+1 -30
View File
@@ -31,7 +31,6 @@ type TrackAvailability struct {
}
var (
// Global SongLink client instance for connection reuse
globalSongLinkClient *SongLinkClient
songLinkClientOnce sync.Once
)
@@ -40,7 +39,7 @@ var (
func NewSongLinkClient() *SongLinkClient {
songLinkClientOnce.Do(func() {
globalSongLinkClient = &SongLinkClient{
client: NewHTTPClientWithTimeout(SongLinkTimeout), // 30s timeout
client: NewHTTPClientWithTimeout(SongLinkTimeout),
}
})
return globalSongLinkClient
@@ -48,15 +47,12 @@ func NewSongLinkClient() *SongLinkClient {
// CheckTrackAvailability checks track availability on streaming platforms
func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc string) (*TrackAvailability, error) {
// Validate Spotify ID format (should be 22 characters alphanumeric)
if spotifyTrackID == "" {
return nil, fmt.Errorf("spotify track ID is empty")
}
// Use global rate limiter - blocks until request is allowed
songLinkRateLimiter.WaitForSlot()
// Build API URL
spotifyBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9vcGVuLnNwb3RpZnkuY29tL3RyYWNrLw==")
spotifyURL := fmt.Sprintf("%s%s", string(spotifyBase), spotifyTrackID)
@@ -68,7 +64,6 @@ func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc stri
return nil, fmt.Errorf("failed to create request: %w", err)
}
// Use retry logic with User-Agent
retryConfig := DefaultRetryConfig()
resp, err := DoRequestWithRetry(s.client, req, retryConfig)
if err != nil {
@@ -76,7 +71,6 @@ func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc stri
}
defer resp.Body.Close()
// Handle specific error codes
if resp.StatusCode == 400 {
return nil, fmt.Errorf("track not found on SongLink (invalid Spotify ID or track unavailable)")
}
@@ -109,27 +103,22 @@ func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc stri
SpotifyID: spotifyTrackID,
}
// Check Tidal
if tidalLink, ok := songLinkResp.LinksByPlatform["tidal"]; ok && tidalLink.URL != "" {
availability.Tidal = true
availability.TidalURL = tidalLink.URL
}
// Check Amazon
if amazonLink, ok := songLinkResp.LinksByPlatform["amazonMusic"]; ok && amazonLink.URL != "" {
availability.Amazon = true
availability.AmazonURL = amazonLink.URL
}
// Check Deezer
if deezerLink, ok := songLinkResp.LinksByPlatform["deezer"]; ok && deezerLink.URL != "" {
availability.Deezer = true
availability.DeezerURL = deezerLink.URL
// Extract Deezer ID from URL (e.g., https://www.deezer.com/track/123456)
availability.DeezerID = extractDeezerIDFromURL(deezerLink.URL)
}
// Check Qobuz using ISRC (SongLink doesn't support Qobuz directly)
if isrc != "" {
availability.Qobuz = checkQobuzAvailability(isrc)
}
@@ -191,12 +180,9 @@ func checkQobuzAvailability(isrc string) bool {
// extractDeezerIDFromURL extracts Deezer track/album/artist ID from URL
func extractDeezerIDFromURL(deezerURL string) string {
// URL format: https://www.deezer.com/track/123456 or https://www.deezer.com/en/track/123456
parts := strings.Split(deezerURL, "/")
if len(parts) > 0 {
// Get the last part which should be the ID
lastPart := parts[len(parts)-1]
// Remove any query parameters
if idx := strings.Index(lastPart, "?"); idx > 0 {
lastPart = lastPart[:idx]
}
@@ -274,7 +260,6 @@ func (s *SongLinkClient) CheckAlbumAvailability(spotifyAlbumID string) (*AlbumAv
SpotifyID: spotifyAlbumID,
}
// Check Deezer
if deezerLink, ok := songLinkResp.LinksByPlatform["deezer"]; ok && deezerLink.URL != "" {
availability.Deezer = true
availability.DeezerURL = deezerLink.URL
@@ -309,13 +294,10 @@ func (s *SongLinkClient) CheckAvailabilityFromDeezer(deezerTrackID string) (*Tra
return nil, fmt.Errorf("deezer track ID is empty")
}
// Use global rate limiter
songLinkRateLimiter.WaitForSlot()
// Build Deezer URL
deezerURL := fmt.Sprintf("https://www.deezer.com/track/%s", deezerTrackID)
// Build API URL using Deezer URL as source
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9hcGkuc29uZy5saW5rL3YxLWFscGhhLjEvbGlua3M/dXJsPQ==")
apiURL := fmt.Sprintf("%s%s&userCountry=US", string(apiBase), url.QueryEscape(deezerURL))
@@ -371,25 +353,20 @@ func (s *SongLinkClient) CheckAvailabilityFromDeezer(deezerTrackID string) (*Tra
DeezerID: deezerTrackID,
}
// Check Spotify
if spotifyLink, ok := songLinkResp.LinksByPlatform["spotify"]; ok && spotifyLink.URL != "" {
// Extract Spotify ID from URL
availability.SpotifyID = extractSpotifyIDFromURL(spotifyLink.URL)
}
// Check Tidal
if tidalLink, ok := songLinkResp.LinksByPlatform["tidal"]; ok && tidalLink.URL != "" {
availability.Tidal = true
availability.TidalURL = tidalLink.URL
}
// Check Amazon
if amazonLink, ok := songLinkResp.LinksByPlatform["amazonMusic"]; ok && amazonLink.URL != "" {
availability.Amazon = true
availability.AmazonURL = amazonLink.URL
}
// Check Deezer URL
if deezerLink, ok := songLinkResp.LinksByPlatform["deezer"]; ok && deezerLink.URL != "" {
availability.DeezerURL = deezerLink.URL
}
@@ -459,24 +436,20 @@ func (s *SongLinkClient) CheckAvailabilityByPlatform(platform, entityType, entit
availability := &TrackAvailability{}
// Check Spotify
if spotifyLink, ok := songLinkResp.LinksByPlatform["spotify"]; ok && spotifyLink.URL != "" {
availability.SpotifyID = extractSpotifyIDFromURL(spotifyLink.URL)
}
// Check Tidal
if tidalLink, ok := songLinkResp.LinksByPlatform["tidal"]; ok && tidalLink.URL != "" {
availability.Tidal = true
availability.TidalURL = tidalLink.URL
}
// Check Amazon
if amazonLink, ok := songLinkResp.LinksByPlatform["amazonMusic"]; ok && amazonLink.URL != "" {
availability.Amazon = true
availability.AmazonURL = amazonLink.URL
}
// Check Deezer
if deezerLink, ok := songLinkResp.LinksByPlatform["deezer"]; ok && deezerLink.URL != "" {
availability.Deezer = true
availability.DeezerURL = deezerLink.URL
@@ -488,10 +461,8 @@ func (s *SongLinkClient) CheckAvailabilityByPlatform(platform, entityType, entit
// extractSpotifyIDFromURL extracts Spotify track ID from URL
func extractSpotifyIDFromURL(spotifyURL string) string {
// URL format: https://open.spotify.com/track/0Jcij1eWd5bDMU5iPbxe2i
parts := strings.Split(spotifyURL, "/track/")
if len(parts) > 1 {
// Get the ID part and remove any query parameters
idPart := parts[1]
if idx := strings.Index(idPart, "?"); idx > 0 {
idPart = idPart[:idx]
+1 -8
View File
@@ -84,7 +84,6 @@ func HasSpotifyCredentials() bool {
credentialsMu.RLock()
defer credentialsMu.RUnlock()
// Check custom credentials first
if customClientID != "" && customClientSecret != "" {
return true
}
@@ -112,14 +111,12 @@ func getCredentials() (string, string, error) {
return clientID, clientSecret, nil
}
// No credentials available
return "", "", ErrNoSpotifyCredentials
}
// NewSpotifyMetadataClient creates a new Spotify client
// Returns error if credentials are not configured
func NewSpotifyMetadataClient() (*SpotifyMetadataClient, error) {
// Get credentials - will error if not configured
clientID, clientSecret, err := getCredentials()
if err != nil {
return nil, err
@@ -128,7 +125,7 @@ func NewSpotifyMetadataClient() (*SpotifyMetadataClient, error) {
src := rand.NewSource(time.Now().UnixNano())
c := &SpotifyMetadataClient{
httpClient: NewHTTPClientWithTimeout(15 * time.Second), // Use shared transport for connection pooling
httpClient: NewHTTPClientWithTimeout(15 * time.Second),
clientID: clientID,
clientSecret: clientSecret,
rng: rand.New(src),
@@ -451,7 +448,6 @@ func (c *SpotifyMetadataClient) SearchAll(ctx context.Context, query string, tra
})
}
// Limit artists to artistLimit
artistCount := len(response.Artists.Items)
if artistCount > artistLimit {
artistCount = artistLimit
@@ -468,7 +464,6 @@ func (c *SpotifyMetadataClient) SearchAll(ctx context.Context, query string, tra
})
}
// Store in cache
c.cacheMu.Lock()
c.searchCache[cacheKey] = &cacheEntry{
data: result,
@@ -604,7 +599,6 @@ func (c *SpotifyMetadataClient) fetchAlbum(ctx context.Context, albumID, token s
TrackList: tracks,
}
// Store in cache
c.cacheMu.Lock()
c.albumCache[albumID] = &cacheEntry{
data: result,
@@ -849,7 +843,6 @@ func (c *SpotifyMetadataClient) fetchArtist(ctx context.Context, artistID, token
Albums: albums,
}
// Store in cache
c.cacheMu.Lock()
c.artistCache[artistID] = &cacheEntry{
data: result,
-1
View File
@@ -31,7 +31,6 @@ type TidalDownloader struct {
}
var (
// Global Tidal downloader instance for token reuse
globalTidalDownloader *TidalDownloader
tidalDownloaderOnce sync.Once
)