mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-03-30 16:30:22 +02:00
293 lines
6.3 KiB
Go
293 lines
6.3 KiB
Go
package gobackend
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
type TrackIDCacheEntry struct {
|
|
TidalTrackID int64
|
|
QobuzTrackID int64
|
|
ExpiresAt time.Time
|
|
}
|
|
|
|
type TrackIDCache struct {
|
|
cache map[string]*TrackIDCacheEntry
|
|
mu sync.RWMutex
|
|
ttl time.Duration
|
|
lastCleanup time.Time
|
|
cleanupInterval time.Duration
|
|
}
|
|
|
|
var (
|
|
globalTrackIDCache *TrackIDCache
|
|
trackIDCacheOnce sync.Once
|
|
)
|
|
|
|
func GetTrackIDCache() *TrackIDCache {
|
|
trackIDCacheOnce.Do(func() {
|
|
globalTrackIDCache = &TrackIDCache{
|
|
cache: make(map[string]*TrackIDCacheEntry),
|
|
ttl: 30 * time.Minute,
|
|
cleanupInterval: 5 * time.Minute,
|
|
}
|
|
})
|
|
return globalTrackIDCache
|
|
}
|
|
|
|
func (c *TrackIDCache) Get(isrc string) *TrackIDCacheEntry {
|
|
c.mu.RLock()
|
|
entry, exists := c.cache[isrc]
|
|
if !exists {
|
|
c.mu.RUnlock()
|
|
return nil
|
|
}
|
|
expired := time.Now().After(entry.ExpiresAt)
|
|
c.mu.RUnlock()
|
|
|
|
if !expired {
|
|
return entry
|
|
}
|
|
|
|
c.mu.Lock()
|
|
entry, exists = c.cache[isrc]
|
|
if exists && time.Now().After(entry.ExpiresAt) {
|
|
delete(c.cache, isrc)
|
|
}
|
|
c.mu.Unlock()
|
|
return nil
|
|
}
|
|
|
|
func (c *TrackIDCache) pruneExpiredLocked(now time.Time) {
|
|
for key, entry := range c.cache {
|
|
if now.After(entry.ExpiresAt) {
|
|
delete(c.cache, key)
|
|
}
|
|
}
|
|
}
|
|
|
|
func (c *TrackIDCache) SetTidal(isrc string, trackID int64) {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
|
|
entry, exists := c.cache[isrc]
|
|
if !exists {
|
|
entry = &TrackIDCacheEntry{}
|
|
c.cache[isrc] = entry
|
|
}
|
|
entry.TidalTrackID = trackID
|
|
now := time.Now()
|
|
entry.ExpiresAt = now.Add(c.ttl)
|
|
|
|
if c.cleanupInterval > 0 && (c.lastCleanup.IsZero() || now.Sub(c.lastCleanup) >= c.cleanupInterval) {
|
|
c.pruneExpiredLocked(now)
|
|
c.lastCleanup = now
|
|
}
|
|
}
|
|
|
|
func (c *TrackIDCache) SetQobuz(isrc string, trackID int64) {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
|
|
entry, exists := c.cache[isrc]
|
|
if !exists {
|
|
entry = &TrackIDCacheEntry{}
|
|
c.cache[isrc] = entry
|
|
}
|
|
entry.QobuzTrackID = trackID
|
|
now := time.Now()
|
|
entry.ExpiresAt = now.Add(c.ttl)
|
|
|
|
if c.cleanupInterval > 0 && (c.lastCleanup.IsZero() || now.Sub(c.lastCleanup) >= c.cleanupInterval) {
|
|
c.pruneExpiredLocked(now)
|
|
c.lastCleanup = now
|
|
}
|
|
}
|
|
|
|
func (c *TrackIDCache) Clear() {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
c.cache = make(map[string]*TrackIDCacheEntry)
|
|
}
|
|
|
|
func (c *TrackIDCache) Size() int {
|
|
c.mu.RLock()
|
|
defer c.mu.RUnlock()
|
|
return len(c.cache)
|
|
}
|
|
|
|
type ParallelDownloadResult struct {
|
|
CoverData []byte
|
|
LyricsData *LyricsResponse
|
|
LyricsLRC string
|
|
CoverErr error
|
|
LyricsErr error
|
|
}
|
|
|
|
func FetchCoverAndLyricsParallel(
|
|
coverURL string,
|
|
maxQualityCover bool,
|
|
spotifyID string,
|
|
trackName string,
|
|
artistName string,
|
|
embedLyrics bool,
|
|
durationMs int64,
|
|
) *ParallelDownloadResult {
|
|
result := &ParallelDownloadResult{}
|
|
var wg sync.WaitGroup
|
|
var resultMu sync.Mutex
|
|
|
|
if coverURL != "" {
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
data, err := downloadCoverToMemory(coverURL, maxQualityCover)
|
|
resultMu.Lock()
|
|
if err != nil {
|
|
result.CoverErr = err
|
|
} else {
|
|
result.CoverData = data
|
|
}
|
|
resultMu.Unlock()
|
|
}()
|
|
}
|
|
|
|
if embedLyrics {
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
client := NewLyricsClient()
|
|
durationSec := float64(durationMs) / 1000.0
|
|
lyrics, err := client.FetchLyricsAllSources(spotifyID, trackName, artistName, durationSec)
|
|
resultMu.Lock()
|
|
if err != nil {
|
|
result.LyricsErr = err
|
|
} else if lyrics != nil && len(lyrics.Lines) > 0 {
|
|
result.LyricsData = lyrics
|
|
result.LyricsLRC = convertToLRCWithMetadata(lyrics, trackName, artistName)
|
|
} else {
|
|
result.LyricsErr = fmt.Errorf("no lyrics found")
|
|
}
|
|
resultMu.Unlock()
|
|
}()
|
|
}
|
|
|
|
wg.Wait()
|
|
return result
|
|
}
|
|
|
|
type PreWarmCacheRequest struct {
|
|
ISRC string
|
|
TrackName string
|
|
ArtistName string
|
|
SpotifyID string
|
|
Service string
|
|
}
|
|
|
|
func PreWarmTrackCache(requests []PreWarmCacheRequest) {
|
|
if len(requests) == 0 {
|
|
return
|
|
}
|
|
|
|
cache := GetTrackIDCache()
|
|
|
|
semaphore := make(chan struct{}, 3)
|
|
var wg sync.WaitGroup
|
|
|
|
for _, req := range requests {
|
|
if req.ISRC == "" {
|
|
continue
|
|
}
|
|
if cached := cache.Get(req.ISRC); cached != nil {
|
|
continue
|
|
}
|
|
|
|
wg.Add(1)
|
|
go func(r PreWarmCacheRequest) {
|
|
defer wg.Done()
|
|
semaphore <- struct{}{}
|
|
defer func() { <-semaphore }()
|
|
|
|
switch r.Service {
|
|
case "tidal":
|
|
preWarmTidalCache(r.ISRC, r.TrackName, r.ArtistName)
|
|
case "qobuz":
|
|
preWarmQobuzCache(r.ISRC, r.SpotifyID)
|
|
}
|
|
}(req)
|
|
}
|
|
|
|
wg.Wait()
|
|
}
|
|
|
|
func preWarmTidalCache(isrc, _, _ string) {
|
|
downloader := NewTidalDownloader()
|
|
track, err := downloader.SearchTrackByISRC(isrc)
|
|
if err == nil && track != nil {
|
|
GetTrackIDCache().SetTidal(isrc, track.ID)
|
|
}
|
|
}
|
|
|
|
// preWarmQobuzCache tries to get Qobuz Track ID in the following order:
|
|
// 1. From SongLink (fast, no Qobuz API call needed)
|
|
// 2. Direct ISRC search on Qobuz API (slower, may fail if ISRC not in Qobuz database)
|
|
func preWarmQobuzCache(isrc, spotifyID string) {
|
|
if spotifyID != "" {
|
|
client := NewSongLinkClient()
|
|
availability, err := client.CheckTrackAvailability(spotifyID, isrc)
|
|
if err == nil && availability != nil && availability.QobuzID != "" {
|
|
var trackID int64
|
|
if _, parseErr := fmt.Sscanf(availability.QobuzID, "%d", &trackID); parseErr == nil && trackID > 0 {
|
|
GoLog("[Qobuz] Pre-warm cache: Got Qobuz ID %d from SongLink for ISRC %s\n", trackID, isrc)
|
|
GetTrackIDCache().SetQobuz(isrc, trackID)
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
downloader := NewQobuzDownloader()
|
|
track, err := downloader.SearchTrackByISRC(isrc)
|
|
if err == nil && track != nil {
|
|
GoLog("[Qobuz] Pre-warm cache: Got Qobuz ID %d from direct ISRC search for %s\n", track.ID, isrc)
|
|
GetTrackIDCache().SetQobuz(isrc, track.ID)
|
|
}
|
|
}
|
|
|
|
func PreWarmCache(tracksJSON string) error {
|
|
var tracks []struct {
|
|
ISRC string `json:"isrc"`
|
|
TrackName string `json:"track_name"`
|
|
ArtistName string `json:"artist_name"`
|
|
SpotifyID string `json:"spotify_id"`
|
|
Service string `json:"service"`
|
|
}
|
|
|
|
if err := json.Unmarshal([]byte(tracksJSON), &tracks); err != nil {
|
|
return fmt.Errorf("failed to parse tracks JSON: %w", err)
|
|
}
|
|
|
|
requests := make([]PreWarmCacheRequest, len(tracks))
|
|
for i, t := range tracks {
|
|
requests[i] = PreWarmCacheRequest{
|
|
ISRC: t.ISRC,
|
|
TrackName: t.TrackName,
|
|
ArtistName: t.ArtistName,
|
|
SpotifyID: t.SpotifyID,
|
|
Service: t.Service,
|
|
}
|
|
}
|
|
|
|
go PreWarmTrackCache(requests)
|
|
return nil
|
|
}
|
|
|
|
func ClearTrackCache() {
|
|
GetTrackIDCache().Clear()
|
|
}
|
|
|
|
func GetCacheSize() int {
|
|
return GetTrackIDCache().Size()
|
|
}
|