mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-08-18 17:07:19 +02:00
chore: cleanup unused code and dead imports
This commit is contained in:
+4
-21
@@ -17,13 +17,12 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// AmazonDownloader handles Amazon Music downloads using DoubleDouble service (same as PC)
|
||||
type AmazonDownloader struct {
|
||||
client *http.Client
|
||||
regions []string // us, eu regions for DoubleDouble service
|
||||
lastAPICallTime time.Time // Rate limiting: track last API call
|
||||
apiCallCount int // Rate limiting: counter per minute
|
||||
apiCallResetTime time.Time // Rate limiting: reset time
|
||||
regions []string
|
||||
lastAPICallTime time.Time
|
||||
apiCallCount int
|
||||
apiCallResetTime time.Time
|
||||
}
|
||||
|
||||
var (
|
||||
@@ -38,7 +37,6 @@ type DoubleDoubleSubmitResponse struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
|
||||
// DoubleDoubleStatusResponse is the response from DoubleDouble status endpoint
|
||||
type DoubleDoubleStatusResponse struct {
|
||||
Status string `json:"status"`
|
||||
FriendlyStatus string `json:"friendlyStatus"`
|
||||
@@ -49,7 +47,6 @@ type DoubleDoubleStatusResponse struct {
|
||||
} `json:"current"`
|
||||
}
|
||||
|
||||
// amazonArtistsMatch checks if the artist names are similar enough
|
||||
func amazonArtistsMatch(expectedArtist, foundArtist string) bool {
|
||||
normExpected := strings.ToLower(strings.TrimSpace(expectedArtist))
|
||||
normFound := strings.ToLower(strings.TrimSpace(foundArtist))
|
||||
@@ -90,7 +87,6 @@ func amazonArtistsMatch(expectedArtist, foundArtist string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// amazonIsASCIIString checks if a string contains only ASCII characters
|
||||
func amazonIsASCIIString(s string) bool {
|
||||
for _, r := range s {
|
||||
if r > 127 {
|
||||
@@ -100,7 +96,6 @@ func amazonIsASCIIString(s string) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// NewAmazonDownloader creates a new Amazon downloader (returns singleton for connection reuse)
|
||||
func NewAmazonDownloader() *AmazonDownloader {
|
||||
amazonDownloaderOnce.Do(func() {
|
||||
globalAmazonDownloader = &AmazonDownloader{
|
||||
@@ -113,7 +108,6 @@ func NewAmazonDownloader() *AmazonDownloader {
|
||||
}
|
||||
|
||||
// waitForRateLimit implements rate limiting similar to PC version
|
||||
// Max 9 requests per minute with 7 second delay between requests
|
||||
func (a *AmazonDownloader) waitForRateLimit() {
|
||||
amazonRateLimitMu.Lock()
|
||||
defer amazonRateLimitMu.Unlock()
|
||||
@@ -125,7 +119,6 @@ func (a *AmazonDownloader) waitForRateLimit() {
|
||||
a.apiCallResetTime = now
|
||||
}
|
||||
|
||||
// If we've hit the limit (9 requests per minute), wait until next minute
|
||||
if a.apiCallCount >= 9 {
|
||||
waitTime := time.Minute - now.Sub(a.apiCallResetTime)
|
||||
if waitTime > 0 {
|
||||
@@ -136,7 +129,6 @@ func (a *AmazonDownloader) waitForRateLimit() {
|
||||
}
|
||||
}
|
||||
|
||||
// Add delay between requests (7 seconds like PC version)
|
||||
if !a.lastAPICallTime.IsZero() {
|
||||
timeSinceLastCall := now.Sub(a.lastAPICallTime)
|
||||
minDelay := 7 * time.Second
|
||||
@@ -151,7 +143,6 @@ func (a *AmazonDownloader) waitForRateLimit() {
|
||||
a.apiCallCount++
|
||||
}
|
||||
|
||||
// GetAvailableAPIs returns list of available DoubleDouble regions
|
||||
// Uses same service as PC version (doubledouble.top)
|
||||
func (a *AmazonDownloader) GetAvailableAPIs() []string {
|
||||
// DoubleDouble service regions (same as PC)
|
||||
@@ -176,11 +167,9 @@ func (a *AmazonDownloader) downloadFromDoubleDoubleService(amazonURL, _ string)
|
||||
serviceDomain, _ := base64.StdEncoding.DecodeString("LmRvdWJsZWRvdWJsZS50b3A=") // .doubledouble.top
|
||||
baseURL := fmt.Sprintf("%s%s%s", string(serviceBase), region, string(serviceDomain))
|
||||
|
||||
// Step 1: Submit download request with rate limiting
|
||||
encodedURL := url.QueryEscape(amazonURL)
|
||||
submitURL := fmt.Sprintf("%s/dl?url=%s", baseURL, encodedURL)
|
||||
|
||||
// Apply rate limiting before request (like PC version)
|
||||
a.waitForRateLimit()
|
||||
|
||||
req, err := http.NewRequest("GET", submitURL, nil)
|
||||
@@ -334,7 +323,6 @@ func (a *AmazonDownloader) downloadFromDoubleDoubleService(amazonURL, _ string)
|
||||
return "", "", "", fmt.Errorf("all regions failed. Last error: %v", lastError)
|
||||
}
|
||||
|
||||
// DownloadFile downloads a file from URL with User-Agent and progress tracking
|
||||
func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath, itemID string) error {
|
||||
ctx := context.Background()
|
||||
|
||||
@@ -434,7 +422,6 @@ type AmazonDownloadResult struct {
|
||||
ISRC string
|
||||
}
|
||||
|
||||
// downloadFromAmazon downloads a track using the request parameters
|
||||
// Uses DoubleDouble service (same as PC version)
|
||||
func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
||||
downloader := NewAmazonDownloader()
|
||||
@@ -580,15 +567,12 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
||||
fmt.Printf("Warning: failed to embed metadata: %v\n", err)
|
||||
}
|
||||
|
||||
// Handle lyrics based on LyricsMode setting
|
||||
// Mode: "embed" (default), "external" (.lrc file), "both"
|
||||
if req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" {
|
||||
lyricsMode := req.LyricsMode
|
||||
if lyricsMode == "" {
|
||||
lyricsMode = "embed" // default
|
||||
}
|
||||
|
||||
// Save external .lrc file if mode is "external" or "both"
|
||||
if lyricsMode == "external" || lyricsMode == "both" {
|
||||
GoLog("[Amazon] Saving external LRC file...\n")
|
||||
if lrcPath, lrcErr := SaveLRCFile(outputPath, parallelResult.LyricsLRC); lrcErr != nil {
|
||||
@@ -598,7 +582,6 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// Embed lyrics if mode is "embed" or "both"
|
||||
if lyricsMode == "embed" || lyricsMode == "both" {
|
||||
GoLog("[Amazon] Embedding parallel-fetched lyrics (%d lines)...\n", len(parallelResult.LyricsData.Lines))
|
||||
if embedErr := EmbedLyrics(outputPath, parallelResult.LyricsLRC); embedErr != nil {
|
||||
|
||||
+3
-14
@@ -8,18 +8,15 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Spotify image size codes (same as PC version)
|
||||
const (
|
||||
spotifySize300 = "ab67616d00001e02" // 300x300 (small)
|
||||
spotifySize640 = "ab67616d0000b273" // 640x640 (medium)
|
||||
spotifySizeMax = "ab67616d000082c1" // Max resolution (~2000x2000)
|
||||
spotifySize300 = "ab67616d00001e02"
|
||||
spotifySize640 = "ab67616d0000b273"
|
||||
spotifySizeMax = "ab67616d000082c1"
|
||||
)
|
||||
|
||||
// Deezer CDN supports these sizes: 56, 250, 500, 1000, 1400, 1800
|
||||
var deezerSizeRegex = regexp.MustCompile(`/(\d+)x(\d+)-\d+-\d+-\d+-\d+\.jpg$`)
|
||||
|
||||
// convertSmallToMedium upgrades 300x300 cover URL to 640x640
|
||||
// Same logic as PC version for consistency
|
||||
func convertSmallToMedium(imageURL string) string {
|
||||
if strings.Contains(imageURL, spotifySize300) {
|
||||
return strings.Replace(imageURL, spotifySize300, spotifySize640, 1)
|
||||
@@ -27,8 +24,6 @@ func convertSmallToMedium(imageURL string) string {
|
||||
return imageURL
|
||||
}
|
||||
|
||||
// downloadCoverToMemory downloads cover art and returns as bytes (no file creation)
|
||||
// This avoids file permission issues on Android
|
||||
func downloadCoverToMemory(coverURL string, maxQuality bool) ([]byte, error) {
|
||||
if coverURL == "" {
|
||||
return nil, fmt.Errorf("no cover URL provided")
|
||||
@@ -90,8 +85,6 @@ func downloadCoverToMemory(coverURL string, maxQuality bool) ([]byte, error) {
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// upgradeToMaxQuality upgrades cover URL to maximum quality
|
||||
// Supports both Spotify and Deezer CDNs
|
||||
func upgradeToMaxQuality(coverURL string) string {
|
||||
// Spotify CDN upgrade
|
||||
if strings.Contains(coverURL, spotifySize640) {
|
||||
@@ -106,9 +99,6 @@ func upgradeToMaxQuality(coverURL string) string {
|
||||
return coverURL
|
||||
}
|
||||
|
||||
// upgradeDeezerCover upgrades Deezer cover URL to maximum quality (1800x1800)
|
||||
// Deezer CDN format: https://cdn-images.dzcdn.net/images/cover/{hash}/{size}x{size}-000000-80-0-0.jpg
|
||||
// Available sizes: 56, 250, 500, 1000, 1400, 1800
|
||||
func upgradeDeezerCover(coverURL string) string {
|
||||
if !strings.Contains(coverURL, "cdn-images.dzcdn.net") {
|
||||
return coverURL
|
||||
@@ -122,7 +112,6 @@ func upgradeDeezerCover(coverURL string) string {
|
||||
return upgraded
|
||||
}
|
||||
|
||||
// GetCoverFromSpotify gets cover URL from Spotify metadata
|
||||
func GetCoverFromSpotify(imageURL string, maxQuality bool) string {
|
||||
if imageURL == "" {
|
||||
return ""
|
||||
|
||||
+6
-34
@@ -25,13 +25,12 @@ const (
|
||||
deezerMaxParallelISRC = 10
|
||||
)
|
||||
|
||||
// DeezerClient handles Deezer API interactions (no auth required)
|
||||
type DeezerClient struct {
|
||||
httpClient *http.Client
|
||||
searchCache map[string]*cacheEntry
|
||||
albumCache map[string]*cacheEntry
|
||||
artistCache map[string]*cacheEntry
|
||||
isrcCache map[string]string // trackID -> ISRC cache
|
||||
isrcCache map[string]string
|
||||
cacheMu sync.RWMutex
|
||||
}
|
||||
|
||||
@@ -40,7 +39,6 @@ var (
|
||||
deezerClientOnce sync.Once
|
||||
)
|
||||
|
||||
// GetDeezerClient returns singleton Deezer client
|
||||
func GetDeezerClient() *DeezerClient {
|
||||
deezerClientOnce.Do(func() {
|
||||
deezerClient = &DeezerClient{
|
||||
@@ -54,7 +52,6 @@ func GetDeezerClient() *DeezerClient {
|
||||
return deezerClient
|
||||
}
|
||||
|
||||
// Deezer API response types
|
||||
type deezerTrack struct {
|
||||
ID int64 `json:"id"`
|
||||
Title string `json:"title"`
|
||||
@@ -63,7 +60,7 @@ type deezerTrack struct {
|
||||
DiskNumber int `json:"disk_number"`
|
||||
ISRC string `json:"isrc"`
|
||||
Link string `json:"link"`
|
||||
ReleaseDate string `json:"release_date"` // Sometimes at track level
|
||||
ReleaseDate string `json:"release_date"`
|
||||
Artist deezerArtist `json:"artist"`
|
||||
Album deezerAlbumSimple `json:"album"`
|
||||
Contributors []deezerArtist `json:"contributors"`
|
||||
@@ -86,8 +83,8 @@ type deezerAlbumSimple struct {
|
||||
CoverMedium string `json:"cover_medium"`
|
||||
CoverBig string `json:"cover_big"`
|
||||
CoverXL string `json:"cover_xl"`
|
||||
ReleaseDate string `json:"release_date"` // Sometimes at album level
|
||||
RecordType string `json:"record_type"` // album, single, ep, compile
|
||||
ReleaseDate string `json:"release_date"`
|
||||
RecordType string `json:"record_type"`
|
||||
}
|
||||
|
||||
func (c *DeezerClient) convertTrack(track deezerTrack) TrackMetadata {
|
||||
@@ -146,8 +143,8 @@ type deezerAlbumFull struct {
|
||||
CoverXL string `json:"cover_xl"`
|
||||
ReleaseDate string `json:"release_date"`
|
||||
NbTracks int `json:"nb_tracks"`
|
||||
RecordType string `json:"record_type"` // album, single, ep, compile
|
||||
Label string `json:"label"` // Record label name
|
||||
RecordType string `json:"record_type"`
|
||||
Label string `json:"label"`
|
||||
Genres struct {
|
||||
Data []deezerGenre `json:"data"`
|
||||
} `json:"genres"`
|
||||
@@ -185,7 +182,6 @@ type deezerPlaylistFull struct {
|
||||
} `json:"tracks"`
|
||||
}
|
||||
|
||||
// SearchAll searches for tracks and artists on Deezer
|
||||
// NOTE: ISRC is NOT fetched during search for performance - use GetTrackISRC when needed for download
|
||||
func (c *DeezerClient) SearchAll(ctx context.Context, query string, trackLimit, artistLimit int) (*SearchAllResult, error) {
|
||||
GoLog("[Deezer] SearchAll: query=%q, trackLimit=%d, artistLimit=%d\n", query, trackLimit, artistLimit)
|
||||
@@ -230,11 +226,9 @@ func (c *DeezerClient) SearchAll(ctx context.Context, query string, trackLimit,
|
||||
GoLog("[Deezer] Got %d tracks from API\n", len(trackResp.Data))
|
||||
|
||||
for _, track := range trackResp.Data {
|
||||
// Convert directly without fetching ISRC - much faster
|
||||
result.Tracks = append(result.Tracks, c.convertTrack(track))
|
||||
}
|
||||
|
||||
// Search artists
|
||||
artistURL := fmt.Sprintf("%s/artist?q=%s&limit=%d", deezerSearchURL, url.QueryEscape(query), artistLimit)
|
||||
GoLog("[Deezer] Fetching artists from: %s\n", artistURL)
|
||||
|
||||
@@ -267,7 +261,6 @@ func (c *DeezerClient) SearchAll(ctx context.Context, query string, trackLimit,
|
||||
|
||||
GoLog("[Deezer] SearchAll complete: %d tracks, %d artists\n", len(result.Tracks), len(result.Artists))
|
||||
|
||||
// Cache result
|
||||
c.cacheMu.Lock()
|
||||
c.searchCache[cacheKey] = &cacheEntry{
|
||||
data: result,
|
||||
@@ -292,7 +285,6 @@ func (c *DeezerClient) GetTrack(ctx context.Context, trackID string) (*TrackResp
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetAlbum fetches album with tracks
|
||||
// ISRC is fetched in parallel for better performance
|
||||
func (c *DeezerClient) GetAlbum(ctx context.Context, albumID string) (*AlbumResponsePayload, error) {
|
||||
c.cacheMu.RLock()
|
||||
@@ -338,7 +330,6 @@ func (c *DeezerClient) GetAlbum(ctx context.Context, albumID string) (*AlbumResp
|
||||
Label: album.Label, // From Deezer album
|
||||
}
|
||||
|
||||
// Fetch ISRCs in parallel
|
||||
isrcMap := c.fetchISRCsParallel(ctx, album.Tracks.Data)
|
||||
|
||||
tracks := make([]AlbumTrackMetadata, 0, len(album.Tracks.Data))
|
||||
@@ -386,7 +377,6 @@ func (c *DeezerClient) GetAlbum(ctx context.Context, albumID string) (*AlbumResp
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// GetArtist fetches artist with albums
|
||||
func (c *DeezerClient) GetArtist(ctx context.Context, artistID string) (*ArtistResponsePayload, error) {
|
||||
c.cacheMu.RLock()
|
||||
if entry, ok := c.artistCache[artistID]; ok && !entry.isExpired() {
|
||||
@@ -472,8 +462,6 @@ func (c *DeezerClient) GetArtist(ctx context.Context, artistID string) (*ArtistR
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// GetPlaylist fetches playlist with tracks
|
||||
// ISRC is fetched in parallel for better performance
|
||||
func (c *DeezerClient) GetPlaylist(ctx context.Context, playlistID string) (*PlaylistResponsePayload, error) {
|
||||
playlistURL := fmt.Sprintf(deezerPlaylistURL, playlistID)
|
||||
|
||||
@@ -496,7 +484,6 @@ func (c *DeezerClient) GetPlaylist(ctx context.Context, playlistID string) (*Pla
|
||||
info.Owner.Name = playlist.Title
|
||||
info.Owner.Images = playlistImage
|
||||
|
||||
// Fetch ISRCs in parallel
|
||||
isrcMap := c.fetchISRCsParallel(ctx, playlist.Tracks.Data)
|
||||
|
||||
tracks := make([]AlbumTrackMetadata, 0, len(playlist.Tracks.Data))
|
||||
@@ -535,15 +522,11 @@ func (c *DeezerClient) GetPlaylist(ctx context.Context, playlistID string) (*Pla
|
||||
}, nil
|
||||
}
|
||||
|
||||
// SearchByISRC searches for a track by ISRC using direct endpoint
|
||||
func (c *DeezerClient) SearchByISRC(ctx context.Context, isrc string) (*TrackMetadata, error) {
|
||||
// Use direct ISRC endpoint (API 2.0)
|
||||
// https://api.deezer.com/2.0/track/isrc:{ISRC}
|
||||
directURL := fmt.Sprintf("%s/track/isrc:%s", deezerBaseURL, isrc)
|
||||
|
||||
var track deezerTrack
|
||||
if err := c.getJSON(ctx, directURL, &track); err != nil {
|
||||
// Fallback to search if direct endpoint fails
|
||||
searchURL := fmt.Sprintf("%s/track?q=isrc:%s&limit=1", deezerSearchURL, isrc)
|
||||
var resp struct {
|
||||
Data []deezerTrack `json:"data"`
|
||||
@@ -623,7 +606,6 @@ func (c *DeezerClient) fetchISRCsParallel(ctx context.Context, tracks []deezerTr
|
||||
go func(t deezerTrack) {
|
||||
defer wg.Done()
|
||||
|
||||
// Acquire semaphore
|
||||
select {
|
||||
case sem <- struct{}{}:
|
||||
defer func() { <-sem }()
|
||||
@@ -652,7 +634,6 @@ func (c *DeezerClient) fetchISRCsParallel(ctx context.Context, tracks []deezerTr
|
||||
return result
|
||||
}
|
||||
|
||||
// GetTrackISRC fetches ISRC for a single track (with caching)
|
||||
// Use this when you need ISRC for download
|
||||
func (c *DeezerClient) GetTrackISRC(ctx context.Context, trackID string) (string, error) {
|
||||
c.cacheMu.RLock()
|
||||
@@ -662,13 +643,11 @@ func (c *DeezerClient) GetTrackISRC(ctx context.Context, trackID string) (string
|
||||
}
|
||||
c.cacheMu.RUnlock()
|
||||
|
||||
// Fetch from API
|
||||
fullTrack, err := c.fetchFullTrack(ctx, trackID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Cache the result
|
||||
c.cacheMu.Lock()
|
||||
c.isrcCache[trackID] = fullTrack.ISRC
|
||||
c.cacheMu.Unlock()
|
||||
@@ -715,20 +694,17 @@ func (c *DeezerClient) getBestAlbumImage(album deezerAlbumFull) string {
|
||||
return album.Cover
|
||||
}
|
||||
|
||||
// AlbumExtendedMetadata contains genre and label information from an album
|
||||
type AlbumExtendedMetadata struct {
|
||||
Genre string // Comma-separated list of genres
|
||||
Label string // Record label name
|
||||
}
|
||||
|
||||
// GetAlbumExtendedMetadata fetches genre and label from a Deezer album
|
||||
// Uses the album ID from a track to fetch extended metadata
|
||||
func (c *DeezerClient) GetAlbumExtendedMetadata(ctx context.Context, albumID string) (*AlbumExtendedMetadata, error) {
|
||||
if albumID == "" {
|
||||
return nil, fmt.Errorf("empty album ID")
|
||||
}
|
||||
|
||||
// Check cache first
|
||||
cacheKey := fmt.Sprintf("album_meta:%s", albumID)
|
||||
c.cacheMu.RLock()
|
||||
if entry, ok := c.searchCache[cacheKey]; ok && !entry.isExpired() {
|
||||
@@ -744,7 +720,6 @@ func (c *DeezerClient) GetAlbumExtendedMetadata(ctx context.Context, albumID str
|
||||
return nil, fmt.Errorf("failed to fetch album: %w", err)
|
||||
}
|
||||
|
||||
// Extract genres as comma-separated string
|
||||
var genres []string
|
||||
for _, g := range album.Genres.Data {
|
||||
if g.Name != "" {
|
||||
@@ -757,7 +732,6 @@ func (c *DeezerClient) GetAlbumExtendedMetadata(ctx context.Context, albumID str
|
||||
Label: album.Label,
|
||||
}
|
||||
|
||||
// Cache the result
|
||||
c.cacheMu.Lock()
|
||||
c.searchCache[cacheKey] = &cacheEntry{
|
||||
data: result,
|
||||
@@ -782,7 +756,6 @@ func (c *DeezerClient) GetTrackAlbumID(ctx context.Context, trackID string) (str
|
||||
return fmt.Sprintf("%d", track.Album.ID), nil
|
||||
}
|
||||
|
||||
// GetExtendedMetadataByTrackID fetches genre and label using a Deezer track ID
|
||||
// This is a convenience function that first gets the album ID, then fetches album metadata
|
||||
func (c *DeezerClient) GetExtendedMetadataByTrackID(ctx context.Context, trackID string) (*AlbumExtendedMetadata, error) {
|
||||
albumID, err := c.GetTrackAlbumID(ctx, trackID)
|
||||
@@ -837,7 +810,6 @@ func parseDeezerURL(input string) (string, string, error) {
|
||||
|
||||
parts := strings.Split(strings.Trim(parsed.Path, "/"), "/")
|
||||
|
||||
// Skip language prefix if present (e.g., /en/, /fr/)
|
||||
if len(parts) > 0 && len(parts[0]) == 2 {
|
||||
parts = parts[1:]
|
||||
}
|
||||
|
||||
@@ -158,7 +158,6 @@ func checkISRCExistsInternal(outputDir, isrc string) (string, bool) {
|
||||
return "", false
|
||||
}
|
||||
|
||||
// Use index for fast lookup
|
||||
idx := GetISRCIndex(outputDir)
|
||||
filePath, exists := idx.lookup(isrc)
|
||||
if !exists {
|
||||
@@ -175,7 +174,6 @@ func checkISRCExistsInternal(outputDir, isrc string) (string, bool) {
|
||||
}
|
||||
|
||||
// CheckISRCExists is the exported version for gomobile (returns string, error)
|
||||
// Returns the filepath if exists, empty string if not
|
||||
func CheckISRCExists(outputDir, isrc string) (string, error) {
|
||||
filepath, _ := checkISRCExistsInternal(outputDir, isrc)
|
||||
return filepath, nil
|
||||
@@ -199,9 +197,6 @@ type FileExistenceResult struct {
|
||||
ArtistName string `json:"artist_name,omitempty"`
|
||||
}
|
||||
|
||||
// CheckFilesExistParallel checks if multiple files exist in parallel
|
||||
// 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) {
|
||||
var tracks []struct {
|
||||
ISRC string `json:"isrc"`
|
||||
@@ -266,7 +261,6 @@ func PreBuildISRCIndex(outputDir string) error {
|
||||
}
|
||||
|
||||
// AddToISRCIndex adds a new file to the ISRC index after successful download
|
||||
// This avoids rebuilding the entire index
|
||||
func AddToISRCIndex(outputDir, isrc, filePath string) {
|
||||
if outputDir == "" || isrc == "" || filePath == "" {
|
||||
return
|
||||
|
||||
+33
-124
@@ -13,8 +13,6 @@ import (
|
||||
"github.com/dop251/goja"
|
||||
)
|
||||
|
||||
// ParseSpotifyURL parses and validates a Spotify URL
|
||||
// Returns JSON with type (track/album/playlist) and ID
|
||||
func ParseSpotifyURL(url string) (string, error) {
|
||||
parsed, err := parseSpotifyURI(url)
|
||||
if err != nil {
|
||||
@@ -34,19 +32,14 @@ func ParseSpotifyURL(url string) (string, error) {
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
// SetSpotifyAPICredentials sets custom Spotify API credentials from Flutter
|
||||
func SetSpotifyAPICredentials(clientID, clientSecret string) {
|
||||
SetSpotifyCredentials(clientID, clientSecret)
|
||||
}
|
||||
|
||||
// CheckSpotifyCredentials checks if Spotify credentials are configured
|
||||
// Returns true if credentials are available (custom or env vars)
|
||||
func CheckSpotifyCredentials() bool {
|
||||
return HasSpotifyCredentials()
|
||||
}
|
||||
|
||||
// GetSpotifyMetadata fetches metadata from Spotify URL
|
||||
// Returns JSON with track/album/playlist data
|
||||
func GetSpotifyMetadata(spotifyURL string) (string, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
@@ -68,8 +61,6 @@ func GetSpotifyMetadata(spotifyURL string) (string, error) {
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
// SearchSpotify searches for tracks on Spotify
|
||||
// Returns JSON array of track results
|
||||
func SearchSpotify(query string, limit int) (string, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
@@ -91,8 +82,6 @@ func SearchSpotify(query string, limit int) (string, error) {
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
// SearchSpotifyAll searches for tracks and artists on Spotify
|
||||
// Returns JSON with tracks and artists arrays
|
||||
func SearchSpotifyAll(query string, trackLimit, artistLimit int) (string, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
@@ -114,8 +103,6 @@ func SearchSpotifyAll(query string, trackLimit, artistLimit int) (string, error)
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
// CheckAvailability checks track availability on streaming services
|
||||
// Returns JSON with availability info for Tidal, Qobuz, Amazon
|
||||
func CheckAvailability(spotifyID, isrc string) (string, error) {
|
||||
client := NewSongLinkClient()
|
||||
availability, err := client.CheckTrackAvailability(spotifyID, isrc)
|
||||
@@ -131,7 +118,6 @@ func CheckAvailability(spotifyID, isrc string) (string, error) {
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
// DownloadRequest represents a download request from Flutter
|
||||
type DownloadRequest struct {
|
||||
ISRC string `json:"isrc"`
|
||||
Service string `json:"service"`
|
||||
@@ -143,58 +129,51 @@ type DownloadRequest struct {
|
||||
CoverURL string `json:"cover_url"`
|
||||
OutputDir string `json:"output_dir"`
|
||||
FilenameFormat string `json:"filename_format"`
|
||||
Quality string `json:"quality"` // LOSSLESS, HI_RES, HI_RES_LOSSLESS
|
||||
Quality string `json:"quality"`
|
||||
EmbedLyrics bool `json:"embed_lyrics"`
|
||||
EmbedMaxQualityCover bool `json:"embed_max_quality_cover"`
|
||||
TrackNumber int `json:"track_number"`
|
||||
DiscNumber int `json:"disc_number"`
|
||||
TotalTracks int `json:"total_tracks"`
|
||||
ReleaseDate string `json:"release_date"`
|
||||
ItemID string `json:"item_id"` // Unique ID for progress tracking
|
||||
DurationMS int `json:"duration_ms"` // Expected duration in milliseconds (for verification)
|
||||
Source string `json:"source"` // Extension ID that provided this track (prioritize this extension)
|
||||
// Extended metadata from Deezer for FLAC tagging
|
||||
Genre string `json:"genre,omitempty"` // Music genre(s), comma-separated
|
||||
Label string `json:"label,omitempty"` // Record label name
|
||||
Copyright string `json:"copyright,omitempty"` // Copyright information
|
||||
// Enriched IDs from Odesli/song.link - used to skip search and directly fetch
|
||||
TidalID string `json:"tidal_id,omitempty"`
|
||||
QobuzID string `json:"qobuz_id,omitempty"`
|
||||
DeezerID string `json:"deezer_id,omitempty"`
|
||||
// Lyrics mode: "embed" (default), "external" (.lrc file), "both"
|
||||
LyricsMode string `json:"lyrics_mode,omitempty"`
|
||||
ItemID string `json:"item_id"`
|
||||
DurationMS int `json:"duration_ms"`
|
||||
Source string `json:"source"`
|
||||
Genre string `json:"genre,omitempty"`
|
||||
Label string `json:"label,omitempty"`
|
||||
Copyright string `json:"copyright,omitempty"`
|
||||
TidalID string `json:"tidal_id,omitempty"`
|
||||
QobuzID string `json:"qobuz_id,omitempty"`
|
||||
DeezerID string `json:"deezer_id,omitempty"`
|
||||
LyricsMode string `json:"lyrics_mode,omitempty"`
|
||||
}
|
||||
|
||||
// DownloadResponse represents the result of a download
|
||||
type DownloadResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message"`
|
||||
FilePath string `json:"file_path,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
ErrorType string `json:"error_type,omitempty"` // "not_found", "rate_limit", "network", "unknown"
|
||||
AlreadyExists bool `json:"already_exists,omitempty"`
|
||||
// Actual quality info from the source
|
||||
ActualBitDepth int `json:"actual_bit_depth,omitempty"`
|
||||
ActualSampleRate int `json:"actual_sample_rate,omitempty"`
|
||||
Service string `json:"service,omitempty"` // Actual service used (for fallback)
|
||||
Title string `json:"title,omitempty"`
|
||||
Artist string `json:"artist,omitempty"`
|
||||
Album string `json:"album,omitempty"`
|
||||
AlbumArtist string `json:"album_artist,omitempty"`
|
||||
ReleaseDate string `json:"release_date,omitempty"`
|
||||
TrackNumber int `json:"track_number,omitempty"`
|
||||
DiscNumber int `json:"disc_number,omitempty"`
|
||||
ISRC string `json:"isrc,omitempty"`
|
||||
CoverURL string `json:"cover_url,omitempty"`
|
||||
// Extended metadata for FLAC tagging (passed to Flutter for M4A->FLAC conversion)
|
||||
Genre string `json:"genre,omitempty"` // Music genre(s)
|
||||
Label string `json:"label,omitempty"` // Record label
|
||||
Copyright string `json:"copyright,omitempty"` // Copyright info
|
||||
// If true, skip metadata enrichment from Deezer/Spotify (extension already provides metadata)
|
||||
SkipMetadataEnrichment bool `json:"skip_metadata_enrichment,omitempty"`
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message"`
|
||||
FilePath string `json:"file_path,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
ErrorType string `json:"error_type,omitempty"` // "not_found", "rate_limit", "network", "unknown"
|
||||
AlreadyExists bool `json:"already_exists,omitempty"`
|
||||
ActualBitDepth int `json:"actual_bit_depth,omitempty"`
|
||||
ActualSampleRate int `json:"actual_sample_rate,omitempty"`
|
||||
Service string `json:"service,omitempty"` // Actual service used (for fallback)
|
||||
Title string `json:"title,omitempty"`
|
||||
Artist string `json:"artist,omitempty"`
|
||||
Album string `json:"album,omitempty"`
|
||||
AlbumArtist string `json:"album_artist,omitempty"`
|
||||
ReleaseDate string `json:"release_date,omitempty"`
|
||||
TrackNumber int `json:"track_number,omitempty"`
|
||||
DiscNumber int `json:"disc_number,omitempty"`
|
||||
ISRC string `json:"isrc,omitempty"`
|
||||
CoverURL string `json:"cover_url,omitempty"`
|
||||
Genre string `json:"genre,omitempty"`
|
||||
Label string `json:"label,omitempty"`
|
||||
Copyright string `json:"copyright,omitempty"`
|
||||
SkipMetadataEnrichment bool `json:"skip_metadata_enrichment,omitempty"`
|
||||
}
|
||||
|
||||
// DownloadResult is a generic result type for all downloaders
|
||||
type DownloadResult struct {
|
||||
FilePath string
|
||||
BitDepth int
|
||||
@@ -208,9 +187,6 @@ type DownloadResult struct {
|
||||
ISRC string
|
||||
}
|
||||
|
||||
// DownloadTrack downloads a track from the specified service
|
||||
// requestJSON is a JSON string of DownloadRequest
|
||||
// Returns JSON string of DownloadResponse
|
||||
func DownloadTrack(requestJSON string) (string, error) {
|
||||
var req DownloadRequest
|
||||
if err := json.Unmarshal([]byte(requestJSON), &req); err != nil {
|
||||
@@ -224,7 +200,6 @@ func DownloadTrack(requestJSON string) (string, error) {
|
||||
req.AlbumArtist = strings.TrimSpace(req.AlbumArtist)
|
||||
req.OutputDir = strings.TrimSpace(req.OutputDir)
|
||||
|
||||
// Add output directory to allowed download dirs for extensions
|
||||
if req.OutputDir != "" {
|
||||
AddAllowedDownloadDir(req.OutputDir)
|
||||
}
|
||||
@@ -348,22 +323,18 @@ func DownloadTrack(requestJSON string) (string, error) {
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
// DownloadWithFallback tries to download from services in order
|
||||
// Starts with the preferred service from request, then tries others
|
||||
func DownloadWithFallback(requestJSON string) (string, error) {
|
||||
var req DownloadRequest
|
||||
if err := json.Unmarshal([]byte(requestJSON), &req); err != nil {
|
||||
return errorResponse("Invalid request: " + err.Error())
|
||||
}
|
||||
|
||||
// Trim whitespace from string fields to prevent filename/path issues
|
||||
req.TrackName = strings.TrimSpace(req.TrackName)
|
||||
req.ArtistName = strings.TrimSpace(req.ArtistName)
|
||||
req.AlbumName = strings.TrimSpace(req.AlbumName)
|
||||
req.AlbumArtist = strings.TrimSpace(req.AlbumArtist)
|
||||
req.OutputDir = strings.TrimSpace(req.OutputDir)
|
||||
|
||||
// Add output directory to allowed download dirs for extensions
|
||||
if req.OutputDir != "" {
|
||||
AddAllowedDownloadDir(req.OutputDir)
|
||||
}
|
||||
@@ -520,47 +491,36 @@ func DownloadWithFallback(requestJSON string) (string, error) {
|
||||
return errorResponse("All services failed. Last error: " + lastErr.Error())
|
||||
}
|
||||
|
||||
// GetDownloadProgress returns current download progress
|
||||
func GetDownloadProgress() string {
|
||||
progress := getProgress()
|
||||
jsonBytes, _ := json.Marshal(progress)
|
||||
return string(jsonBytes)
|
||||
}
|
||||
|
||||
// GetAllDownloadProgress returns progress for all active downloads (concurrent mode)
|
||||
func GetAllDownloadProgress() string {
|
||||
return GetMultiProgress()
|
||||
}
|
||||
|
||||
// InitItemProgress initializes progress tracking for a download item
|
||||
func InitItemProgress(itemID string) {
|
||||
StartItemProgress(itemID)
|
||||
}
|
||||
|
||||
// FinishItemProgress marks a download item as complete and removes tracking
|
||||
func FinishItemProgress(itemID string) {
|
||||
CompleteItemProgress(itemID)
|
||||
}
|
||||
|
||||
// ClearItemProgress removes progress tracking for a specific item
|
||||
func ClearItemProgress(itemID string) {
|
||||
RemoveItemProgress(itemID)
|
||||
}
|
||||
|
||||
// CancelDownload cancels an in-progress download for the given item.
|
||||
func CancelDownload(itemID string) {
|
||||
cancelDownload(itemID)
|
||||
}
|
||||
|
||||
// CleanupConnections closes idle HTTP connections
|
||||
// Call this periodically during large batch downloads to prevent TCP exhaustion
|
||||
func CleanupConnections() {
|
||||
CloseIdleConnections()
|
||||
}
|
||||
|
||||
// ReadFileMetadata reads metadata directly from a FLAC file
|
||||
// Returns JSON with all embedded metadata (title, artist, album, track number, etc.)
|
||||
// This is useful for displaying accurate metadata in the UI without relying on cached data
|
||||
func ReadFileMetadata(filePath string) (string, error) {
|
||||
metadata, err := ReadMetadata(filePath)
|
||||
if err != nil {
|
||||
@@ -600,12 +560,10 @@ func ReadFileMetadata(filePath string) (string, error) {
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
// SetDownloadDirectory sets the default download directory
|
||||
func SetDownloadDirectory(path string) error {
|
||||
return setDownloadDir(path)
|
||||
}
|
||||
|
||||
// CheckDuplicate checks if a file with the given ISRC exists
|
||||
func CheckDuplicate(outputDir, isrc string) (string, error) {
|
||||
existingFile, exists := CheckISRCExists(outputDir, isrc)
|
||||
|
||||
@@ -622,26 +580,18 @@ func CheckDuplicate(outputDir, isrc string) (string, error) {
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
// CheckDuplicatesBatch checks multiple files for duplicates in parallel
|
||||
// Uses ISRC index for fast lookup (builds index once, checks all tracks)
|
||||
// tracksJSON format: [{"isrc": "...", "track_name": "...", "artist_name": "..."}, ...]
|
||||
// Returns JSON array of results
|
||||
func CheckDuplicatesBatch(outputDir, tracksJSON string) (string, error) {
|
||||
return CheckFilesExistParallel(outputDir, tracksJSON)
|
||||
}
|
||||
|
||||
// PreBuildDuplicateIndex pre-builds the ISRC index for a directory
|
||||
// Call this when entering album/playlist screen for faster duplicate checking
|
||||
func PreBuildDuplicateIndex(outputDir string) error {
|
||||
return PreBuildISRCIndex(outputDir)
|
||||
}
|
||||
|
||||
// InvalidateDuplicateIndex clears the ISRC index cache for a directory
|
||||
func InvalidateDuplicateIndex(outputDir string) {
|
||||
InvalidateISRCCache(outputDir)
|
||||
}
|
||||
|
||||
// BuildFilename builds a filename from template and metadata
|
||||
func BuildFilename(template string, metadataJSON string) (string, error) {
|
||||
var metadata map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(metadataJSON), &metadata); err != nil {
|
||||
@@ -652,14 +602,10 @@ func BuildFilename(template string, metadataJSON string) (string, error) {
|
||||
return filename, nil
|
||||
}
|
||||
|
||||
// SanitizeFilename removes invalid characters from filename
|
||||
func SanitizeFilename(filename string) string {
|
||||
return sanitizeFilename(filename)
|
||||
}
|
||||
|
||||
// FetchLyrics fetches lyrics for a track from LRCLIB
|
||||
// Returns JSON with lyrics data
|
||||
// durationMs: track duration in milliseconds for matching, use 0 to skip duration matching
|
||||
func FetchLyrics(spotifyID, trackName, artistName string, durationMs int64) (string, error) {
|
||||
client := NewLyricsClient()
|
||||
durationSec := float64(durationMs) / 1000.0
|
||||
@@ -683,9 +629,6 @@ func FetchLyrics(spotifyID, trackName, artistName string, durationMs int64) (str
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
// 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
|
||||
// durationMs: track duration in milliseconds for matching, use 0 to skip duration matching
|
||||
func GetLyricsLRC(spotifyID, trackName, artistName string, filePath string, durationMs int64) (string, error) {
|
||||
if filePath != "" {
|
||||
lyrics, err := ExtractLyrics(filePath)
|
||||
@@ -705,7 +648,6 @@ func GetLyricsLRC(spotifyID, trackName, artistName string, filePath string, dura
|
||||
return lrcContent, nil
|
||||
}
|
||||
|
||||
// EmbedLyricsToFile embeds lyrics into an existing FLAC file
|
||||
func EmbedLyricsToFile(filePath, lyrics string) (string, error) {
|
||||
err := EmbedLyrics(filePath, lyrics)
|
||||
if err != nil {
|
||||
@@ -721,9 +663,6 @@ func EmbedLyricsToFile(filePath, lyrics string) (string, error) {
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
// PreWarmTrackCacheJSON pre-warms the track ID cache for album/playlist tracks
|
||||
// tracksJSON is a JSON array of objects with: isrc, track_name, artist_name, spotify_id, service
|
||||
// This runs in background and returns immediately
|
||||
func PreWarmTrackCacheJSON(tracksJSON string) (string, error) {
|
||||
var tracks []struct {
|
||||
ISRC string `json:"isrc"`
|
||||
@@ -759,20 +698,14 @@ func PreWarmTrackCacheJSON(tracksJSON string) (string, error) {
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
// GetTrackCacheSize returns the current track ID cache size
|
||||
func GetTrackCacheSize() int {
|
||||
return GetCacheSize()
|
||||
}
|
||||
|
||||
// ClearTrackIDCache clears the track ID cache
|
||||
func ClearTrackIDCache() {
|
||||
ClearTrackCache()
|
||||
}
|
||||
|
||||
// ==================== DEEZER API ====================
|
||||
|
||||
// SearchDeezerAll searches for tracks and artists on Deezer (no API key required)
|
||||
// Returns JSON with tracks and artists arrays
|
||||
func SearchDeezerAll(query string, trackLimit, artistLimit int) (string, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
@@ -990,10 +923,6 @@ func GetSpotifyMetadataWithDeezerFallback(spotifyURL string) (string, error) {
|
||||
return "", fmt.Errorf("spotify rate limited. Playlists are user-specific and require Spotify API")
|
||||
}
|
||||
|
||||
// ==================== SONGLINK DEEZER SUPPORT ====================
|
||||
|
||||
// CheckAvailabilityFromDeezerID checks track availability using Deezer track ID as source
|
||||
// Returns JSON with availability info for Spotify, Tidal, Amazon, etc.
|
||||
func CheckAvailabilityFromDeezerID(deezerTrackID string) (string, error) {
|
||||
client := NewSongLinkClient()
|
||||
availability, err := client.CheckAvailabilityFromDeezer(deezerTrackID)
|
||||
@@ -1177,14 +1106,12 @@ func UpgradeExtensionFromPath(filePath string) (string, error) {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Initialize with saved settings
|
||||
settingsStore := GetExtensionSettingsStore()
|
||||
settings := settingsStore.GetAll(ext.ID)
|
||||
if len(settings) > 0 {
|
||||
manager.InitializeExtension(ext.ID, settings)
|
||||
}
|
||||
|
||||
// Return extension info as JSON
|
||||
result := map[string]interface{}{
|
||||
"id": ext.ID,
|
||||
"display_name": ext.Manifest.DisplayName,
|
||||
@@ -1348,8 +1275,6 @@ func InvokeExtensionActionJSON(extensionID, actionName string) (string, error) {
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
// ==================== EXTENSION AUTH API ====================
|
||||
|
||||
// GetExtensionPendingAuthJSON returns pending auth request for an extension
|
||||
func GetExtensionPendingAuthJSON(extensionID string) (string, error) {
|
||||
req := GetPendingAuthRequest(extensionID)
|
||||
@@ -1429,9 +1354,6 @@ func GetAllPendingAuthRequestsJSON() (string, error) {
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
// ==================== EXTENSION FFMPEG API ====================
|
||||
|
||||
// GetPendingFFmpegCommandJSON returns a pending FFmpeg command for Flutter to execute
|
||||
func GetPendingFFmpegCommandJSON(commandID string) (string, error) {
|
||||
cmd := GetPendingFFmpegCommand(commandID)
|
||||
if cmd == nil {
|
||||
@@ -1491,7 +1413,6 @@ func EnrichTrackWithExtensionJSON(extensionID, trackJSON string) (string, error)
|
||||
manager := GetExtensionManager()
|
||||
ext, err := manager.GetExtension(extensionID)
|
||||
if err != nil {
|
||||
// Extension not found, return original track
|
||||
return trackJSON, nil
|
||||
}
|
||||
|
||||
@@ -1595,10 +1516,6 @@ func GetSearchProvidersJSON() (string, error) {
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
// ==================== EXTENSION URL HANDLER ====================
|
||||
|
||||
// HandleURLWithExtensionJSON tries to handle a URL with any matching extension
|
||||
// Returns JSON with type, tracks, album info, etc.
|
||||
func HandleURLWithExtensionJSON(url string) (string, error) {
|
||||
manager := GetExtensionManager()
|
||||
resultWithID, err := manager.HandleURLWithExtension(url)
|
||||
@@ -1860,7 +1777,6 @@ func GetPlaylistWithExtensionJSON(extensionID, playlistID string) (string, error
|
||||
return "", fmt.Errorf("failed to marshal result: %w", err)
|
||||
}
|
||||
|
||||
// Parse into album metadata (same structure)
|
||||
var album ExtAlbumMetadata
|
||||
if err := json.Unmarshal(jsonBytes, &album); err != nil {
|
||||
return "", fmt.Errorf("failed to parse playlist: %w", err)
|
||||
@@ -1961,7 +1877,6 @@ func GetArtistWithExtensionJSON(extensionID, artistID string) (string, error) {
|
||||
response["header_image"] = artist.HeaderImage
|
||||
}
|
||||
|
||||
// Add listeners if present
|
||||
if artist.Listeners > 0 {
|
||||
response["listeners"] = artist.Listeners
|
||||
}
|
||||
@@ -2019,9 +1934,6 @@ func GetURLHandlersJSON() (string, error) {
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
// ==================== EXTENSION POST-PROCESSING ====================
|
||||
|
||||
// RunPostProcessingJSON runs post-processing hooks on a file
|
||||
func RunPostProcessingJSON(filePath, metadataJSON string) (string, error) {
|
||||
var metadata map[string]interface{}
|
||||
if metadataJSON != "" {
|
||||
@@ -2077,8 +1989,6 @@ func GetPostProcessingProvidersJSON() (string, error) {
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
// ==================== EXTENSION STORE ====================
|
||||
|
||||
// InitExtensionStoreJSON initializes the extension store with cache directory
|
||||
func InitExtensionStoreJSON(cacheDir string) error {
|
||||
InitExtensionStore(cacheDir)
|
||||
@@ -2092,7 +2002,6 @@ func GetStoreExtensionsJSON(forceRefresh bool) (string, error) {
|
||||
return "", fmt.Errorf("extension store not initialized")
|
||||
}
|
||||
|
||||
// Force refresh if requested
|
||||
if forceRefresh {
|
||||
store.FetchRegistry(true)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
// Package gobackend provides extension management functionality
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
@@ -15,8 +14,6 @@ import (
|
||||
"github.com/dop251/goja"
|
||||
)
|
||||
|
||||
// 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 {
|
||||
parts1 := strings.Split(strings.TrimPrefix(v1, "v"), ".")
|
||||
parts2 := strings.Split(strings.TrimPrefix(v2, "v"), ".")
|
||||
@@ -46,7 +43,6 @@ func compareVersions(v1, v2 string) int {
|
||||
return 0
|
||||
}
|
||||
|
||||
// LoadedExtension represents an extension that has been loaded into memory
|
||||
type LoadedExtension struct {
|
||||
ID string `json:"id"`
|
||||
Manifest *ExtensionManifest `json:"manifest"`
|
||||
@@ -72,7 +68,6 @@ var (
|
||||
globalExtManagerOnce sync.Once
|
||||
)
|
||||
|
||||
// GetExtensionManager returns the global extension manager instance
|
||||
func GetExtensionManager() *ExtensionManager {
|
||||
globalExtManagerOnce.Do(func() {
|
||||
globalExtManager = &ExtensionManager{
|
||||
@@ -82,7 +77,6 @@ func GetExtensionManager() *ExtensionManager {
|
||||
return globalExtManager
|
||||
}
|
||||
|
||||
// SetDirectories sets the extensions and data directories
|
||||
func (m *ExtensionManager) SetDirectories(extensionsDir, dataDir string) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
@@ -100,9 +94,7 @@ func (m *ExtensionManager) SetDirectories(extensionsDir, dataDir string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// LoadExtensionFromFile loads an extension from a .spotiflac-ext file
|
||||
func (m *ExtensionManager) LoadExtensionFromFile(filePath string) (*LoadedExtension, error) {
|
||||
// Validate file extension
|
||||
if !strings.HasSuffix(strings.ToLower(filePath), ".spotiflac-ext") {
|
||||
return nil, fmt.Errorf("Invalid file format. Please select a .spotiflac-ext file")
|
||||
}
|
||||
@@ -181,14 +173,11 @@ func (m *ExtensionManager) LoadExtensionFromFile(filePath string) (*LoadedExtens
|
||||
return nil, fmt.Errorf("failed to create extension directory: %w", err)
|
||||
}
|
||||
|
||||
// Extract all files (preserving directory structure)
|
||||
for _, file := range zipReader.File {
|
||||
if file.FileInfo().IsDir() {
|
||||
continue
|
||||
}
|
||||
|
||||
// Preserve relative path within the zip (support subdirectories)
|
||||
// Clean the path to prevent path traversal attacks
|
||||
relPath := filepath.Clean(file.Name)
|
||||
if strings.HasPrefix(relPath, "..") || filepath.IsAbs(relPath) {
|
||||
GoLog("[Extension] Skipping unsafe path in archive: %s\n", file.Name)
|
||||
@@ -246,7 +235,6 @@ func (m *ExtensionManager) LoadExtensionFromFile(filePath string) (*LoadedExtens
|
||||
return ext, nil
|
||||
}
|
||||
|
||||
// initializeVM creates and initializes the Goja VM for an extension
|
||||
func (m *ExtensionManager) initializeVM(ext *LoadedExtension) error {
|
||||
vm := goja.New()
|
||||
ext.VM = vm
|
||||
@@ -323,7 +311,6 @@ func (m *ExtensionManager) UnloadExtension(extensionID string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetExtension returns a loaded extension by ID
|
||||
// Returns error if extension not found (gomobile compatible)
|
||||
func (m *ExtensionManager) GetExtension(extensionID string) (*LoadedExtension, error) {
|
||||
m.mu.RLock()
|
||||
@@ -348,7 +335,6 @@ func (m *ExtensionManager) GetAllExtensions() []*LoadedExtension {
|
||||
return result
|
||||
}
|
||||
|
||||
// SetExtensionEnabled enables or disables an extension
|
||||
func (m *ExtensionManager) SetExtensionEnabled(extensionID string, enabled bool) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
@@ -409,7 +395,6 @@ func (m *ExtensionManager) LoadExtensionsFromDirectory(dirPath string) ([]string
|
||||
return loaded, errors
|
||||
}
|
||||
|
||||
// loadExtensionFromDirectory loads an extension from an already extracted directory
|
||||
func (m *ExtensionManager) loadExtensionFromDirectory(dirPath string) (*LoadedExtension, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
@@ -498,7 +483,6 @@ func (m *ExtensionManager) RemoveExtension(extensionID string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpgradeExtension upgrades an existing extension from a new package file
|
||||
// Only allows upgrades (new version > current version), not downgrades
|
||||
func (m *ExtensionManager) UpgradeExtension(filePath string) (*LoadedExtension, error) {
|
||||
// Validate file extension
|
||||
@@ -645,7 +629,6 @@ func (m *ExtensionManager) UpgradeExtension(filePath string) (*LoadedExtension,
|
||||
return ext, nil
|
||||
}
|
||||
|
||||
// ExtensionUpgradeInfo holds information about extension upgrade check
|
||||
type ExtensionUpgradeInfo struct {
|
||||
ExtensionID string `json:"extension_id"`
|
||||
CurrentVersion string `json:"current_version"`
|
||||
@@ -717,7 +700,6 @@ func (m *ExtensionManager) checkExtensionUpgradeInternal(filePath string) (*Exte
|
||||
return info, nil
|
||||
}
|
||||
|
||||
// CheckExtensionUpgradeJSON checks if a package file is an upgrade and returns JSON
|
||||
func (m *ExtensionManager) CheckExtensionUpgradeJSON(filePath string) (string, error) {
|
||||
info, err := m.checkExtensionUpgradeInternal(filePath)
|
||||
if err != nil {
|
||||
@@ -827,7 +809,6 @@ func (m *ExtensionManager) GetInstalledExtensionsJSON() (string, error) {
|
||||
|
||||
// ==================== Extension Lifecycle ====================
|
||||
|
||||
// InitializeExtension calls the extension's initialize method with settings
|
||||
func (m *ExtensionManager) InitializeExtension(extensionID string, settings map[string]interface{}) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
@@ -889,7 +870,6 @@ func (m *ExtensionManager) InitializeExtension(extensionID string, settings map[
|
||||
return nil
|
||||
}
|
||||
|
||||
// CleanupExtension calls the extension's cleanup method
|
||||
func (m *ExtensionManager) CleanupExtension(extensionID string) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
@@ -900,10 +880,9 @@ func (m *ExtensionManager) CleanupExtension(extensionID string) error {
|
||||
}
|
||||
|
||||
if ext.VM == nil {
|
||||
return nil // No VM, nothing to cleanup
|
||||
return nil
|
||||
}
|
||||
|
||||
// Call cleanup function
|
||||
script := `
|
||||
(function() {
|
||||
if (typeof extension !== 'undefined' && typeof extension.cleanup === 'function') {
|
||||
@@ -952,16 +931,13 @@ func (m *ExtensionManager) UnloadAllExtensions() {
|
||||
m.mu.Unlock()
|
||||
|
||||
for _, id := range extensionIDs {
|
||||
// Call cleanup first
|
||||
m.CleanupExtension(id)
|
||||
// Then unload
|
||||
m.UnloadExtension(id)
|
||||
}
|
||||
|
||||
GoLog("[Extension] All extensions unloaded\n")
|
||||
}
|
||||
|
||||
// InvokeAction calls a custom action function on an extension (e.g., for button settings)
|
||||
// The function is called as extension.<actionName>() and can return a result
|
||||
func (m *ExtensionManager) InvokeAction(extensionID string, actionName string) (map[string]interface{}, error) {
|
||||
m.mu.Lock()
|
||||
|
||||
@@ -151,9 +151,7 @@ func ParseManifest(data []byte) (*ExtensionManifest, error) {
|
||||
return &manifest, nil
|
||||
}
|
||||
|
||||
// Validate checks if the manifest has all required fields and valid values
|
||||
func (m *ExtensionManifest) Validate() error {
|
||||
// Check required fields
|
||||
if strings.TrimSpace(m.Name) == "" {
|
||||
return &ManifestValidationError{Field: "name", Message: "name is required"}
|
||||
}
|
||||
@@ -174,7 +172,6 @@ func (m *ExtensionManifest) Validate() error {
|
||||
return &ManifestValidationError{Field: "type", Message: "at least one type is required"}
|
||||
}
|
||||
|
||||
// Validate extension types
|
||||
for _, t := range m.Types {
|
||||
if t != ExtensionTypeMetadataProvider && t != ExtensionTypeDownloadProvider {
|
||||
return &ManifestValidationError{
|
||||
@@ -200,21 +197,6 @@ func (m *ExtensionManifest) Validate() error {
|
||||
}
|
||||
}
|
||||
|
||||
// Validate setting type
|
||||
validTypes := map[SettingType]bool{
|
||||
SettingTypeString: true,
|
||||
SettingTypeNumber: true,
|
||||
SettingTypeBool: true,
|
||||
SettingTypeSelect: true,
|
||||
SettingTypeButton: true,
|
||||
}
|
||||
if !validTypes[setting.Type] {
|
||||
return &ManifestValidationError{
|
||||
Field: fmt.Sprintf("settings[%d].type", i),
|
||||
Message: fmt.Sprintf("invalid setting type: %s", setting.Type),
|
||||
}
|
||||
}
|
||||
|
||||
// Select type requires options
|
||||
if setting.Type == SettingTypeSelect && len(setting.Options) == 0 {
|
||||
return &ManifestValidationError{
|
||||
@@ -223,7 +205,6 @@ func (m *ExtensionManifest) Validate() error {
|
||||
}
|
||||
}
|
||||
|
||||
// Button type requires action
|
||||
if setting.Type == SettingTypeButton && setting.Action == "" {
|
||||
return &ManifestValidationError{
|
||||
Field: fmt.Sprintf("settings[%d].action", i),
|
||||
@@ -300,7 +281,6 @@ func (m *ExtensionManifest) MatchesURL(urlStr string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// Parse URL to get host
|
||||
urlStr = strings.ToLower(strings.TrimSpace(urlStr))
|
||||
for _, pattern := range m.URLHandler.Patterns {
|
||||
pattern = strings.ToLower(strings.TrimSpace(pattern))
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
// Package gobackend provides extension runtime with sandboxed execution
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
@@ -17,7 +16,6 @@ var (
|
||||
extensionAuthStateMu sync.RWMutex
|
||||
)
|
||||
|
||||
// ExtensionAuthState holds auth state for an extension
|
||||
type ExtensionAuthState struct {
|
||||
PendingAuthURL string
|
||||
AuthCode string
|
||||
@@ -30,7 +28,6 @@ type ExtensionAuthState struct {
|
||||
PKCEChallenge string
|
||||
}
|
||||
|
||||
// PendingAuthRequest holds a pending OAuth request that needs Flutter to open URL
|
||||
type PendingAuthRequest struct {
|
||||
ExtensionID string
|
||||
AuthURL string
|
||||
@@ -55,7 +52,6 @@ func ClearPendingAuthRequest(extensionID string) {
|
||||
delete(pendingAuthRequests, extensionID)
|
||||
}
|
||||
|
||||
// SetExtensionAuthCode sets auth code for an extension (called from Flutter after OAuth callback)
|
||||
func SetExtensionAuthCode(extensionID string, authCode string) {
|
||||
extensionAuthStateMu.Lock()
|
||||
defer extensionAuthStateMu.Unlock()
|
||||
@@ -68,7 +64,6 @@ func SetExtensionAuthCode(extensionID string, authCode string) {
|
||||
state.AuthCode = authCode
|
||||
}
|
||||
|
||||
// SetExtensionTokens sets access/refresh tokens for an extension
|
||||
func SetExtensionTokens(extensionID string, accessToken, refreshToken string, expiresAt time.Time) {
|
||||
extensionAuthStateMu.Lock()
|
||||
defer extensionAuthStateMu.Unlock()
|
||||
@@ -84,7 +79,6 @@ func SetExtensionTokens(extensionID string, accessToken, refreshToken string, ex
|
||||
state.IsAuthenticated = accessToken != ""
|
||||
}
|
||||
|
||||
// ExtensionRuntime provides sandboxed APIs for extensions
|
||||
type ExtensionRuntime struct {
|
||||
extensionID string
|
||||
manifest *ExtensionManifest
|
||||
@@ -95,7 +89,6 @@ type ExtensionRuntime struct {
|
||||
vm *goja.Runtime
|
||||
}
|
||||
|
||||
// NewExtensionRuntime creates a new runtime for an extension
|
||||
func NewExtensionRuntime(ext *LoadedExtension) *ExtensionRuntime {
|
||||
jar, _ := newSimpleCookieJar()
|
||||
|
||||
@@ -108,7 +101,6 @@ func NewExtensionRuntime(ext *LoadedExtension) *ExtensionRuntime {
|
||||
vm: ext.VM,
|
||||
}
|
||||
|
||||
// Create HTTP client with redirect validation to prevent SSRF via open redirect
|
||||
client := &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
Jar: jar,
|
||||
@@ -119,7 +111,6 @@ func NewExtensionRuntime(ext *LoadedExtension) *ExtensionRuntime {
|
||||
GoLog("[Extension:%s] Redirect blocked: domain '%s' not in allowed list\n", ext.ID, domain)
|
||||
return &RedirectBlockedError{Domain: domain}
|
||||
}
|
||||
// Also block redirects to private/local networks (SSRF protection)
|
||||
if isPrivateIP(domain) {
|
||||
GoLog("[Extension:%s] Redirect blocked: private IP '%s'\n", ext.ID, domain)
|
||||
return &RedirectBlockedError{Domain: domain, IsPrivate: true}
|
||||
@@ -136,7 +127,6 @@ func NewExtensionRuntime(ext *LoadedExtension) *ExtensionRuntime {
|
||||
return runtime
|
||||
}
|
||||
|
||||
// RedirectBlockedError is returned when a redirect is blocked due to domain validation
|
||||
type RedirectBlockedError struct {
|
||||
Domain string
|
||||
IsPrivate bool
|
||||
@@ -162,10 +152,10 @@ func isPrivateIP(host string) bool {
|
||||
"172.24.", "172.25.", "172.26.", "172.27.",
|
||||
"172.28.", "172.29.", "172.30.", "172.31.",
|
||||
"192.168.",
|
||||
"169.254.", // Link-local
|
||||
"::1", // IPv6 localhost
|
||||
"fc00:", // IPv6 private
|
||||
"fe80:", // IPv6 link-local
|
||||
"169.254.",
|
||||
"::1",
|
||||
"fc00:",
|
||||
"fe80:",
|
||||
}
|
||||
|
||||
hostLower := host
|
||||
@@ -183,7 +173,6 @@ func isPrivateIP(host string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// simpleCookieJar is a simple in-memory cookie jar
|
||||
type simpleCookieJar struct {
|
||||
cookies map[string][]*http.Cookie
|
||||
mu sync.RWMutex
|
||||
@@ -208,7 +197,6 @@ func (j *simpleCookieJar) Cookies(u *url.URL) []*http.Cookie {
|
||||
return j.cookies[u.Host]
|
||||
}
|
||||
|
||||
// SetSettings updates the runtime settings
|
||||
func (r *ExtensionRuntime) SetSettings(settings map[string]interface{}) {
|
||||
r.settings = settings
|
||||
}
|
||||
@@ -228,7 +216,6 @@ func (r *ExtensionRuntime) RegisterAPIs(vm *goja.Runtime) {
|
||||
httpObj.Set("clearCookies", r.httpClearCookies)
|
||||
vm.Set("http", httpObj)
|
||||
|
||||
// Storage API
|
||||
storageObj := vm.NewObject()
|
||||
storageObj.Set("get", r.storageGet)
|
||||
storageObj.Set("set", r.storageSet)
|
||||
@@ -243,7 +230,6 @@ func (r *ExtensionRuntime) RegisterAPIs(vm *goja.Runtime) {
|
||||
credentialsObj.Set("has", r.credentialsHas)
|
||||
vm.Set("credentials", credentialsObj)
|
||||
|
||||
// Auth API (for OAuth and other auth flows)
|
||||
authObj := vm.NewObject()
|
||||
authObj.Set("openAuthUrl", r.authOpenUrl)
|
||||
authObj.Set("getAuthCode", r.authGetCode)
|
||||
@@ -270,7 +256,6 @@ func (r *ExtensionRuntime) RegisterAPIs(vm *goja.Runtime) {
|
||||
fileObj.Set("getSize", r.fileGetSize)
|
||||
vm.Set("file", fileObj)
|
||||
|
||||
// FFmpeg API (for post-processing)
|
||||
ffmpegObj := vm.NewObject()
|
||||
ffmpegObj.Set("execute", r.ffmpegExecute)
|
||||
ffmpegObj.Set("getInfo", r.ffmpegGetInfo)
|
||||
@@ -284,7 +269,6 @@ func (r *ExtensionRuntime) RegisterAPIs(vm *goja.Runtime) {
|
||||
matchingObj.Set("normalizeString", r.matchingNormalizeString)
|
||||
vm.Set("matching", matchingObj)
|
||||
|
||||
// Utilities
|
||||
utilsObj := vm.NewObject()
|
||||
utilsObj.Set("base64Encode", r.base64Encode)
|
||||
utilsObj.Set("base64Decode", r.base64Decode)
|
||||
@@ -310,7 +294,6 @@ func (r *ExtensionRuntime) RegisterAPIs(vm *goja.Runtime) {
|
||||
logObj.Set("error", r.logError)
|
||||
vm.Set("log", logObj)
|
||||
|
||||
// Go backend functions
|
||||
gobackendObj := vm.NewObject()
|
||||
gobackendObj.Set("sanitizeFilename", r.sanitizeFilenameWrapper)
|
||||
vm.Set("gobackend", gobackendObj)
|
||||
@@ -321,16 +304,12 @@ func (r *ExtensionRuntime) RegisterAPIs(vm *goja.Runtime) {
|
||||
// Global fetch() - Promise-style HTTP API (browser-compatible)
|
||||
vm.Set("fetch", r.fetchPolyfill)
|
||||
|
||||
// Global atob/btoa - Base64 encoding (browser-compatible)
|
||||
vm.Set("atob", r.atobPolyfill)
|
||||
vm.Set("btoa", r.btoaPolyfill)
|
||||
|
||||
// TextEncoder/TextDecoder constructors
|
||||
r.registerTextEncoderDecoder(vm)
|
||||
|
||||
// URL class for URL parsing
|
||||
r.registerURLClass(vm)
|
||||
|
||||
// JSON global (browser-compatible)
|
||||
r.registerJSONGlobal(vm)
|
||||
}
|
||||
|
||||
@@ -18,7 +18,6 @@ import (
|
||||
|
||||
// ==================== Auth API (OAuth Support) ====================
|
||||
|
||||
// authOpenUrl requests Flutter to open an OAuth URL
|
||||
func (r *ExtensionRuntime) authOpenUrl(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
@@ -33,7 +32,6 @@ func (r *ExtensionRuntime) authOpenUrl(call goja.FunctionCall) goja.Value {
|
||||
callbackURL = call.Arguments[1].String()
|
||||
}
|
||||
|
||||
// Store pending auth request for Flutter to pick up
|
||||
pendingAuthRequestsMu.Lock()
|
||||
pendingAuthRequests[r.extensionID] = &PendingAuthRequest{
|
||||
ExtensionID: r.extensionID,
|
||||
@@ -42,7 +40,6 @@ func (r *ExtensionRuntime) authOpenUrl(call goja.FunctionCall) goja.Value {
|
||||
}
|
||||
pendingAuthRequestsMu.Unlock()
|
||||
|
||||
// Update auth state
|
||||
extensionAuthStateMu.Lock()
|
||||
state, exists := extensionAuthState[r.extensionID]
|
||||
if !exists {
|
||||
@@ -50,7 +47,7 @@ func (r *ExtensionRuntime) authOpenUrl(call goja.FunctionCall) goja.Value {
|
||||
extensionAuthState[r.extensionID] = state
|
||||
}
|
||||
state.PendingAuthURL = authURL
|
||||
state.AuthCode = "" // Clear any previous auth code
|
||||
state.AuthCode = ""
|
||||
extensionAuthStateMu.Unlock()
|
||||
|
||||
GoLog("[Extension:%s] Auth URL requested: %s\n", r.extensionID, authURL)
|
||||
@@ -61,7 +58,6 @@ func (r *ExtensionRuntime) authOpenUrl(call goja.FunctionCall) goja.Value {
|
||||
})
|
||||
}
|
||||
|
||||
// authGetCode gets the auth code (set by Flutter after OAuth callback)
|
||||
func (r *ExtensionRuntime) authGetCode(call goja.FunctionCall) goja.Value {
|
||||
extensionAuthStateMu.RLock()
|
||||
defer extensionAuthStateMu.RUnlock()
|
||||
@@ -114,7 +110,6 @@ func (r *ExtensionRuntime) authSetCode(call goja.FunctionCall) goja.Value {
|
||||
return r.vm.ToValue(true)
|
||||
}
|
||||
|
||||
// authClear clears all auth state for the extension
|
||||
func (r *ExtensionRuntime) authClear(call goja.FunctionCall) goja.Value {
|
||||
extensionAuthStateMu.Lock()
|
||||
delete(extensionAuthState, r.extensionID)
|
||||
@@ -138,7 +133,6 @@ func (r *ExtensionRuntime) authIsAuthenticated(call goja.FunctionCall) goja.Valu
|
||||
return r.vm.ToValue(false)
|
||||
}
|
||||
|
||||
// Check if token is expired
|
||||
if state.IsAuthenticated && !state.ExpiresAt.IsZero() && time.Now().After(state.ExpiresAt) {
|
||||
return r.vm.ToValue(false)
|
||||
}
|
||||
@@ -146,7 +140,6 @@ func (r *ExtensionRuntime) authIsAuthenticated(call goja.FunctionCall) goja.Valu
|
||||
return r.vm.ToValue(state.IsAuthenticated)
|
||||
}
|
||||
|
||||
// authGetTokens returns current tokens (for extension to use in API calls)
|
||||
func (r *ExtensionRuntime) authGetTokens(call goja.FunctionCall) goja.Value {
|
||||
extensionAuthStateMu.RLock()
|
||||
defer extensionAuthStateMu.RUnlock()
|
||||
@@ -182,16 +175,13 @@ func generatePKCEVerifier(length int) (string, error) {
|
||||
length = 128
|
||||
}
|
||||
|
||||
// Generate random bytes
|
||||
bytes := make([]byte, length)
|
||||
if _, err := rand.Read(bytes); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Use base64url encoding without padding (RFC 7636 compliant)
|
||||
verifier := base64.RawURLEncoding.EncodeToString(bytes)
|
||||
|
||||
// Trim to exact length
|
||||
if len(verifier) > length {
|
||||
verifier = verifier[:length]
|
||||
}
|
||||
@@ -199,15 +189,12 @@ func generatePKCEVerifier(length int) (string, error) {
|
||||
return verifier, nil
|
||||
}
|
||||
|
||||
// generatePKCEChallenge generates a code challenge from verifier using S256 method
|
||||
func generatePKCEChallenge(verifier string) string {
|
||||
hash := sha256.Sum256([]byte(verifier))
|
||||
// Base64url encode without padding (RFC 7636)
|
||||
return base64.RawURLEncoding.EncodeToString(hash[:])
|
||||
}
|
||||
|
||||
// authGeneratePKCE generates a PKCE code verifier and challenge pair
|
||||
// Returns: { verifier: string, challenge: string, method: "S256" }
|
||||
func (r *ExtensionRuntime) authGeneratePKCE(call goja.FunctionCall) goja.Value {
|
||||
// Default length is 64 characters
|
||||
length := 64
|
||||
@@ -227,7 +214,6 @@ func (r *ExtensionRuntime) authGeneratePKCE(call goja.FunctionCall) goja.Value {
|
||||
|
||||
challenge := generatePKCEChallenge(verifier)
|
||||
|
||||
// Store in auth state for later use
|
||||
extensionAuthStateMu.Lock()
|
||||
state, exists := extensionAuthState[r.extensionID]
|
||||
if !exists {
|
||||
@@ -247,7 +233,6 @@ func (r *ExtensionRuntime) authGeneratePKCE(call goja.FunctionCall) goja.Value {
|
||||
})
|
||||
}
|
||||
|
||||
// authGetPKCE returns the current PKCE verifier and challenge (if generated)
|
||||
func (r *ExtensionRuntime) authGetPKCE(call goja.FunctionCall) goja.Value {
|
||||
extensionAuthStateMu.RLock()
|
||||
defer extensionAuthStateMu.RUnlock()
|
||||
@@ -405,7 +390,6 @@ func (r *ExtensionRuntime) authExchangeCodeWithPKCE(call goja.FunctionCall) goja
|
||||
})
|
||||
}
|
||||
|
||||
// Get stored PKCE verifier
|
||||
extensionAuthStateMu.RLock()
|
||||
state, exists := extensionAuthState[r.extensionID]
|
||||
var verifier string
|
||||
@@ -421,7 +405,6 @@ func (r *ExtensionRuntime) authExchangeCodeWithPKCE(call goja.FunctionCall) goja
|
||||
})
|
||||
}
|
||||
|
||||
// Validate domain
|
||||
if err := r.validateDomain(tokenURL); err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
@@ -429,7 +412,6 @@ func (r *ExtensionRuntime) authExchangeCodeWithPKCE(call goja.FunctionCall) goja
|
||||
})
|
||||
}
|
||||
|
||||
// Build token request body
|
||||
formData := url.Values{}
|
||||
formData.Set("grant_type", "authorization_code")
|
||||
formData.Set("client_id", clientID)
|
||||
@@ -439,14 +421,12 @@ func (r *ExtensionRuntime) authExchangeCodeWithPKCE(call goja.FunctionCall) goja
|
||||
formData.Set("redirect_uri", redirectURI)
|
||||
}
|
||||
|
||||
// Add extra params
|
||||
if extraParams, ok := config["extraParams"].(map[string]interface{}); ok {
|
||||
for k, v := range extraParams {
|
||||
formData.Set(k, fmt.Sprintf("%v", v))
|
||||
}
|
||||
}
|
||||
|
||||
// Make token request
|
||||
req, err := http.NewRequest("POST", tokenURL, strings.NewReader(formData.Encode()))
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
@@ -475,7 +455,6 @@ func (r *ExtensionRuntime) authExchangeCodeWithPKCE(call goja.FunctionCall) goja
|
||||
})
|
||||
}
|
||||
|
||||
// Parse response
|
||||
var tokenResp map[string]interface{}
|
||||
if err := json.Unmarshal(body, &tokenResp); err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
@@ -485,7 +464,6 @@ func (r *ExtensionRuntime) authExchangeCodeWithPKCE(call goja.FunctionCall) goja
|
||||
})
|
||||
}
|
||||
|
||||
// Check for error in response
|
||||
if errMsg, ok := tokenResp["error"].(string); ok {
|
||||
errDesc, _ := tokenResp["error_description"].(string)
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
@@ -495,7 +473,6 @@ func (r *ExtensionRuntime) authExchangeCodeWithPKCE(call goja.FunctionCall) goja
|
||||
})
|
||||
}
|
||||
|
||||
// Extract tokens
|
||||
accessToken, _ := tokenResp["access_token"].(string)
|
||||
refreshToken, _ := tokenResp["refresh_token"].(string)
|
||||
expiresIn, _ := tokenResp["expires_in"].(float64)
|
||||
@@ -508,7 +485,6 @@ func (r *ExtensionRuntime) authExchangeCodeWithPKCE(call goja.FunctionCall) goja
|
||||
})
|
||||
}
|
||||
|
||||
// Store tokens in auth state
|
||||
extensionAuthStateMu.Lock()
|
||||
state, exists = extensionAuthState[r.extensionID]
|
||||
if !exists {
|
||||
@@ -521,14 +497,12 @@ func (r *ExtensionRuntime) authExchangeCodeWithPKCE(call goja.FunctionCall) goja
|
||||
if expiresIn > 0 {
|
||||
state.ExpiresAt = time.Now().Add(time.Duration(expiresIn) * time.Second)
|
||||
}
|
||||
// Clear PKCE after successful exchange
|
||||
state.PKCEVerifier = ""
|
||||
state.PKCEChallenge = ""
|
||||
extensionAuthStateMu.Unlock()
|
||||
|
||||
GoLog("[Extension:%s] PKCE token exchange successful\n", r.extensionID)
|
||||
|
||||
// Return full token response
|
||||
result := map[string]interface{}{
|
||||
"success": true,
|
||||
"access_token": accessToken,
|
||||
@@ -538,7 +512,6 @@ func (r *ExtensionRuntime) authExchangeCodeWithPKCE(call goja.FunctionCall) goja
|
||||
if expiresIn > 0 {
|
||||
result["expires_in"] = expiresIn
|
||||
}
|
||||
// Include any additional fields from response
|
||||
if scope, ok := tokenResp["scope"].(string); ok {
|
||||
result["scope"] = scope
|
||||
}
|
||||
|
||||
@@ -31,14 +31,12 @@ var (
|
||||
ffmpegCommandID int64
|
||||
)
|
||||
|
||||
// GetPendingFFmpegCommand returns a pending FFmpeg command (called from Flutter)
|
||||
func GetPendingFFmpegCommand(commandID string) *FFmpegCommand {
|
||||
ffmpegCommandsMu.RLock()
|
||||
defer ffmpegCommandsMu.RUnlock()
|
||||
return ffmpegCommands[commandID]
|
||||
}
|
||||
|
||||
// SetFFmpegCommandResult sets the result of an FFmpeg command (called from Flutter)
|
||||
func SetFFmpegCommandResult(commandID string, success bool, output, errorMsg string) {
|
||||
ffmpegCommandsMu.Lock()
|
||||
defer ffmpegCommandsMu.Unlock()
|
||||
@@ -50,14 +48,12 @@ func SetFFmpegCommandResult(commandID string, success bool, output, errorMsg str
|
||||
}
|
||||
}
|
||||
|
||||
// ClearFFmpegCommand removes a completed FFmpeg command
|
||||
func ClearFFmpegCommand(commandID string) {
|
||||
ffmpegCommandsMu.Lock()
|
||||
defer ffmpegCommandsMu.Unlock()
|
||||
delete(ffmpegCommands, commandID)
|
||||
}
|
||||
|
||||
// ffmpegExecute queues an FFmpeg command for execution by Flutter
|
||||
func (r *ExtensionRuntime) ffmpegExecute(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
@@ -118,7 +114,6 @@ func (r *ExtensionRuntime) ffmpegExecute(call goja.FunctionCall) goja.Value {
|
||||
}
|
||||
}
|
||||
|
||||
// ffmpegGetInfo gets audio file information using FFprobe
|
||||
func (r *ExtensionRuntime) ffmpegGetInfo(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
@@ -147,7 +142,6 @@ func (r *ExtensionRuntime) ffmpegGetInfo(call goja.FunctionCall) goja.Value {
|
||||
})
|
||||
}
|
||||
|
||||
// ffmpegConvert is a helper for common conversion operations
|
||||
func (r *ExtensionRuntime) ffmpegConvert(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 2 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
|
||||
@@ -21,8 +21,6 @@ var (
|
||||
allowedDownloadDirsMu sync.RWMutex
|
||||
)
|
||||
|
||||
// SetAllowedDownloadDirs sets the list of directories where extensions can write files
|
||||
// This should be called by the Go backend when setting up download paths
|
||||
func SetAllowedDownloadDirs(dirs []string) {
|
||||
allowedDownloadDirsMu.Lock()
|
||||
defer allowedDownloadDirsMu.Unlock()
|
||||
@@ -30,7 +28,6 @@ func SetAllowedDownloadDirs(dirs []string) {
|
||||
GoLog("[Extension] Allowed download directories set: %v\n", dirs)
|
||||
}
|
||||
|
||||
// AddAllowedDownloadDir adds a directory to the allowed list
|
||||
func AddAllowedDownloadDir(dir string) {
|
||||
allowedDownloadDirsMu.Lock()
|
||||
defer allowedDownloadDirsMu.Unlock()
|
||||
@@ -40,7 +37,6 @@ func AddAllowedDownloadDir(dir string) {
|
||||
}
|
||||
}
|
||||
|
||||
// isPathInAllowedDirs checks if an absolute path is within any allowed directory
|
||||
func isPathInAllowedDirs(absPath string) bool {
|
||||
allowedDownloadDirsMu.RLock()
|
||||
defer allowedDownloadDirsMu.RUnlock()
|
||||
@@ -62,36 +58,28 @@ func (r *ExtensionRuntime) validatePath(path string) (string, error) {
|
||||
return "", fmt.Errorf("file access denied: extension does not have 'file' permission")
|
||||
}
|
||||
|
||||
// Clean and resolve the path
|
||||
cleanPath := filepath.Clean(path)
|
||||
|
||||
// SECURITY: Block absolute paths by default
|
||||
// Only allow if path is in explicitly allowed download directories
|
||||
if filepath.IsAbs(cleanPath) {
|
||||
absPath, err := filepath.Abs(cleanPath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("invalid path: %w", err)
|
||||
}
|
||||
|
||||
// Check if path is in allowed download directories
|
||||
if isPathInAllowedDirs(absPath) {
|
||||
return absPath, nil
|
||||
}
|
||||
|
||||
// Block all other absolute paths
|
||||
return "", fmt.Errorf("file access denied: absolute paths are not allowed. Use relative paths within extension sandbox")
|
||||
}
|
||||
|
||||
// For relative paths, join with data directory (extension's sandbox)
|
||||
fullPath := filepath.Join(r.dataDir, cleanPath)
|
||||
|
||||
// Resolve to absolute path
|
||||
absPath, err := filepath.Abs(fullPath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("invalid path: %w", err)
|
||||
}
|
||||
|
||||
// Ensure path is within data directory (prevent path traversal)
|
||||
absDataDir, _ := filepath.Abs(r.dataDir)
|
||||
if !strings.HasPrefix(absPath, absDataDir) {
|
||||
return "", fmt.Errorf("file access denied: path '%s' is outside sandbox", path)
|
||||
@@ -100,8 +88,6 @@ func (r *ExtensionRuntime) validatePath(path string) (string, error) {
|
||||
return absPath, nil
|
||||
}
|
||||
|
||||
// fileDownload downloads a file from URL to the specified path
|
||||
// Supports progress callback via options.onProgress
|
||||
func (r *ExtensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 2 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
@@ -113,7 +99,6 @@ func (r *ExtensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
|
||||
urlStr := call.Arguments[0].String()
|
||||
outputPath := call.Arguments[1].String()
|
||||
|
||||
// Validate domain
|
||||
if err := r.validateDomain(urlStr); err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
@@ -121,7 +106,6 @@ func (r *ExtensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
|
||||
})
|
||||
}
|
||||
|
||||
// Validate output path (allows absolute paths for download queue)
|
||||
fullPath, err := r.validatePath(outputPath)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
@@ -130,20 +114,17 @@ func (r *ExtensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
|
||||
})
|
||||
}
|
||||
|
||||
// Get options if provided
|
||||
var onProgress goja.Callable
|
||||
var headers map[string]string
|
||||
if len(call.Arguments) > 2 && !goja.IsUndefined(call.Arguments[2]) && !goja.IsNull(call.Arguments[2]) {
|
||||
optionsObj := call.Arguments[2].Export()
|
||||
if opts, ok := optionsObj.(map[string]interface{}); ok {
|
||||
// Extract headers
|
||||
if h, ok := opts["headers"].(map[string]interface{}); ok {
|
||||
headers = make(map[string]string)
|
||||
for k, v := range h {
|
||||
headers[k] = fmt.Sprintf("%v", v)
|
||||
}
|
||||
}
|
||||
// Extract onProgress callback
|
||||
if progressVal, ok := opts["onProgress"]; ok {
|
||||
if callable, ok := goja.AssertFunction(r.vm.ToValue(progressVal)); ok {
|
||||
onProgress = callable
|
||||
@@ -152,7 +133,6 @@ func (r *ExtensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
|
||||
}
|
||||
}
|
||||
|
||||
// Create directory if needed
|
||||
dir := filepath.Dir(fullPath)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
@@ -161,7 +141,6 @@ func (r *ExtensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
|
||||
})
|
||||
}
|
||||
|
||||
// Create HTTP request
|
||||
req, err := http.NewRequest("GET", urlStr, nil)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
@@ -170,7 +149,6 @@ func (r *ExtensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
|
||||
})
|
||||
}
|
||||
|
||||
// Set headers
|
||||
for k, v := range headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
@@ -178,7 +156,6 @@ func (r *ExtensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
|
||||
req.Header.Set("User-Agent", "SpotiFLAC-Extension/1.0")
|
||||
}
|
||||
|
||||
// Download file
|
||||
resp, err := r.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
@@ -195,7 +172,6 @@ func (r *ExtensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
|
||||
})
|
||||
}
|
||||
|
||||
// Create output file
|
||||
out, err := os.Create(fullPath)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
@@ -205,12 +181,10 @@ func (r *ExtensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
|
||||
}
|
||||
defer out.Close()
|
||||
|
||||
// Get content length for progress
|
||||
contentLength := resp.ContentLength
|
||||
|
||||
// Copy content with progress reporting
|
||||
var written int64
|
||||
buf := make([]byte, 32*1024) // 32KB buffer
|
||||
buf := make([]byte, 32*1024)
|
||||
for {
|
||||
nr, er := resp.Body.Read(buf)
|
||||
if nr > 0 {
|
||||
@@ -235,7 +209,6 @@ func (r *ExtensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
|
||||
})
|
||||
}
|
||||
|
||||
// Report progress
|
||||
if onProgress != nil && contentLength > 0 {
|
||||
_, _ = onProgress(goja.Undefined(), r.vm.ToValue(written), r.vm.ToValue(contentLength))
|
||||
}
|
||||
@@ -260,7 +233,6 @@ func (r *ExtensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
|
||||
})
|
||||
}
|
||||
|
||||
// fileExists checks if a file exists in the sandbox
|
||||
func (r *ExtensionRuntime) fileExists(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue(false)
|
||||
@@ -276,7 +248,6 @@ func (r *ExtensionRuntime) fileExists(call goja.FunctionCall) goja.Value {
|
||||
return r.vm.ToValue(err == nil)
|
||||
}
|
||||
|
||||
// fileDelete deletes a file in the sandbox
|
||||
func (r *ExtensionRuntime) fileDelete(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
@@ -306,7 +277,6 @@ func (r *ExtensionRuntime) fileDelete(call goja.FunctionCall) goja.Value {
|
||||
})
|
||||
}
|
||||
|
||||
// fileRead reads a file from the sandbox
|
||||
func (r *ExtensionRuntime) fileRead(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
@@ -338,7 +308,6 @@ func (r *ExtensionRuntime) fileRead(call goja.FunctionCall) goja.Value {
|
||||
})
|
||||
}
|
||||
|
||||
// fileWrite writes data to a file in the sandbox
|
||||
func (r *ExtensionRuntime) fileWrite(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 2 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
@@ -380,7 +349,6 @@ func (r *ExtensionRuntime) fileWrite(call goja.FunctionCall) goja.Value {
|
||||
})
|
||||
}
|
||||
|
||||
// fileCopy copies a file within the sandbox
|
||||
func (r *ExtensionRuntime) fileCopy(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 2 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
@@ -408,7 +376,6 @@ func (r *ExtensionRuntime) fileCopy(call goja.FunctionCall) goja.Value {
|
||||
})
|
||||
}
|
||||
|
||||
// Read source file
|
||||
data, err := os.ReadFile(fullSrc)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
@@ -417,7 +384,6 @@ func (r *ExtensionRuntime) fileCopy(call goja.FunctionCall) goja.Value {
|
||||
})
|
||||
}
|
||||
|
||||
// Create destination directory if needed
|
||||
dir := filepath.Dir(fullDst)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
@@ -426,7 +392,6 @@ func (r *ExtensionRuntime) fileCopy(call goja.FunctionCall) goja.Value {
|
||||
})
|
||||
}
|
||||
|
||||
// Write to destination
|
||||
if err := os.WriteFile(fullDst, data, 0644); err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
@@ -440,7 +405,6 @@ func (r *ExtensionRuntime) fileCopy(call goja.FunctionCall) goja.Value {
|
||||
})
|
||||
}
|
||||
|
||||
// fileMove moves/renames a file within the sandbox
|
||||
func (r *ExtensionRuntime) fileMove(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 2 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
@@ -468,7 +432,6 @@ func (r *ExtensionRuntime) fileMove(call goja.FunctionCall) goja.Value {
|
||||
})
|
||||
}
|
||||
|
||||
// Create destination directory if needed
|
||||
dir := filepath.Dir(fullDst)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
@@ -490,7 +453,6 @@ func (r *ExtensionRuntime) fileMove(call goja.FunctionCall) goja.Value {
|
||||
})
|
||||
}
|
||||
|
||||
// fileGetSize returns the size of a file in bytes
|
||||
func (r *ExtensionRuntime) fileGetSize(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
|
||||
@@ -52,7 +52,6 @@ func (r *ExtensionRuntime) httpGet(call goja.FunctionCall) goja.Value {
|
||||
|
||||
urlStr := call.Arguments[0].String()
|
||||
|
||||
// Validate domain
|
||||
if err := r.validateDomain(urlStr); err != nil {
|
||||
GoLog("[Extension:%s] HTTP blocked: %v\n", r.extensionID, err)
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
@@ -60,7 +59,6 @@ func (r *ExtensionRuntime) httpGet(call goja.FunctionCall) goja.Value {
|
||||
})
|
||||
}
|
||||
|
||||
// Get headers if provided
|
||||
headers := make(map[string]string)
|
||||
if len(call.Arguments) > 1 && !goja.IsUndefined(call.Arguments[1]) && !goja.IsNull(call.Arguments[1]) {
|
||||
headersObj := call.Arguments[1].Export()
|
||||
@@ -71,7 +69,6 @@ func (r *ExtensionRuntime) httpGet(call goja.FunctionCall) goja.Value {
|
||||
}
|
||||
}
|
||||
|
||||
// Create request
|
||||
req, err := http.NewRequest("GET", urlStr, nil)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
@@ -97,7 +94,6 @@ func (r *ExtensionRuntime) httpGet(call goja.FunctionCall) goja.Value {
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Read body
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
@@ -134,7 +130,6 @@ func (r *ExtensionRuntime) httpPost(call goja.FunctionCall) goja.Value {
|
||||
|
||||
urlStr := call.Arguments[0].String()
|
||||
|
||||
// Validate domain
|
||||
if err := r.validateDomain(urlStr); err != nil {
|
||||
GoLog("[Extension:%s] HTTP blocked: %v\n", r.extensionID, err)
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
@@ -175,7 +170,6 @@ func (r *ExtensionRuntime) httpPost(call goja.FunctionCall) goja.Value {
|
||||
}
|
||||
}
|
||||
|
||||
// Create request
|
||||
req, err := http.NewRequest("POST", urlStr, strings.NewReader(bodyStr))
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
@@ -204,7 +198,6 @@ func (r *ExtensionRuntime) httpPost(call goja.FunctionCall) goja.Value {
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Read body
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
@@ -231,8 +224,6 @@ func (r *ExtensionRuntime) httpPost(call goja.FunctionCall) goja.Value {
|
||||
})
|
||||
}
|
||||
|
||||
// httpRequest performs a generic HTTP request (GET, POST, PUT, DELETE, etc.)
|
||||
// Usage: http.request(url, options) where options = { method, body, headers }
|
||||
func (r *ExtensionRuntime) httpRequest(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
@@ -242,7 +233,6 @@ func (r *ExtensionRuntime) httpRequest(call goja.FunctionCall) goja.Value {
|
||||
|
||||
urlStr := call.Arguments[0].String()
|
||||
|
||||
// Validate domain
|
||||
if err := r.validateDomain(urlStr); err != nil {
|
||||
GoLog("[Extension:%s] HTTP blocked: %v\n", r.extensionID, err)
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
@@ -326,7 +316,6 @@ func (r *ExtensionRuntime) httpRequest(call goja.FunctionCall) goja.Value {
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Read body
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
@@ -354,7 +343,6 @@ func (r *ExtensionRuntime) httpRequest(call goja.FunctionCall) goja.Value {
|
||||
})
|
||||
}
|
||||
|
||||
// httpPut performs a PUT request (shortcut for http.request with method: "PUT")
|
||||
func (r *ExtensionRuntime) httpPut(call goja.FunctionCall) goja.Value {
|
||||
return r.httpMethodShortcut("PUT", call)
|
||||
}
|
||||
@@ -364,7 +352,6 @@ func (r *ExtensionRuntime) httpDelete(call goja.FunctionCall) goja.Value {
|
||||
return r.httpMethodShortcut("DELETE", call)
|
||||
}
|
||||
|
||||
// httpPatch performs a PATCH request (shortcut for http.request with method: "PATCH")
|
||||
func (r *ExtensionRuntime) httpPatch(call goja.FunctionCall) goja.Value {
|
||||
return r.httpMethodShortcut("PATCH", call)
|
||||
}
|
||||
@@ -380,7 +367,6 @@ func (r *ExtensionRuntime) httpMethodShortcut(method string, call goja.FunctionC
|
||||
|
||||
urlStr := call.Arguments[0].String()
|
||||
|
||||
// Validate domain
|
||||
if err := r.validateDomain(urlStr); err != nil {
|
||||
GoLog("[Extension:%s] HTTP blocked: %v\n", r.extensionID, err)
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
@@ -465,7 +451,6 @@ func (r *ExtensionRuntime) httpMethodShortcut(method string, call goja.FunctionC
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Read body
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
@@ -492,7 +477,6 @@ func (r *ExtensionRuntime) httpMethodShortcut(method string, call goja.FunctionC
|
||||
})
|
||||
}
|
||||
|
||||
// httpClearCookies clears all cookies for this extension
|
||||
func (r *ExtensionRuntime) httpClearCookies(call goja.FunctionCall) goja.Value {
|
||||
if jar, ok := r.cookieJar.(*simpleCookieJar); ok {
|
||||
jar.mu.Lock()
|
||||
|
||||
@@ -143,19 +143,16 @@ func (r *ExtensionRuntime) getSaltPath() string {
|
||||
func (r *ExtensionRuntime) getOrCreateSalt() ([]byte, error) {
|
||||
saltPath := r.getSaltPath()
|
||||
|
||||
// Try to read existing salt
|
||||
salt, err := os.ReadFile(saltPath)
|
||||
if err == nil && len(salt) == 32 {
|
||||
return salt, nil
|
||||
}
|
||||
|
||||
// Generate new random salt (32 bytes)
|
||||
salt = make([]byte, 32)
|
||||
if _, err := io.ReadFull(rand.Reader, salt); err != nil {
|
||||
return nil, fmt.Errorf("failed to generate salt: %w", err)
|
||||
}
|
||||
|
||||
// Save salt to file
|
||||
if err := os.WriteFile(saltPath, salt, 0600); err != nil {
|
||||
return nil, fmt.Errorf("failed to save salt: %w", err)
|
||||
}
|
||||
@@ -214,7 +211,6 @@ func (r *ExtensionRuntime) saveCredentials(creds map[string]interface{}) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// Encrypt the data
|
||||
key, err := r.getEncryptionKey()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get encryption key: %w", err)
|
||||
|
||||
@@ -94,7 +94,6 @@ func (r *ExtensionRuntime) hmacSHA1(call goja.FunctionCall) goja.Value {
|
||||
return r.vm.ToValue([]byte{})
|
||||
}
|
||||
|
||||
// Get key - can be string or array of bytes
|
||||
var keyBytes []byte
|
||||
keyArg := call.Arguments[0].Export()
|
||||
switch k := keyArg.(type) {
|
||||
@@ -113,7 +112,6 @@ func (r *ExtensionRuntime) hmacSHA1(call goja.FunctionCall) goja.Value {
|
||||
return r.vm.ToValue([]byte{})
|
||||
}
|
||||
|
||||
// Get message - can be string or array of bytes
|
||||
var msgBytes []byte
|
||||
msgArg := call.Arguments[1].Export()
|
||||
switch m := msgArg.(type) {
|
||||
@@ -136,7 +134,6 @@ func (r *ExtensionRuntime) hmacSHA1(call goja.FunctionCall) goja.Value {
|
||||
mac.Write(msgBytes)
|
||||
result := mac.Sum(nil)
|
||||
|
||||
// Convert to array of numbers for JavaScript
|
||||
jsArray := make([]interface{}, len(result))
|
||||
for i, b := range result {
|
||||
jsArray[i] = int(b)
|
||||
|
||||
@@ -42,7 +42,6 @@ func (s *ExtensionSettingsStore) SetDataDir(dataDir string) error {
|
||||
return fmt.Errorf("failed to create settings directory: %w", err)
|
||||
}
|
||||
|
||||
// Load all existing settings
|
||||
return s.loadAllSettings()
|
||||
}
|
||||
|
||||
@@ -99,7 +98,6 @@ func (s *ExtensionSettingsStore) loadSettings(extensionID string) (map[string]in
|
||||
func (s *ExtensionSettingsStore) saveSettings(extensionID string, settings map[string]interface{}) error {
|
||||
settingsPath := s.getSettingsPath(extensionID)
|
||||
|
||||
// Create directory if needed
|
||||
dir := filepath.Dir(settingsPath)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return err
|
||||
@@ -160,7 +158,6 @@ func (s *ExtensionSettingsStore) Set(extensionID, key string, value interface{})
|
||||
|
||||
s.settings[extensionID][key] = value
|
||||
|
||||
// Persist to disk
|
||||
return s.saveSettings(extensionID, s.settings[extensionID])
|
||||
}
|
||||
|
||||
@@ -198,7 +195,6 @@ func (s *ExtensionSettingsStore) RemoveAll(extensionID string) error {
|
||||
|
||||
delete(s.settings, extensionID)
|
||||
|
||||
// Remove settings file
|
||||
settingsPath := s.getSettingsPath(extensionID)
|
||||
if err := os.Remove(settingsPath); err != nil && !os.IsNotExist(err) {
|
||||
return err
|
||||
|
||||
@@ -35,7 +35,6 @@ type StoreExtension struct {
|
||||
Downloads int `json:"downloads"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
MinAppVersion string `json:"min_app_version,omitempty"`
|
||||
// Alternative camelCase fields (for flexibility)
|
||||
DisplayNameAlt string `json:"displayName,omitempty"`
|
||||
DownloadURLAlt string `json:"downloadUrl,omitempty"`
|
||||
IconURLAlt string `json:"iconUrl,omitempty"`
|
||||
@@ -332,7 +331,6 @@ func (s *ExtensionStore) DownloadExtension(extensionID string, destPath string)
|
||||
return fmt.Errorf("download returned HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// Create destination file
|
||||
out, err := os.Create(destPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create file: %w", err)
|
||||
|
||||
@@ -6,10 +6,8 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Invalid filename characters for Android/Windows/Linux
|
||||
var invalidChars = regexp.MustCompile(`[<>:"/\\|?*\x00-\x1f]`)
|
||||
|
||||
// sanitizeFilename removes invalid characters from filename
|
||||
func sanitizeFilename(filename string) string {
|
||||
sanitized := invalidChars.ReplaceAllString(filename, "_")
|
||||
|
||||
@@ -30,7 +28,6 @@ func sanitizeFilename(filename string) string {
|
||||
return sanitized
|
||||
}
|
||||
|
||||
// buildFilenameFromTemplate builds a filename from template and metadata
|
||||
func buildFilenameFromTemplate(template string, metadata map[string]interface{}) string {
|
||||
if template == "" {
|
||||
template = "{artist} - {title}"
|
||||
@@ -91,7 +88,6 @@ func formatDiscNumber(n int) string {
|
||||
return fmt.Sprintf("%d", n)
|
||||
}
|
||||
|
||||
// extractYear extracts year from date string (YYYY-MM-DD or YYYY)
|
||||
func extractYear(date string) string {
|
||||
if len(date) >= 4 {
|
||||
return date[:4]
|
||||
|
||||
@@ -15,8 +15,6 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// HTTP utility functions for consistent request handling across all downloaders
|
||||
|
||||
// getRandomUserAgent generates a random Windows Chrome User-Agent string
|
||||
// Uses modern Chrome format with build and patch numbers
|
||||
// Windows 11 still reports as "Windows NT 10.0" for compatibility
|
||||
@@ -34,41 +32,6 @@ func getRandomUserAgent() string {
|
||||
)
|
||||
}
|
||||
|
||||
// getRandomMacUserAgent generates a random Mac Chrome User-Agent string
|
||||
// Alternative format matching referensi/backend/spotify_metadata.go exactly
|
||||
// func getRandomMacUserAgent() string {
|
||||
// macMajor := rand.Intn(4) + 11 // macOS 11-14
|
||||
// macMinor := rand.Intn(5) + 4 // Minor 4-8
|
||||
// webkitMajor := rand.Intn(7) + 530
|
||||
// webkitMinor := rand.Intn(7) + 30
|
||||
// chromeMajor := rand.Intn(25) + 80
|
||||
// chromeBuild := rand.Intn(1500) + 3000
|
||||
// chromePatch := rand.Intn(65) + 60
|
||||
// safariMajor := rand.Intn(7) + 530
|
||||
// safariMinor := rand.Intn(6) + 30
|
||||
//
|
||||
// return fmt.Sprintf(
|
||||
// "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_%d_%d) AppleWebKit/%d.%d (KHTML, like Gecko) Chrome/%d.0.%d.%d Safari/%d.%d",
|
||||
// macMajor,
|
||||
// macMinor,
|
||||
// webkitMajor,
|
||||
// webkitMinor,
|
||||
// chromeMajor,
|
||||
// chromeBuild,
|
||||
// chromePatch,
|
||||
// safariMajor,
|
||||
// safariMinor,
|
||||
// )
|
||||
// }
|
||||
|
||||
// getRandomDesktopUserAgent randomly picks between Windows and Mac User-Agent
|
||||
// func getRandomDesktopUserAgent() string {
|
||||
// if rand.Intn(2) == 0 {
|
||||
// return getRandomUserAgent() // Windows
|
||||
// }
|
||||
// return getRandomMacUserAgent() // Mac
|
||||
// }
|
||||
|
||||
const (
|
||||
DefaultTimeout = 60 * time.Second
|
||||
DownloadTimeout = 120 * time.Second
|
||||
@@ -106,7 +69,6 @@ var downloadClient = &http.Client{
|
||||
Timeout: DownloadTimeout,
|
||||
}
|
||||
|
||||
// NewHTTPClientWithTimeout creates an HTTP client with specified timeout
|
||||
func NewHTTPClientWithTimeout(timeout time.Duration) *http.Client {
|
||||
return &http.Client{
|
||||
Transport: sharedTransport,
|
||||
@@ -127,7 +89,6 @@ func CloseIdleConnections() {
|
||||
sharedTransport.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())
|
||||
@@ -146,7 +107,6 @@ type RetryConfig struct {
|
||||
BackoffFactor float64
|
||||
}
|
||||
|
||||
// DefaultRetryConfig returns default retry configuration
|
||||
func DefaultRetryConfig() RetryConfig {
|
||||
return RetryConfig{
|
||||
MaxRetries: DefaultMaxRetries,
|
||||
@@ -252,13 +212,11 @@ func DoRequestWithRetry(client *http.Client, req *http.Request, config RetryConf
|
||||
return nil, fmt.Errorf("request failed after %d retries: %w", config.MaxRetries+1, lastErr)
|
||||
}
|
||||
|
||||
// calculateNextDelay calculates the next delay with exponential backoff
|
||||
func calculateNextDelay(currentDelay time.Duration, config RetryConfig) time.Duration {
|
||||
nextDelay := time.Duration(float64(currentDelay) * config.BackoffFactor)
|
||||
return min(nextDelay, config.MaxDelay)
|
||||
}
|
||||
|
||||
// getRetryAfterDuration parses Retry-After header and returns duration
|
||||
// Returns 60 seconds as default if header is missing or invalid
|
||||
func getRetryAfterDuration(resp *http.Response) time.Duration {
|
||||
retryAfter := resp.Header.Get("Retry-After")
|
||||
@@ -301,7 +259,6 @@ func ReadResponseBody(resp *http.Response) ([]byte, error) {
|
||||
return body, nil
|
||||
}
|
||||
|
||||
// ValidateResponse checks if response is valid (non-nil, status 2xx)
|
||||
func ValidateResponse(resp *http.Response) error {
|
||||
if resp == nil {
|
||||
return fmt.Errorf("response is nil")
|
||||
@@ -330,7 +287,6 @@ 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
|
||||
@@ -446,7 +402,6 @@ func IsISPBlocking(err error, requestURL string) *ISPBlockingError {
|
||||
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)
|
||||
@@ -484,7 +439,6 @@ func extractDomain(rawURL string) string {
|
||||
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 {
|
||||
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// LogEntry represents a single log entry
|
||||
type LogEntry struct {
|
||||
Timestamp string `json:"timestamp"`
|
||||
Level string `json:"level"`
|
||||
@@ -16,7 +15,6 @@ type LogEntry struct {
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// LogBuffer stores logs in a circular buffer for retrieval by Flutter
|
||||
type LogBuffer struct {
|
||||
entries []LogEntry
|
||||
maxSize int
|
||||
@@ -41,7 +39,6 @@ func GetLogBuffer() *LogBuffer {
|
||||
return globalLogBuffer
|
||||
}
|
||||
|
||||
// SetLoggingEnabled enables or disables logging
|
||||
func (lb *LogBuffer) SetLoggingEnabled(enabled bool) {
|
||||
lb.mu.Lock()
|
||||
defer lb.mu.Unlock()
|
||||
@@ -55,7 +52,6 @@ func (lb *LogBuffer) IsLoggingEnabled() bool {
|
||||
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()
|
||||
|
||||
+2
-33
@@ -15,13 +15,9 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// ========================================
|
||||
// Lyrics Cache with TTL
|
||||
// ========================================
|
||||
|
||||
const (
|
||||
lyricsCacheTTL = 24 * time.Hour // Cache lyrics for 24 hours
|
||||
durationToleranceSec = 10.0 // Duration matching tolerance in seconds
|
||||
lyricsCacheTTL = 24 * time.Hour
|
||||
durationToleranceSec = 10.0
|
||||
)
|
||||
|
||||
type lyricsCacheEntry struct {
|
||||
@@ -39,10 +35,8 @@ var globalLyricsCache = &lyricsCache{
|
||||
}
|
||||
|
||||
func (c *lyricsCache) generateKey(artist, track string, durationSec float64) string {
|
||||
// Normalize key: lowercase, trim spaces
|
||||
normalizedArtist := strings.ToLower(strings.TrimSpace(artist))
|
||||
normalizedTrack := strings.ToLower(strings.TrimSpace(track))
|
||||
// Round duration to nearest 10 seconds for cache key
|
||||
roundedDuration := math.Round(durationSec/10) * 10
|
||||
return fmt.Sprintf("%s|%s|%.0f", normalizedArtist, normalizedTrack, roundedDuration)
|
||||
}
|
||||
@@ -57,7 +51,6 @@ func (c *lyricsCache) Get(artist, track string, durationSec float64) (*LyricsRes
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// Check if expired
|
||||
if time.Now().After(entry.expiresAt) {
|
||||
return nil, false
|
||||
}
|
||||
@@ -76,7 +69,6 @@ func (c *lyricsCache) Set(artist, track string, durationSec float64, response *L
|
||||
}
|
||||
}
|
||||
|
||||
// CleanExpired removes expired entries from cache
|
||||
func (c *lyricsCache) CleanExpired() int {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
@@ -92,7 +84,6 @@ func (c *lyricsCache) CleanExpired() int {
|
||||
return cleaned
|
||||
}
|
||||
|
||||
// Size returns current cache size
|
||||
func (c *lyricsCache) Size() int {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
@@ -174,8 +165,6 @@ func (c *LyricsClient) FetchLyricsWithMetadata(artist, track string) (*LyricsRes
|
||||
return c.parseLRCLibResponse(&lrcResp), nil
|
||||
}
|
||||
|
||||
// FetchLyricsFromLRCLibSearch searches lyrics with optional duration matching
|
||||
// durationSec: track duration in seconds, use 0 to skip duration matching
|
||||
func (c *LyricsClient) FetchLyricsFromLRCLibSearch(query string, durationSec float64) (*LyricsResponse, error) {
|
||||
baseURL := "https://lrclib.net/api/search"
|
||||
params := url.Values{}
|
||||
@@ -208,13 +197,11 @@ func (c *LyricsClient) FetchLyricsFromLRCLibSearch(query string, durationSec flo
|
||||
return nil, fmt.Errorf("no lyrics found")
|
||||
}
|
||||
|
||||
// Filter and score results based on duration matching and synced lyrics
|
||||
bestMatch := c.findBestMatch(results, durationSec)
|
||||
if bestMatch != nil {
|
||||
return c.parseLRCLibResponse(bestMatch), nil
|
||||
}
|
||||
|
||||
// Fallback: return first result with synced lyrics
|
||||
for _, result := range results {
|
||||
if result.SyncedLyrics != "" {
|
||||
return c.parseLRCLibResponse(&result), nil
|
||||
@@ -224,7 +211,6 @@ func (c *LyricsClient) FetchLyricsFromLRCLibSearch(query string, durationSec flo
|
||||
return c.parseLRCLibResponse(&results[0]), nil
|
||||
}
|
||||
|
||||
// findBestMatch finds the best matching lyrics based on duration and sync status
|
||||
func (c *LyricsClient) findBestMatch(results []LRCLibResponse, targetDurationSec float64) *LRCLibResponse {
|
||||
var bestSynced *LRCLibResponse
|
||||
var bestPlain *LRCLibResponse
|
||||
@@ -232,11 +218,9 @@ func (c *LyricsClient) findBestMatch(results []LRCLibResponse, targetDurationSec
|
||||
for i := range results {
|
||||
result := &results[i]
|
||||
|
||||
// Check duration match if target duration is provided
|
||||
durationMatches := targetDurationSec == 0 || c.durationMatches(result.Duration, targetDurationSec)
|
||||
|
||||
if durationMatches {
|
||||
// Prefer synced lyrics over plain
|
||||
if result.SyncedLyrics != "" && bestSynced == nil {
|
||||
bestSynced = result
|
||||
} else if result.PlainLyrics != "" && bestPlain == nil {
|
||||
@@ -245,20 +229,17 @@ func (c *LyricsClient) findBestMatch(results []LRCLibResponse, targetDurationSec
|
||||
}
|
||||
}
|
||||
|
||||
// Return synced first, then plain
|
||||
if bestSynced != nil {
|
||||
return bestSynced
|
||||
}
|
||||
return bestPlain
|
||||
}
|
||||
|
||||
// durationMatches checks if two durations are within tolerance
|
||||
func (c *LyricsClient) durationMatches(lrcDuration, targetDuration float64) bool {
|
||||
diff := math.Abs(lrcDuration - targetDuration)
|
||||
return diff <= durationToleranceSec
|
||||
}
|
||||
|
||||
// FetchLyricsAllSources fetches lyrics from multiple sources with caching and duration matching
|
||||
// durationSec: track duration in seconds for matching, use 0 to skip duration matching
|
||||
func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName string, durationSec float64) (*LyricsResponse, error) {
|
||||
// Check cache first
|
||||
@@ -396,7 +377,6 @@ func msToLRCTimestamp(ms int64) string {
|
||||
return fmt.Sprintf("[%02d:%02d.%02d]", minutes, seconds, centiseconds)
|
||||
}
|
||||
|
||||
// convertToLRC converts lyrics to LRC format string (without metadata headers)
|
||||
// Use convertToLRCWithMetadata for full LRC with headers
|
||||
// Kept for potential future use
|
||||
// func convertToLRC(lyrics *LyricsResponse) string {
|
||||
@@ -423,8 +403,6 @@ func msToLRCTimestamp(ms int64) string {
|
||||
// return builder.String()
|
||||
// }
|
||||
|
||||
// convertToLRCWithMetadata converts lyrics to LRC format with metadata headers
|
||||
// Includes [ti:], [ar:], [by:] headers
|
||||
func convertToLRCWithMetadata(lyrics *LyricsResponse, trackName, artistName string) string {
|
||||
if lyrics == nil || len(lyrics.Lines) == 0 {
|
||||
return ""
|
||||
@@ -432,13 +410,11 @@ func convertToLRCWithMetadata(lyrics *LyricsResponse, trackName, artistName stri
|
||||
|
||||
var builder strings.Builder
|
||||
|
||||
// Add metadata headers
|
||||
builder.WriteString(fmt.Sprintf("[ti:%s]\n", trackName))
|
||||
builder.WriteString(fmt.Sprintf("[ar:%s]\n", artistName))
|
||||
builder.WriteString("[by:SpotiFLAC-Mobile]\n")
|
||||
builder.WriteString("\n")
|
||||
|
||||
// Add lyrics lines
|
||||
if lyrics.SyncType == "LINE_SYNCED" {
|
||||
for _, line := range lyrics.Lines {
|
||||
if line.Words == "" {
|
||||
@@ -488,24 +464,17 @@ func simplifyTrackName(name string) string {
|
||||
return strings.TrimSpace(result)
|
||||
}
|
||||
|
||||
// SaveLRCFile saves lyrics as a .lrc file next to the audio file
|
||||
// audioFilePath: path to the audio file (e.g., /path/to/song.flac)
|
||||
// lrcContent: the LRC format lyrics content
|
||||
// Returns the path to the saved .lrc file, or error
|
||||
func SaveLRCFile(audioFilePath, lrcContent string) (string, error) {
|
||||
if lrcContent == "" {
|
||||
return "", fmt.Errorf("empty LRC content")
|
||||
}
|
||||
|
||||
// Get the directory and base name without extension
|
||||
dir := filepath.Dir(audioFilePath)
|
||||
ext := filepath.Ext(audioFilePath)
|
||||
baseName := strings.TrimSuffix(filepath.Base(audioFilePath), ext)
|
||||
|
||||
// Create the .lrc file path
|
||||
lrcFilePath := filepath.Join(dir, baseName+".lrc")
|
||||
|
||||
// Write the LRC content to the file
|
||||
if err := os.WriteFile(lrcFilePath, []byte(lrcContent), 0644); err != nil {
|
||||
return "", fmt.Errorf("failed to write LRC file: %w", err)
|
||||
}
|
||||
|
||||
+8
-23
@@ -11,7 +11,6 @@ import (
|
||||
"github.com/go-flac/go-flac"
|
||||
)
|
||||
|
||||
// Metadata represents track metadata for embedding
|
||||
type Metadata struct {
|
||||
Title string
|
||||
Artist string
|
||||
@@ -24,12 +23,11 @@ type Metadata struct {
|
||||
ISRC string
|
||||
Description string
|
||||
Lyrics string
|
||||
Genre string // Music genre (e.g., "Rock", "Pop", "Electronic")
|
||||
Label string // Record label (ORGANIZATION tag in Vorbis)
|
||||
Copyright string // Copyright information
|
||||
Genre string
|
||||
Label string
|
||||
Copyright string
|
||||
}
|
||||
|
||||
// EmbedMetadata embeds metadata into a FLAC file
|
||||
func EmbedMetadata(filePath string, metadata Metadata, coverPath string) error {
|
||||
f, err := flac.ParseFile(filePath)
|
||||
if err != nil {
|
||||
@@ -138,8 +136,6 @@ func EmbedMetadata(filePath string, metadata Metadata, coverPath string) error {
|
||||
return f.Save(filePath)
|
||||
}
|
||||
|
||||
// EmbedMetadataWithCoverData embeds metadata into a FLAC file with cover data as bytes
|
||||
// This avoids file permission issues on Android by not requiring a temp file
|
||||
func EmbedMetadataWithCoverData(filePath string, metadata Metadata, coverData []byte) error {
|
||||
f, err := flac.ParseFile(filePath)
|
||||
if err != nil {
|
||||
@@ -337,7 +333,6 @@ func fileExists(path string) bool {
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// EmbedLyrics embeds lyrics into a FLAC file as a separate operation
|
||||
func EmbedLyrics(filePath string, lyrics string) error {
|
||||
f, err := flac.ParseFile(filePath)
|
||||
if err != nil {
|
||||
@@ -375,11 +370,9 @@ func EmbedLyrics(filePath string, lyrics string) error {
|
||||
return f.Save(filePath)
|
||||
}
|
||||
|
||||
// EmbedGenreLabel embeds genre and label into a FLAC file as a separate operation
|
||||
// This is used for extension downloads where the file is already downloaded
|
||||
func EmbedGenreLabel(filePath string, genre, label string) error {
|
||||
if genre == "" && label == "" {
|
||||
return nil // Nothing to embed
|
||||
return nil
|
||||
}
|
||||
|
||||
f, err := flac.ParseFile(filePath)
|
||||
@@ -451,16 +444,12 @@ func ExtractLyrics(filePath string) (string, error) {
|
||||
return "", fmt.Errorf("no lyrics found in file")
|
||||
}
|
||||
|
||||
// AudioQuality represents audio quality info from a FLAC file
|
||||
type AudioQuality struct {
|
||||
BitDepth int `json:"bit_depth"`
|
||||
SampleRate int `json:"sample_rate"`
|
||||
TotalSamples int64 `json:"total_samples"`
|
||||
}
|
||||
|
||||
// GetAudioQuality reads bit depth and sample rate from a FLAC file's StreamInfo block
|
||||
// FLAC StreamInfo is always the first metadata block after the 4-byte "fLaC" marker
|
||||
// For M4A files, it delegates to GetM4AQuality
|
||||
func GetAudioQuality(filePath string) (AudioQuality, error) {
|
||||
file, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
@@ -597,7 +586,6 @@ func EmbedM4AMetadata(filePath string, metadata Metadata, coverData []byte) erro
|
||||
return nil
|
||||
}
|
||||
|
||||
// findAtom finds an atom by name starting from offset
|
||||
func findAtom(data []byte, name string, offset int) int {
|
||||
for i := offset; i < len(data)-8; {
|
||||
size := int(uint32(data[i])<<24 | uint32(data[i+1])<<16 | uint32(data[i+2])<<8 | uint32(data[i+3]))
|
||||
@@ -689,7 +677,6 @@ func buildMetaAtom(metadata Metadata, coverData []byte) []byte {
|
||||
return metaAtom
|
||||
}
|
||||
|
||||
// buildTextAtom builds a text metadata atom (©nam, ©ART, etc.)
|
||||
func buildTextAtom(name, value string) []byte {
|
||||
valueBytes := []byte(value)
|
||||
|
||||
@@ -741,7 +728,6 @@ func buildTrackNumberAtom(track, total int) []byte {
|
||||
return atom
|
||||
}
|
||||
|
||||
// buildDiscNumberAtom builds disk atom
|
||||
func buildDiscNumberAtom(disc, total int) []byte {
|
||||
dataAtom := []byte{
|
||||
0, 0, 0, 22, // size
|
||||
@@ -767,9 +753,9 @@ func buildDiscNumberAtom(disc, total int) []byte {
|
||||
|
||||
// buildCoverAtom builds covr atom with image data
|
||||
func buildCoverAtom(coverData []byte) []byte {
|
||||
imageType := byte(13) // default JPEG
|
||||
imageType := byte(13)
|
||||
if len(coverData) > 8 && coverData[0] == 0x89 && coverData[1] == 'P' && coverData[2] == 'N' && coverData[3] == 'G' {
|
||||
imageType = 14 // PNG
|
||||
imageType = 14
|
||||
}
|
||||
|
||||
dataSize := 16 + len(coverData)
|
||||
@@ -779,8 +765,8 @@ func buildCoverAtom(coverData []byte) []byte {
|
||||
dataAtom[2] = byte(dataSize >> 8)
|
||||
dataAtom[3] = byte(dataSize)
|
||||
dataAtom = append(dataAtom, []byte("data")...)
|
||||
dataAtom = append(dataAtom, 0, 0, 0, imageType) // type = JPEG or PNG
|
||||
dataAtom = append(dataAtom, 0, 0, 0, 0) // locale
|
||||
dataAtom = append(dataAtom, 0, 0, 0, imageType)
|
||||
dataAtom = append(dataAtom, 0, 0, 0, 0)
|
||||
dataAtom = append(dataAtom, coverData...)
|
||||
|
||||
atomSize := 8 + len(dataAtom)
|
||||
@@ -795,7 +781,6 @@ func buildCoverAtom(coverData []byte) []byte {
|
||||
return atom
|
||||
}
|
||||
|
||||
// GetM4AQuality reads audio quality from M4A file
|
||||
func GetM4AQuality(filePath string) (AudioQuality, error) {
|
||||
data, err := os.ReadFile(filePath)
|
||||
if err != nil {
|
||||
|
||||
+2
-38
@@ -6,11 +6,6 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// ========================================
|
||||
// ISRC to Track ID Cache
|
||||
// ========================================
|
||||
|
||||
// TrackIDCacheEntry holds cached track ID with metadata
|
||||
type TrackIDCacheEntry struct {
|
||||
TidalTrackID int64
|
||||
QobuzTrackID int64
|
||||
@@ -18,7 +13,6 @@ type TrackIDCacheEntry struct {
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
// TrackIDCache caches ISRC to track ID mappings
|
||||
type TrackIDCache struct {
|
||||
cache map[string]*TrackIDCacheEntry
|
||||
mu sync.RWMutex
|
||||
@@ -30,7 +24,6 @@ var (
|
||||
trackIDCacheOnce sync.Once
|
||||
)
|
||||
|
||||
// GetTrackIDCache returns the global track ID cache
|
||||
func GetTrackIDCache() *TrackIDCache {
|
||||
trackIDCacheOnce.Do(func() {
|
||||
globalTrackIDCache = &TrackIDCache{
|
||||
@@ -41,7 +34,6 @@ func GetTrackIDCache() *TrackIDCache {
|
||||
return globalTrackIDCache
|
||||
}
|
||||
|
||||
// Get retrieves a cached entry by ISRC
|
||||
func (c *TrackIDCache) Get(isrc string) *TrackIDCacheEntry {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
@@ -53,7 +45,6 @@ func (c *TrackIDCache) Get(isrc string) *TrackIDCacheEntry {
|
||||
return entry
|
||||
}
|
||||
|
||||
// SetTidal caches Tidal track ID for an ISRC
|
||||
func (c *TrackIDCache) SetTidal(isrc string, trackID int64) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
@@ -67,7 +58,6 @@ func (c *TrackIDCache) SetTidal(isrc string, trackID int64) {
|
||||
entry.ExpiresAt = time.Now().Add(c.ttl)
|
||||
}
|
||||
|
||||
// SetQobuz caches Qobuz track ID for an ISRC
|
||||
func (c *TrackIDCache) SetQobuz(isrc string, trackID int64) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
@@ -81,7 +71,6 @@ func (c *TrackIDCache) SetQobuz(isrc string, trackID int64) {
|
||||
entry.ExpiresAt = time.Now().Add(c.ttl)
|
||||
}
|
||||
|
||||
// SetAmazon caches Amazon track ID for an ISRC
|
||||
func (c *TrackIDCache) SetAmazon(isrc string, trackID string) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
@@ -95,24 +84,18 @@ func (c *TrackIDCache) SetAmazon(isrc string, trackID string) {
|
||||
entry.ExpiresAt = time.Now().Add(c.ttl)
|
||||
}
|
||||
|
||||
// Clear removes all cached entries
|
||||
func (c *TrackIDCache) Clear() {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.cache = make(map[string]*TrackIDCacheEntry)
|
||||
}
|
||||
|
||||
// Size returns the number of cached entries
|
||||
func (c *TrackIDCache) Size() int {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
return len(c.cache)
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Parallel Download Helper
|
||||
// ========================================
|
||||
|
||||
// ParallelDownloadResult holds results from parallel operations
|
||||
type ParallelDownloadResult struct {
|
||||
CoverData []byte
|
||||
@@ -122,9 +105,6 @@ type ParallelDownloadResult struct {
|
||||
LyricsErr error
|
||||
}
|
||||
|
||||
// FetchCoverAndLyricsParallel downloads cover and fetches lyrics in parallel
|
||||
// This runs while the main audio download is happening
|
||||
// durationMs: track duration in milliseconds for lyrics matching
|
||||
func FetchCoverAndLyricsParallel(
|
||||
coverURL string,
|
||||
maxQualityCover bool,
|
||||
@@ -153,7 +133,6 @@ func FetchCoverAndLyricsParallel(
|
||||
}()
|
||||
}
|
||||
|
||||
// Fetch lyrics in parallel
|
||||
if embedLyrics {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
@@ -180,11 +159,6 @@ func FetchCoverAndLyricsParallel(
|
||||
return result
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Pre-warm Cache for Album/Playlist
|
||||
// ========================================
|
||||
|
||||
// PreWarmCacheRequest represents a track to pre-warm cache for
|
||||
type PreWarmCacheRequest struct {
|
||||
ISRC string
|
||||
TrackName string
|
||||
@@ -193,8 +167,6 @@ type PreWarmCacheRequest struct {
|
||||
Service string // "tidal", "qobuz", "amazon"
|
||||
}
|
||||
|
||||
// PreWarmTrackCache pre-fetches track IDs for multiple tracks (for album/playlist)
|
||||
// This runs in background while user is viewing the track list
|
||||
func PreWarmTrackCache(requests []PreWarmCacheRequest) {
|
||||
if len(requests) == 0 {
|
||||
return
|
||||
@@ -214,8 +186,8 @@ func PreWarmTrackCache(requests []PreWarmCacheRequest) {
|
||||
wg.Add(1)
|
||||
go func(r PreWarmCacheRequest) {
|
||||
defer wg.Done()
|
||||
semaphore <- struct{}{} // Acquire
|
||||
defer func() { <-semaphore }() // Release
|
||||
semaphore <- struct{}{}
|
||||
defer func() { <-semaphore }()
|
||||
|
||||
switch r.Service {
|
||||
case "tidal":
|
||||
@@ -259,12 +231,6 @@ func preWarmAmazonCache(isrc, spotifyID string) {
|
||||
}
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Exported Functions for Flutter
|
||||
// ========================================
|
||||
|
||||
// PreWarmCache is called from Flutter to pre-warm cache for album/playlist tracks
|
||||
// tracksJSON is a JSON array of {isrc, track_name, artist_name, service}
|
||||
func PreWarmCache(tracksJSON string) error {
|
||||
var requests []PreWarmCacheRequest
|
||||
|
||||
@@ -272,13 +238,11 @@ func PreWarmCache(tracksJSON string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// ClearTrackCache clears the track ID cache
|
||||
func ClearTrackCache() {
|
||||
GetTrackIDCache().Clear()
|
||||
fmt.Println("[Cache] Track ID cache cleared")
|
||||
}
|
||||
|
||||
// GetCacheSize returns the current cache size
|
||||
func GetCacheSize() int {
|
||||
return GetTrackIDCache().Size()
|
||||
}
|
||||
|
||||
+4
-20
@@ -6,8 +6,6 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// DownloadProgress represents current download progress
|
||||
// Now unified - returns data from multi-progress system
|
||||
type DownloadProgress struct {
|
||||
CurrentFile string `json:"current_file"`
|
||||
Progress float64 `json:"progress"`
|
||||
@@ -15,21 +13,19 @@ type DownloadProgress struct {
|
||||
BytesTotal int64 `json:"bytes_total"`
|
||||
BytesReceived int64 `json:"bytes_received"`
|
||||
IsDownloading bool `json:"is_downloading"`
|
||||
Status string `json:"status"` // "downloading", "finalizing", "completed"
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
// ItemProgress represents progress for a single download item
|
||||
type ItemProgress struct {
|
||||
ItemID string `json:"item_id"`
|
||||
BytesTotal int64 `json:"bytes_total"`
|
||||
BytesReceived int64 `json:"bytes_received"`
|
||||
Progress float64 `json:"progress"` // 0.0 to 1.0
|
||||
SpeedMBps float64 `json:"speed_mbps"` // Download speed in MB/s
|
||||
Progress float64 `json:"progress"`
|
||||
SpeedMBps float64 `json:"speed_mbps"`
|
||||
IsDownloading bool `json:"is_downloading"`
|
||||
Status string `json:"status"` // "downloading", "finalizing", "completed"
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
// MultiProgress holds progress for multiple concurrent downloads
|
||||
type MultiProgress struct {
|
||||
Items map[string]*ItemProgress `json:"items"`
|
||||
}
|
||||
@@ -38,12 +34,10 @@ var (
|
||||
downloadDir string
|
||||
downloadDirMu sync.RWMutex
|
||||
|
||||
// Multi-download progress tracking (unified system)
|
||||
multiProgress = MultiProgress{Items: make(map[string]*ItemProgress)}
|
||||
multiMu sync.RWMutex
|
||||
)
|
||||
|
||||
// getProgress returns current download progress from multi-progress system
|
||||
func getProgress() DownloadProgress {
|
||||
multiMu.RLock()
|
||||
defer multiMu.RUnlock()
|
||||
@@ -62,7 +56,6 @@ func getProgress() DownloadProgress {
|
||||
return DownloadProgress{}
|
||||
}
|
||||
|
||||
// GetMultiProgress returns progress for all active downloads as JSON
|
||||
func GetMultiProgress() string {
|
||||
multiMu.RLock()
|
||||
defer multiMu.RUnlock()
|
||||
@@ -74,7 +67,6 @@ func GetMultiProgress() string {
|
||||
return string(jsonBytes)
|
||||
}
|
||||
|
||||
// GetItemProgress returns progress for a specific item as JSON
|
||||
func GetItemProgress(itemID string) string {
|
||||
multiMu.RLock()
|
||||
defer multiMu.RUnlock()
|
||||
@@ -201,14 +193,6 @@ func setDownloadDir(path string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// getDownloadDir returns the default download directory
|
||||
// Kept for potential future use
|
||||
// func getDownloadDir() string {
|
||||
// downloadDirMu.RLock()
|
||||
// defer downloadDirMu.RUnlock()
|
||||
// return downloadDir
|
||||
// }
|
||||
|
||||
// ItemProgressWriter wraps io.Writer to track download progress for a specific item
|
||||
type ItemProgressWriter struct {
|
||||
writer interface{ Write([]byte) (int, error) }
|
||||
|
||||
+1
-41
@@ -17,7 +17,6 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// QobuzDownloader handles Qobuz downloads
|
||||
type QobuzDownloader struct {
|
||||
client *http.Client
|
||||
appID string
|
||||
@@ -29,7 +28,6 @@ var (
|
||||
qobuzDownloaderOnce sync.Once
|
||||
)
|
||||
|
||||
// QobuzTrack represents a Qobuz track
|
||||
type QobuzTrack struct {
|
||||
ID int64 `json:"id"`
|
||||
Title string `json:"title"`
|
||||
@@ -50,7 +48,6 @@ type QobuzTrack struct {
|
||||
} `json:"performer"`
|
||||
}
|
||||
|
||||
// qobuzArtistsMatch checks if the artist names are similar enough
|
||||
func qobuzArtistsMatch(expectedArtist, foundArtist string) bool {
|
||||
normExpected := strings.ToLower(strings.TrimSpace(expectedArtist))
|
||||
normFound := strings.ToLower(strings.TrimSpace(foundArtist))
|
||||
@@ -93,9 +90,7 @@ func qobuzArtistsMatch(expectedArtist, foundArtist string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// qobuzSplitArtists splits artist string by common separators
|
||||
func qobuzSplitArtists(artists string) []string {
|
||||
// Replace common separators with a standard one
|
||||
normalized := artists
|
||||
normalized = strings.ReplaceAll(normalized, " feat. ", "|")
|
||||
normalized = strings.ReplaceAll(normalized, " feat ", "|")
|
||||
@@ -154,7 +149,6 @@ func qobuzSameWordsUnordered(a, b string) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// 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))
|
||||
@@ -164,12 +158,10 @@ func qobuzTitlesMatch(expectedTitle, foundTitle string) bool {
|
||||
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)
|
||||
|
||||
@@ -177,14 +169,12 @@ func qobuzTitlesMatch(expectedTitle, foundTitle string) bool {
|
||||
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)
|
||||
|
||||
@@ -225,19 +215,15 @@ func qobuzExtractCoreTitle(title string) string {
|
||||
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, ")")
|
||||
@@ -258,7 +244,6 @@ func qobuzCleanTitle(title string) string {
|
||||
break
|
||||
}
|
||||
|
||||
// Same for brackets
|
||||
for {
|
||||
startBracket := strings.LastIndex(cleaned, "[")
|
||||
endBracket := strings.LastIndex(cleaned, "]")
|
||||
@@ -279,7 +264,6 @@ func qobuzCleanTitle(title string) string {
|
||||
break
|
||||
}
|
||||
|
||||
// Remove trailing " - version" patterns
|
||||
dashPatterns := []string{
|
||||
" - remaster", " - remastered", " - single version", " - radio edit",
|
||||
" - live", " - acoustic", " - demo", " - remix",
|
||||
@@ -290,7 +274,6 @@ func qobuzCleanTitle(title string) string {
|
||||
}
|
||||
}
|
||||
|
||||
// Remove multiple spaces
|
||||
for strings.Contains(cleaned, " ") {
|
||||
cleaned = strings.ReplaceAll(cleaned, " ", " ")
|
||||
}
|
||||
@@ -350,7 +333,6 @@ func containsQueryQobuz(queries []string, query string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// NewQobuzDownloader creates a new Qobuz downloader (returns singleton for connection reuse)
|
||||
func NewQobuzDownloader() *QobuzDownloader {
|
||||
qobuzDownloaderOnce.Do(func() {
|
||||
globalQobuzDownloader = &QobuzDownloader{
|
||||
@@ -361,7 +343,6 @@ func NewQobuzDownloader() *QobuzDownloader {
|
||||
return globalQobuzDownloader
|
||||
}
|
||||
|
||||
// GetTrackByID fetches track info directly by Qobuz track ID
|
||||
func (q *QobuzDownloader) GetTrackByID(trackID int64) (*QobuzTrack, error) {
|
||||
// Qobuz API: /track/get?track_id=XXX
|
||||
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly93d3cucW9idXouY29tL2FwaS5qc29uLzAuMi90cmFjay9nZXQ/dHJhY2tfaWQ9")
|
||||
@@ -412,7 +393,6 @@ func (q *QobuzDownloader) GetAvailableAPIs() []string {
|
||||
return apis
|
||||
}
|
||||
|
||||
// SearchTrackByISRC searches for a track by ISRC
|
||||
func (q *QobuzDownloader) SearchTrackByISRC(isrc string) (*QobuzTrack, error) {
|
||||
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly93d3cucW9idXouY29tL2FwaS5qc29uLzAuMi90cmFjay9zZWFyY2g/cXVlcnk9")
|
||||
searchURL := fmt.Sprintf("%s%s&limit=50&app_id=%s", string(apiBase), url.QueryEscape(isrc), q.appID)
|
||||
@@ -455,7 +435,6 @@ func (q *QobuzDownloader) SearchTrackByISRC(isrc string) (*QobuzTrack, error) {
|
||||
return nil, fmt.Errorf("no exact ISRC match found for: %s", isrc)
|
||||
}
|
||||
|
||||
// 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) {
|
||||
GoLog("[Qobuz] Searching by ISRC: %s\n", isrc)
|
||||
@@ -500,7 +479,6 @@ func (q *QobuzDownloader) SearchTrackByISRCWithDuration(isrc string, expectedDur
|
||||
GoLog("[Qobuz] Found %d exact ISRC matches\n", len(isrcMatches))
|
||||
|
||||
if len(isrcMatches) > 0 {
|
||||
// Verify duration if provided
|
||||
if expectedDurationSec > 0 {
|
||||
var durationVerifiedMatches []*QobuzTrack
|
||||
for _, track := range isrcMatches {
|
||||
@@ -508,7 +486,6 @@ func (q *QobuzDownloader) SearchTrackByISRCWithDuration(isrc string, expectedDur
|
||||
if durationDiff < 0 {
|
||||
durationDiff = -durationDiff
|
||||
}
|
||||
// Allow 10 seconds tolerance
|
||||
if durationDiff <= 10 {
|
||||
durationVerifiedMatches = append(durationVerifiedMatches, track)
|
||||
}
|
||||
@@ -520,14 +497,12 @@ func (q *QobuzDownloader) SearchTrackByISRCWithDuration(isrc string, expectedDur
|
||||
return durationVerifiedMatches[0], nil
|
||||
}
|
||||
|
||||
// ISRC matches but duration doesn't
|
||||
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
|
||||
GoLog("[Qobuz] ISRC match (no duration verification): '%s'\n", isrcMatches[0].Title)
|
||||
return isrcMatches[0], nil
|
||||
}
|
||||
@@ -539,17 +514,14 @@ func (q *QobuzDownloader) SearchTrackByISRCWithDuration(isrc string, expectedDur
|
||||
return nil, fmt.Errorf("no exact ISRC match found for: %s", isrc)
|
||||
}
|
||||
|
||||
// SearchTrackByISRCWithTitle is deprecated, use SearchTrackByISRCWithDuration instead
|
||||
func (q *QobuzDownloader) SearchTrackByISRCWithTitle(isrc, expectedTitle string) (*QobuzTrack, error) {
|
||||
return q.SearchTrackByISRCWithDuration(isrc, 0)
|
||||
}
|
||||
|
||||
// SearchTrackByMetadata searches for a track using artist name and track name
|
||||
func (q *QobuzDownloader) SearchTrackByMetadata(trackName, artistName string) (*QobuzTrack, error) {
|
||||
return q.SearchTrackByMetadataWithDuration(trackName, artistName, 0)
|
||||
}
|
||||
|
||||
// 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) {
|
||||
@@ -688,7 +660,6 @@ func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistNam
|
||||
}
|
||||
|
||||
if len(durationMatches) > 0 {
|
||||
// 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",
|
||||
@@ -701,7 +672,6 @@ func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistNam
|
||||
return durationMatches[0], nil
|
||||
}
|
||||
|
||||
// No duration match found
|
||||
return nil, fmt.Errorf("no tracks found with matching title and duration (expected '%s', %ds)", trackName, expectedDurationSec)
|
||||
}
|
||||
|
||||
@@ -731,8 +701,6 @@ type qobuzAPIResult struct {
|
||||
duration time.Duration
|
||||
}
|
||||
|
||||
// getQobuzDownloadURLParallel requests download URL from all APIs in parallel
|
||||
// "Siapa cepat dia dapat" - first successful response wins
|
||||
func getQobuzDownloadURLParallel(apis []string, trackID int64, quality string) (string, string, error) {
|
||||
if len(apis) == 0 {
|
||||
return "", "", fmt.Errorf("no APIs available")
|
||||
@@ -839,8 +807,6 @@ func getQobuzDownloadURLParallel(apis []string, trackID int64, quality string) (
|
||||
return "", "", fmt.Errorf("all %d Qobuz APIs failed. Errors: %v", len(apis), errors)
|
||||
}
|
||||
|
||||
// GetDownloadURL gets download URL for a track - tries ALL APIs in parallel
|
||||
// "Siapa cepat dia dapat" - first successful response wins
|
||||
func (q *QobuzDownloader) GetDownloadURL(trackID int64, quality string) (string, error) {
|
||||
apis := q.GetAvailableAPIs()
|
||||
if len(apis) == 0 {
|
||||
@@ -938,7 +904,6 @@ func (q *QobuzDownloader) DownloadFile(downloadURL, outputPath, itemID string) e
|
||||
return nil
|
||||
}
|
||||
|
||||
// QobuzDownloadResult contains download result with quality info
|
||||
type QobuzDownloadResult struct {
|
||||
FilePath string
|
||||
BitDepth int
|
||||
@@ -952,7 +917,6 @@ type QobuzDownloadResult struct {
|
||||
ISRC string
|
||||
}
|
||||
|
||||
// downloadFromQobuz downloads a track using the request parameters
|
||||
func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
|
||||
downloader := NewQobuzDownloader()
|
||||
|
||||
@@ -1135,15 +1099,12 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
|
||||
fmt.Printf("Warning: failed to embed metadata: %v\n", err)
|
||||
}
|
||||
|
||||
// Handle lyrics based on LyricsMode setting
|
||||
// Mode: "embed" (default), "external" (.lrc file), "both"
|
||||
if req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" {
|
||||
lyricsMode := req.LyricsMode
|
||||
if lyricsMode == "" {
|
||||
lyricsMode = "embed" // default
|
||||
lyricsMode = "embed"
|
||||
}
|
||||
|
||||
// Save external .lrc file if mode is "external" or "both"
|
||||
if lyricsMode == "external" || lyricsMode == "both" {
|
||||
GoLog("[Qobuz] Saving external LRC file...\n")
|
||||
if lrcPath, lrcErr := SaveLRCFile(outputPath, parallelResult.LyricsLRC); lrcErr != nil {
|
||||
@@ -1153,7 +1114,6 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// Embed lyrics if mode is "embed" or "both"
|
||||
if lyricsMode == "embed" || lyricsMode == "both" {
|
||||
GoLog("[Qobuz] Embedding parallel-fetched lyrics (%d lines)...\n", len(parallelResult.LyricsData.Lines))
|
||||
if embedErr := EmbedLyrics(outputPath, parallelResult.LyricsLRC); embedErr != nil {
|
||||
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// RateLimiter implements a sliding window rate limiter
|
||||
type RateLimiter struct {
|
||||
mu sync.Mutex
|
||||
maxRequests int
|
||||
@@ -13,7 +12,6 @@ type RateLimiter struct {
|
||||
timestamps []time.Time
|
||||
}
|
||||
|
||||
// NewRateLimiter creates a new rate limiter with specified max requests per window
|
||||
func NewRateLimiter(maxRequests int, window time.Duration) *RateLimiter {
|
||||
return &RateLimiter{
|
||||
maxRequests: maxRequests,
|
||||
@@ -22,8 +20,6 @@ func NewRateLimiter(maxRequests int, window time.Duration) *RateLimiter {
|
||||
}
|
||||
}
|
||||
|
||||
// WaitForSlot blocks until a request is allowed under the rate limit
|
||||
// Returns immediately if under the limit, otherwise waits until a slot is available
|
||||
func (r *RateLimiter) WaitForSlot() {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
@@ -70,8 +66,6 @@ func (r *RateLimiter) cleanOldTimestamps(now time.Time) {
|
||||
}
|
||||
}
|
||||
|
||||
// TryAcquire attempts to acquire a slot without blocking
|
||||
// Returns true if successful, false if rate limit would be exceeded
|
||||
func (r *RateLimiter) TryAcquire() bool {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
@@ -87,7 +81,6 @@ func (r *RateLimiter) TryAcquire() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// Available returns the number of requests available in the current window
|
||||
func (r *RateLimiter) Available() int {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
@@ -99,7 +92,6 @@ func (r *RateLimiter) Available() int {
|
||||
// Global SongLink rate limiter - 9 requests per minute (to be safe, limit is 10)
|
||||
var songLinkRateLimiter = NewRateLimiter(9, time.Minute)
|
||||
|
||||
// GetSongLinkRateLimiter returns the global SongLink rate limiter
|
||||
func GetSongLinkRateLimiter() *RateLimiter {
|
||||
return songLinkRateLimiter
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"unicode"
|
||||
)
|
||||
|
||||
// Hiragana to Romaji mapping
|
||||
var hiraganaToRomaji = map[rune]string{
|
||||
'あ': "a", 'い': "i", 'う': "u", 'え': "e", 'お': "o",
|
||||
'か': "ka", 'き': "ki", 'く': "ku", 'け': "ke", 'こ': "ko",
|
||||
@@ -30,7 +29,6 @@ var hiraganaToRomaji = map[rune]string{
|
||||
'ぁ': "a", 'ぃ': "i", 'ぅ': "u", 'ぇ': "e", 'ぉ': "o",
|
||||
}
|
||||
|
||||
// Katakana to Romaji mapping
|
||||
var katakanaToRomaji = map[rune]string{
|
||||
'ア': "a", 'イ': "i", 'ウ': "u", 'エ': "e", 'オ': "o",
|
||||
'カ': "ka", 'キ': "ki", 'ク': "ku", 'ケ': "ke", 'コ': "ko",
|
||||
@@ -58,7 +56,6 @@ var katakanaToRomaji = map[rune]string{
|
||||
'ヴ': "vu",
|
||||
}
|
||||
|
||||
// Combination mappings for きゃ, しゃ, etc.
|
||||
var combinationHiragana = map[string]string{
|
||||
"きゃ": "kya", "きゅ": "kyu", "きょ": "kyo",
|
||||
"しゃ": "sha", "しゅ": "shu", "しょ": "sho",
|
||||
@@ -91,7 +88,6 @@ var combinationKatakana = map[string]string{
|
||||
"ウィ": "wi", "ウェ": "we", "ウォ": "wo",
|
||||
}
|
||||
|
||||
// ContainsJapanese checks if a string contains Japanese characters
|
||||
func ContainsJapanese(s string) bool {
|
||||
for _, r := range s {
|
||||
if isHiragana(r) || isKatakana(r) || isKanji(r) {
|
||||
@@ -114,8 +110,6 @@ func isKanji(r rune) bool {
|
||||
(r >= 0x3400 && r <= 0x4DBF) // CJK Unified Ideographs Extension A
|
||||
}
|
||||
|
||||
// JapaneseToRomaji converts Japanese text (hiragana/katakana) to romaji
|
||||
// Note: Kanji cannot be converted without a dictionary, so they are kept as-is
|
||||
func JapaneseToRomaji(text string) string {
|
||||
if !ContainsJapanese(text) {
|
||||
return text
|
||||
@@ -175,8 +169,6 @@ func JapaneseToRomaji(text string) string {
|
||||
return result.String()
|
||||
}
|
||||
|
||||
// BuildSearchQuery creates a search query from track name and artist
|
||||
// Converts Japanese to romaji if present
|
||||
func BuildSearchQuery(trackName, artistName string) string {
|
||||
// Convert Japanese to romaji
|
||||
trackRomaji := JapaneseToRomaji(trackName)
|
||||
@@ -189,7 +181,6 @@ func BuildSearchQuery(trackName, artistName string) string {
|
||||
return strings.TrimSpace(artistClean + " " + trackClean)
|
||||
}
|
||||
|
||||
// cleanSearchQuery removes special characters that might interfere with search
|
||||
func cleanSearchQuery(s string) string {
|
||||
var result strings.Builder
|
||||
for _, r := range s {
|
||||
@@ -202,8 +193,6 @@ func cleanSearchQuery(s string) string {
|
||||
return strings.TrimSpace(result.String())
|
||||
}
|
||||
|
||||
// CleanToASCII removes all non-ASCII characters and keeps only letters, numbers, spaces
|
||||
// This is useful for creating search queries that work better with Tidal's search
|
||||
func CleanToASCII(s string) string {
|
||||
var result strings.Builder
|
||||
for _, r := range s {
|
||||
|
||||
@@ -11,12 +11,10 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// SongLinkClient handles song.link API interactions
|
||||
type SongLinkClient struct {
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
// TrackAvailability represents track availability on different platforms
|
||||
type TrackAvailability struct {
|
||||
SpotifyID string `json:"spotify_id"`
|
||||
Tidal bool `json:"tidal"`
|
||||
@@ -35,7 +33,6 @@ var (
|
||||
songLinkClientOnce sync.Once
|
||||
)
|
||||
|
||||
// NewSongLinkClient creates a new SongLink client (returns singleton for connection reuse)
|
||||
func NewSongLinkClient() *SongLinkClient {
|
||||
songLinkClientOnce.Do(func() {
|
||||
globalSongLinkClient = &SongLinkClient{
|
||||
@@ -45,7 +42,6 @@ func NewSongLinkClient() *SongLinkClient {
|
||||
return globalSongLinkClient
|
||||
}
|
||||
|
||||
// CheckTrackAvailability checks track availability on streaming platforms
|
||||
func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc string) (*TrackAvailability, error) {
|
||||
if spotifyTrackID == "" {
|
||||
return nil, fmt.Errorf("spotify track ID is empty")
|
||||
@@ -126,7 +122,6 @@ func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc stri
|
||||
return availability, nil
|
||||
}
|
||||
|
||||
// GetStreamingURLs gets streaming URLs for a Spotify track
|
||||
func (s *SongLinkClient) GetStreamingURLs(spotifyTrackID string) (map[string]string, error) {
|
||||
availability, err := s.CheckTrackAvailability(spotifyTrackID, "")
|
||||
if err != nil {
|
||||
@@ -191,7 +186,6 @@ func extractDeezerIDFromURL(deezerURL string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// GetDeezerIDFromSpotify converts a Spotify track ID to Deezer track ID using SongLink
|
||||
func (s *SongLinkClient) GetDeezerIDFromSpotify(spotifyTrackID string) (string, error) {
|
||||
availability, err := s.CheckTrackAvailability(spotifyTrackID, "")
|
||||
if err != nil {
|
||||
@@ -213,7 +207,6 @@ type AlbumAvailability struct {
|
||||
DeezerID string `json:"deezer_id,omitempty"`
|
||||
}
|
||||
|
||||
// CheckAlbumAvailability checks album availability on streaming platforms using SongLink
|
||||
func (s *SongLinkClient) CheckAlbumAvailability(spotifyAlbumID string) (*AlbumAvailability, error) {
|
||||
// Use global rate limiter
|
||||
songLinkRateLimiter.WaitForSlot()
|
||||
@@ -283,11 +276,6 @@ func (s *SongLinkClient) GetDeezerAlbumIDFromSpotify(spotifyAlbumID string) (str
|
||||
return availability.DeezerID, nil
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Deezer ID Support - Query SongLink using Deezer as source
|
||||
// ========================================
|
||||
|
||||
// CheckAvailabilityFromDeezer checks track availability using Deezer track ID as source
|
||||
// This is useful when we have Deezer metadata and want to find the track on other platforms
|
||||
func (s *SongLinkClient) CheckAvailabilityFromDeezer(deezerTrackID string) (*TrackAvailability, error) {
|
||||
if deezerTrackID == "" {
|
||||
@@ -374,7 +362,6 @@ func (s *SongLinkClient) CheckAvailabilityFromDeezer(deezerTrackID string) (*Tra
|
||||
return availability, nil
|
||||
}
|
||||
|
||||
// CheckAvailabilityByPlatform checks track availability using any supported platform
|
||||
// platform: "spotify", "deezer", "tidal", "amazonMusic", "appleMusic", "youtube", etc.
|
||||
// entityType: "song" or "album"
|
||||
// entityID: the ID on that platform
|
||||
@@ -472,7 +459,6 @@ func extractSpotifyIDFromURL(spotifyURL string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// GetSpotifyIDFromDeezer converts a Deezer track ID to Spotify track ID using SongLink
|
||||
func (s *SongLinkClient) GetSpotifyIDFromDeezer(deezerTrackID string) (string, error) {
|
||||
availability, err := s.CheckAvailabilityFromDeezer(deezerTrackID)
|
||||
if err != nil {
|
||||
@@ -500,7 +486,6 @@ func (s *SongLinkClient) GetTidalURLFromDeezer(deezerTrackID string) (string, er
|
||||
return availability.TidalURL, nil
|
||||
}
|
||||
|
||||
// GetAmazonURLFromDeezer converts a Deezer track ID to Amazon Music URL using SongLink
|
||||
func (s *SongLinkClient) GetAmazonURLFromDeezer(deezerTrackID string) (string, error) {
|
||||
availability, err := s.CheckAvailabilityFromDeezer(deezerTrackID)
|
||||
if err != nil {
|
||||
|
||||
+11
-62
@@ -24,7 +24,6 @@ const (
|
||||
artistAlbumsURL = "https://api.spotify.com/v1/artists/%s/albums"
|
||||
searchBaseURL = "https://api.spotify.com/v1/search"
|
||||
|
||||
// Cache TTL settings
|
||||
artistCacheTTL = 10 * time.Minute
|
||||
searchCacheTTL = 5 * time.Minute
|
||||
albumCacheTTL = 10 * time.Minute
|
||||
@@ -32,7 +31,6 @@ const (
|
||||
|
||||
var errInvalidSpotifyURL = errors.New("invalid or unsupported Spotify URL")
|
||||
|
||||
// cacheEntry holds cached data with expiration
|
||||
type cacheEntry struct {
|
||||
data interface{}
|
||||
expiresAt time.Time
|
||||
@@ -42,26 +40,23 @@ func (e *cacheEntry) isExpired() bool {
|
||||
return time.Now().After(e.expiresAt)
|
||||
}
|
||||
|
||||
// SpotifyMetadataClient handles Spotify API interactions
|
||||
type SpotifyMetadataClient struct {
|
||||
httpClient *http.Client
|
||||
clientID string
|
||||
clientSecret string
|
||||
cachedToken string
|
||||
tokenExpiresAt time.Time
|
||||
tokenMu sync.Mutex // Protects token cache for concurrent access
|
||||
tokenMu sync.Mutex
|
||||
rng *rand.Rand
|
||||
rngMu sync.Mutex
|
||||
userAgent string
|
||||
|
||||
// Caches to reduce API calls
|
||||
artistCache map[string]*cacheEntry // key: artistID
|
||||
searchCache map[string]*cacheEntry // key: query+type
|
||||
albumCache map[string]*cacheEntry // key: albumID
|
||||
artistCache map[string]*cacheEntry
|
||||
searchCache map[string]*cacheEntry
|
||||
albumCache map[string]*cacheEntry
|
||||
cacheMu sync.RWMutex
|
||||
}
|
||||
|
||||
// Custom credentials storage (set from Flutter)
|
||||
var (
|
||||
customClientID string
|
||||
customClientSecret string
|
||||
@@ -79,7 +74,6 @@ func SetSpotifyCredentials(clientID, clientSecret string) {
|
||||
customClientSecret = clientSecret
|
||||
}
|
||||
|
||||
// HasSpotifyCredentials checks if Spotify credentials are configured
|
||||
func HasSpotifyCredentials() bool {
|
||||
credentialsMu.RLock()
|
||||
defer credentialsMu.RUnlock()
|
||||
@@ -114,8 +108,6 @@ func getCredentials() (string, string, error) {
|
||||
return "", "", ErrNoSpotifyCredentials
|
||||
}
|
||||
|
||||
// NewSpotifyMetadataClient creates a new Spotify client
|
||||
// Returns error if credentials are not configured
|
||||
func NewSpotifyMetadataClient() (*SpotifyMetadataClient, error) {
|
||||
clientID, clientSecret, err := getCredentials()
|
||||
if err != nil {
|
||||
@@ -137,7 +129,6 @@ func NewSpotifyMetadataClient() (*SpotifyMetadataClient, error) {
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// TrackMetadata represents track information
|
||||
type TrackMetadata struct {
|
||||
SpotifyID string `json:"spotify_id,omitempty"`
|
||||
Artists string `json:"artists"`
|
||||
@@ -155,7 +146,6 @@ type TrackMetadata struct {
|
||||
AlbumType string `json:"album_type,omitempty"` // album, single, ep, compilation
|
||||
}
|
||||
|
||||
// AlbumTrackMetadata holds per-track info for album/playlist
|
||||
type AlbumTrackMetadata struct {
|
||||
SpotifyID string `json:"spotify_id,omitempty"`
|
||||
Artists string `json:"artists"`
|
||||
@@ -172,28 +162,25 @@ type AlbumTrackMetadata struct {
|
||||
ISRC string `json:"isrc"`
|
||||
AlbumID string `json:"album_id,omitempty"`
|
||||
AlbumURL string `json:"album_url,omitempty"`
|
||||
AlbumType string `json:"album_type,omitempty"` // album, single, ep, compilation
|
||||
AlbumType string `json:"album_type,omitempty"`
|
||||
}
|
||||
|
||||
// AlbumInfoMetadata holds album information
|
||||
type AlbumInfoMetadata struct {
|
||||
TotalTracks int `json:"total_tracks"`
|
||||
Name string `json:"name"`
|
||||
ReleaseDate string `json:"release_date"`
|
||||
Artists string `json:"artists"`
|
||||
Images string `json:"images"`
|
||||
Genre string `json:"genre,omitempty"` // Music genre(s), comma-separated
|
||||
Label string `json:"label,omitempty"` // Record label name
|
||||
Copyright string `json:"copyright,omitempty"` // Copyright information
|
||||
Genre string `json:"genre,omitempty"`
|
||||
Label string `json:"label,omitempty"`
|
||||
Copyright string `json:"copyright,omitempty"`
|
||||
}
|
||||
|
||||
// AlbumResponsePayload is the response for album requests
|
||||
type AlbumResponsePayload struct {
|
||||
AlbumInfo AlbumInfoMetadata `json:"album_info"`
|
||||
TrackList []AlbumTrackMetadata `json:"track_list"`
|
||||
}
|
||||
|
||||
// PlaylistInfoMetadata holds playlist information
|
||||
type PlaylistInfoMetadata struct {
|
||||
Tracks struct {
|
||||
Total int `json:"total"`
|
||||
@@ -205,13 +192,11 @@ type PlaylistInfoMetadata struct {
|
||||
} `json:"owner"`
|
||||
}
|
||||
|
||||
// PlaylistResponsePayload is the response for playlist requests
|
||||
type PlaylistResponsePayload struct {
|
||||
PlaylistInfo PlaylistInfoMetadata `json:"playlist_info"`
|
||||
TrackList []AlbumTrackMetadata `json:"track_list"`
|
||||
}
|
||||
|
||||
// ArtistInfoMetadata holds artist information
|
||||
type ArtistInfoMetadata struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
@@ -220,7 +205,6 @@ type ArtistInfoMetadata struct {
|
||||
Popularity int `json:"popularity"`
|
||||
}
|
||||
|
||||
// ArtistAlbumMetadata holds album info for artist discography
|
||||
type ArtistAlbumMetadata struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
@@ -231,24 +215,20 @@ type ArtistAlbumMetadata struct {
|
||||
Artists string `json:"artists"`
|
||||
}
|
||||
|
||||
// ArtistResponsePayload is the response for artist requests
|
||||
type ArtistResponsePayload struct {
|
||||
ArtistInfo ArtistInfoMetadata `json:"artist_info"`
|
||||
Albums []ArtistAlbumMetadata `json:"albums"`
|
||||
}
|
||||
|
||||
// TrackResponse is the response for single track requests
|
||||
type TrackResponse struct {
|
||||
Track TrackMetadata `json:"track"`
|
||||
}
|
||||
|
||||
// SearchResult represents search results
|
||||
type SearchResult struct {
|
||||
Tracks []TrackMetadata `json:"tracks"`
|
||||
Total int `json:"total"`
|
||||
}
|
||||
|
||||
// SearchArtistResult represents an artist in search results
|
||||
type SearchArtistResult struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
@@ -257,7 +237,6 @@ type SearchArtistResult struct {
|
||||
Popularity int `json:"popularity"`
|
||||
}
|
||||
|
||||
// SearchAllResult represents combined search results for tracks and artists
|
||||
type SearchAllResult struct {
|
||||
Tracks []TrackMetadata `json:"tracks"`
|
||||
Artists []SearchArtistResult `json:"artists"`
|
||||
@@ -274,7 +253,6 @@ type accessTokenResponse struct {
|
||||
TokenType string `json:"token_type"`
|
||||
}
|
||||
|
||||
// Internal API response types
|
||||
type image struct {
|
||||
URL string `json:"url"`
|
||||
}
|
||||
@@ -300,7 +278,7 @@ type albumSimplified struct {
|
||||
Images []image `json:"images"`
|
||||
ExternalURL externalURL `json:"external_urls"`
|
||||
Artists []artist `json:"artists"`
|
||||
AlbumType string `json:"album_type"` // album, single, compilation
|
||||
AlbumType string `json:"album_type"`
|
||||
}
|
||||
|
||||
type trackFull struct {
|
||||
@@ -315,7 +293,6 @@ type trackFull struct {
|
||||
Artists []artist `json:"artists"`
|
||||
}
|
||||
|
||||
// GetFilteredData fetches and formats Spotify data
|
||||
func (c *SpotifyMetadataClient) GetFilteredData(ctx context.Context, spotifyURL string, batch bool, delay time.Duration) (interface{}, error) {
|
||||
parsed, err := parseSpotifyURI(spotifyURL)
|
||||
if err != nil {
|
||||
@@ -341,7 +318,6 @@ func (c *SpotifyMetadataClient) GetFilteredData(ctx context.Context, spotifyURL
|
||||
}
|
||||
}
|
||||
|
||||
// SearchTracks searches for tracks on Spotify
|
||||
func (c *SpotifyMetadataClient) SearchTracks(ctx context.Context, query string, limit int) (*SearchResult, error) {
|
||||
token, err := c.getAccessToken(ctx)
|
||||
if err != nil {
|
||||
@@ -388,7 +364,6 @@ func (c *SpotifyMetadataClient) SearchTracks(ctx context.Context, query string,
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// SearchAll searches for tracks and artists on Spotify
|
||||
func (c *SpotifyMetadataClient) SearchAll(ctx context.Context, query string, trackLimit, artistLimit int) (*SearchAllResult, error) {
|
||||
cacheKey := fmt.Sprintf("all:%s:%d:%d", query, trackLimit, artistLimit)
|
||||
|
||||
@@ -510,7 +485,6 @@ func (c *SpotifyMetadataClient) fetchAlbum(ctx context.Context, albumID, token s
|
||||
}
|
||||
c.cacheMu.RUnlock()
|
||||
|
||||
// Track item structure for pagination
|
||||
type trackItem struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
@@ -546,11 +520,9 @@ func (c *SpotifyMetadataClient) fetchAlbum(ctx context.Context, albumID, token s
|
||||
Images: albumImage,
|
||||
}
|
||||
|
||||
// Collect all tracks (including paginated)
|
||||
allTrackItems := data.Tracks.Items
|
||||
nextURL := data.Tracks.Next
|
||||
|
||||
// Fetch remaining tracks using pagination (no limit)
|
||||
for nextURL != "" {
|
||||
var pageData struct {
|
||||
Items []trackItem `json:"items"`
|
||||
@@ -572,7 +544,6 @@ func (c *SpotifyMetadataClient) fetchAlbum(ctx context.Context, albumID, token s
|
||||
trackIDs[i] = item.ID
|
||||
}
|
||||
|
||||
// Fetch ISRCs in parallel for ALL tracks (like Deezer implementation)
|
||||
isrcMap := c.fetchISRCsParallel(ctx, trackIDs, token)
|
||||
|
||||
tracks := make([]AlbumTrackMetadata, 0, len(allTrackItems))
|
||||
@@ -612,10 +583,8 @@ func (c *SpotifyMetadataClient) fetchAlbum(ctx context.Context, albumID, token s
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// fetchISRCsParallel fetches ISRCs for multiple tracks in parallel
|
||||
// Similar to Deezer implementation for consistency
|
||||
func (c *SpotifyMetadataClient) fetchISRCsParallel(ctx context.Context, trackIDs []string, token string) map[string]string {
|
||||
const maxParallelISRC = 10 // Max concurrent ISRC fetches
|
||||
const maxParallelISRC = 10
|
||||
|
||||
result := make(map[string]string)
|
||||
var resultMu sync.Mutex
|
||||
@@ -624,7 +593,6 @@ func (c *SpotifyMetadataClient) fetchISRCsParallel(ctx context.Context, trackIDs
|
||||
return result
|
||||
}
|
||||
|
||||
// Use semaphore to limit concurrent requests
|
||||
sem := make(chan struct{}, maxParallelISRC)
|
||||
var wg sync.WaitGroup
|
||||
|
||||
@@ -633,7 +601,6 @@ func (c *SpotifyMetadataClient) fetchISRCsParallel(ctx context.Context, trackIDs
|
||||
go func(id string) {
|
||||
defer wg.Done()
|
||||
|
||||
// Acquire semaphore
|
||||
select {
|
||||
case sem <- struct{}{}:
|
||||
defer func() { <-sem }()
|
||||
@@ -654,7 +621,6 @@ func (c *SpotifyMetadataClient) fetchISRCsParallel(ctx context.Context, trackIDs
|
||||
}
|
||||
|
||||
func (c *SpotifyMetadataClient) fetchPlaylist(ctx context.Context, playlistID, token string) (*PlaylistResponsePayload, error) {
|
||||
// First request to get playlist info and first batch of tracks
|
||||
var data struct {
|
||||
Name string `json:"name"`
|
||||
Images []image `json:"images"`
|
||||
@@ -680,10 +646,8 @@ func (c *SpotifyMetadataClient) fetchPlaylist(ctx context.Context, playlistID, t
|
||||
info.Owner.Name = data.Name
|
||||
info.Owner.Images = firstImageURL(data.Images)
|
||||
|
||||
// Pre-allocate with expected capacity
|
||||
tracks := make([]AlbumTrackMetadata, 0, data.Tracks.Total)
|
||||
|
||||
// Add first batch of tracks
|
||||
for _, item := range data.Tracks.Items {
|
||||
if item.Track == nil {
|
||||
continue
|
||||
@@ -707,7 +671,6 @@ func (c *SpotifyMetadataClient) fetchPlaylist(ctx context.Context, playlistID, t
|
||||
})
|
||||
}
|
||||
|
||||
// Fetch remaining tracks using pagination (NO LIMIT - fetch all tracks)
|
||||
nextURL := data.Tracks.Next
|
||||
|
||||
for nextURL != "" {
|
||||
@@ -719,7 +682,6 @@ func (c *SpotifyMetadataClient) fetchPlaylist(ctx context.Context, playlistID, t
|
||||
}
|
||||
|
||||
if err := c.getJSON(ctx, nextURL, token, &pageData); err != nil {
|
||||
// Log error but return what we have so far
|
||||
fmt.Printf("[Spotify] Warning: failed to fetch page, returning %d tracks: %v\n", len(tracks), err)
|
||||
break
|
||||
}
|
||||
@@ -766,7 +728,6 @@ func (c *SpotifyMetadataClient) fetchArtist(ctx context.Context, artistID, token
|
||||
}
|
||||
c.cacheMu.RUnlock()
|
||||
|
||||
// Fetch artist info
|
||||
var artistData struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
@@ -789,7 +750,6 @@ func (c *SpotifyMetadataClient) fetchArtist(ctx context.Context, artistID, token
|
||||
Popularity: artistData.Popularity,
|
||||
}
|
||||
|
||||
// Fetch artist albums (all types: album, single, compilation)
|
||||
albums := make([]ArtistAlbumMetadata, 0)
|
||||
offset := 0
|
||||
limit := 50
|
||||
@@ -829,13 +789,11 @@ func (c *SpotifyMetadataClient) fetchArtist(ctx context.Context, artistID, token
|
||||
})
|
||||
}
|
||||
|
||||
// Check if there are more albums
|
||||
if albumsData.Next == "" || len(albumsData.Items) < limit {
|
||||
break
|
||||
}
|
||||
offset += limit
|
||||
|
||||
// Safety limit to prevent infinite loops
|
||||
if offset > 500 {
|
||||
break
|
||||
}
|
||||
@@ -916,7 +874,6 @@ func (c *SpotifyMetadataClient) getJSON(ctx context.Context, endpoint, token str
|
||||
return err
|
||||
}
|
||||
|
||||
// Set headers (same as PC version baseHeaders)
|
||||
req.Header.Set("User-Agent", c.userAgent)
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("Accept-Language", "en-US,en;q=0.9")
|
||||
@@ -952,8 +909,7 @@ func (c *SpotifyMetadataClient) randomUserAgent() string {
|
||||
c.rngMu.Lock()
|
||||
defer c.rngMu.Unlock()
|
||||
|
||||
// Use Mac User-Agent format (same as PC version)
|
||||
macMajor := c.rng.Intn(4) + 11 // 11-14
|
||||
macMajor := c.rng.Intn(4) + 11
|
||||
macMinor := c.rng.Intn(5) + 4 // 4-8
|
||||
webkitMajor := c.rng.Intn(7) + 530 // 530-536
|
||||
webkitMinor := c.rng.Intn(7) + 30 // 30-36
|
||||
@@ -978,7 +934,6 @@ func parseSpotifyURI(input string) (spotifyURI, error) {
|
||||
return spotifyURI{}, errInvalidSpotifyURL
|
||||
}
|
||||
|
||||
// Handle spotify: URI format
|
||||
if strings.HasPrefix(trimmed, "spotify:") {
|
||||
parts := strings.Split(trimmed, ":")
|
||||
if len(parts) == 3 {
|
||||
@@ -989,13 +944,11 @@ func parseSpotifyURI(input string) (spotifyURI, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// Handle URL format
|
||||
parsed, err := url.Parse(trimmed)
|
||||
if err != nil {
|
||||
return spotifyURI{}, err
|
||||
}
|
||||
|
||||
// Handle embed.spotify.com URLs
|
||||
if parsed.Host == "embed.spotify.com" {
|
||||
if parsed.RawQuery == "" {
|
||||
return spotifyURI{}, errInvalidSpotifyURL
|
||||
@@ -1008,7 +961,6 @@ func parseSpotifyURI(input string) (spotifyURI, error) {
|
||||
return parseSpotifyURI(embedded)
|
||||
}
|
||||
|
||||
// Handle plain ID (no scheme/host) - defaults to playlist
|
||||
if parsed.Scheme == "" && parsed.Host == "" {
|
||||
id := strings.Trim(strings.TrimSpace(parsed.Path), "/")
|
||||
if id == "" {
|
||||
@@ -1034,7 +986,6 @@ func parseSpotifyURI(input string) (spotifyURI, error) {
|
||||
return spotifyURI{}, errInvalidSpotifyURL
|
||||
}
|
||||
|
||||
// Skip intl- prefix if present
|
||||
if strings.HasPrefix(parts[0], "intl-") {
|
||||
parts = parts[1:]
|
||||
}
|
||||
@@ -1042,7 +993,6 @@ func parseSpotifyURI(input string) (spotifyURI, error) {
|
||||
return spotifyURI{}, errInvalidSpotifyURL
|
||||
}
|
||||
|
||||
// Handle standard URLs: /album/{id}, /track/{id}, /playlist/{id}, /artist/{id}
|
||||
if len(parts) == 2 {
|
||||
switch parts[0] {
|
||||
case "album", "track", "playlist", "artist":
|
||||
@@ -1050,7 +1000,6 @@ func parseSpotifyURI(input string) (spotifyURI, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// Handle nested playlist URLs: /user/{user}/playlist/{id}
|
||||
if len(parts) == 4 && parts[2] == "playlist" {
|
||||
return spotifyURI{Type: "playlist", ID: parts[3]}, nil
|
||||
}
|
||||
|
||||
+18
-158
@@ -19,7 +19,6 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// TidalDownloader handles Tidal downloads
|
||||
type TidalDownloader struct {
|
||||
client *http.Client
|
||||
clientID string
|
||||
@@ -35,7 +34,6 @@ var (
|
||||
tidalDownloaderOnce sync.Once
|
||||
)
|
||||
|
||||
// TidalTrack represents a Tidal track
|
||||
type TidalTrack struct {
|
||||
ID int64 `json:"id"`
|
||||
Title string `json:"title"`
|
||||
@@ -60,7 +58,6 @@ type TidalTrack struct {
|
||||
} `json:"mediaMetadata"`
|
||||
}
|
||||
|
||||
// TidalAPIResponseV2 is the new API response format (version 2.0)
|
||||
type TidalAPIResponseV2 struct {
|
||||
Version string `json:"version"`
|
||||
Data struct {
|
||||
@@ -76,7 +73,6 @@ type TidalAPIResponseV2 struct {
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
// TidalBTSManifest is the BTS (application/vnd.tidal.bts) manifest format
|
||||
type TidalBTSManifest struct {
|
||||
MimeType string `json:"mimeType"`
|
||||
Codecs string `json:"codecs"`
|
||||
@@ -84,7 +80,6 @@ type TidalBTSManifest struct {
|
||||
URLs []string `json:"urls"`
|
||||
}
|
||||
|
||||
// MPD represents DASH manifest structure
|
||||
type MPD struct {
|
||||
XMLName xml.Name `xml:"MPD"`
|
||||
Period struct {
|
||||
@@ -105,7 +100,6 @@ type MPD struct {
|
||||
} `xml:"Period"`
|
||||
}
|
||||
|
||||
// NewTidalDownloader creates a new Tidal downloader (returns singleton for token reuse)
|
||||
func NewTidalDownloader() *TidalDownloader {
|
||||
tidalDownloaderOnce.Do(func() {
|
||||
clientID, _ := base64.StdEncoding.DecodeString("NkJEU1JkcEs5aHFFQlRnVQ==")
|
||||
@@ -150,7 +144,6 @@ func (t *TidalDownloader) GetAvailableAPIs() []string {
|
||||
return apis
|
||||
}
|
||||
|
||||
// GetAccessToken gets Tidal access token (with caching)
|
||||
func (t *TidalDownloader) GetAccessToken() (string, error) {
|
||||
t.tokenMu.Lock()
|
||||
defer t.tokenMu.Unlock()
|
||||
@@ -199,7 +192,6 @@ func (t *TidalDownloader) GetAccessToken() (string, error) {
|
||||
return result.AccessToken, nil
|
||||
}
|
||||
|
||||
// GetTidalURLFromSpotify gets Tidal URL from Spotify track ID using SongLink
|
||||
func (t *TidalDownloader) GetTidalURLFromSpotify(spotifyTrackID string) (string, error) {
|
||||
spotifyBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9vcGVuLnNwb3RpZnkuY29tL3RyYWNrLw==")
|
||||
spotifyURL := fmt.Sprintf("%s%s", string(spotifyBase), spotifyTrackID)
|
||||
@@ -239,7 +231,6 @@ func (t *TidalDownloader) GetTidalURLFromSpotify(spotifyTrackID string) (string,
|
||||
return tidalLink.URL, nil
|
||||
}
|
||||
|
||||
// GetTrackIDFromURL extracts track ID from Tidal URL
|
||||
func (t *TidalDownloader) GetTrackIDFromURL(tidalURL string) (int64, error) {
|
||||
parts := strings.Split(tidalURL, "/track/")
|
||||
if len(parts) < 2 {
|
||||
@@ -293,7 +284,6 @@ func (t *TidalDownloader) GetTrackInfoByID(trackID int64) (*TidalTrack, error) {
|
||||
return &trackInfo, nil
|
||||
}
|
||||
|
||||
// SearchTrackByISRC searches for a track by ISRC
|
||||
func (t *TidalDownloader) SearchTrackByISRC(isrc string) (*TidalTrack, error) {
|
||||
token, err := t.GetAccessToken()
|
||||
if err != nil {
|
||||
@@ -341,30 +331,7 @@ func (t *TidalDownloader) SearchTrackByISRC(isrc string) (*TidalTrack, error) {
|
||||
return nil, fmt.Errorf("no exact ISRC match found for: %s", isrc)
|
||||
}
|
||||
|
||||
// normalizeTitle normalizes a track title for comparison
|
||||
// Kept for potential future use
|
||||
// func normalizeTitle(title string) string {
|
||||
// normalized := strings.ToLower(strings.TrimSpace(title))
|
||||
//
|
||||
// // Remove common suffixes in parentheses or brackets
|
||||
// suffixPatterns := []string{
|
||||
// " (remaster)", " (remastered)", " (deluxe)", " (deluxe edition)",
|
||||
// " (bonus track)", " (single)", " (album version)", " (radio edit)",
|
||||
// " [remaster]", " [remastered]", " [deluxe]", " [bonus track]",
|
||||
// }
|
||||
// for _, suffix := range suffixPatterns {
|
||||
// normalized = strings.TrimSuffix(normalized, suffix)
|
||||
// }
|
||||
//
|
||||
// // Remove multiple spaces
|
||||
// for strings.Contains(normalized, " ") {
|
||||
// normalized = strings.ReplaceAll(normalized, " ", " ")
|
||||
// }
|
||||
//
|
||||
// return normalized
|
||||
// }
|
||||
|
||||
// SearchTrackByMetadataWithISRC searches for a track with ISRC matching priority
|
||||
// Now includes romaji conversion for Japanese text (4 search strategies like PC)
|
||||
func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, spotifyISRC string, expectedDuration int) (*TidalTrack, error) {
|
||||
token, err := t.GetAccessToken()
|
||||
@@ -466,7 +433,6 @@ func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, s
|
||||
if len(result.Items) > 0 {
|
||||
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 != "" {
|
||||
for i := range result.Items {
|
||||
if result.Items[i].ISRC == spotifyISRC {
|
||||
@@ -592,7 +558,6 @@ func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, s
|
||||
return bestMatch, nil
|
||||
}
|
||||
|
||||
// containsQuery checks if a query already exists in the list
|
||||
func containsQuery(queries []string, query string) bool {
|
||||
for _, q := range queries {
|
||||
if q == query {
|
||||
@@ -602,7 +567,6 @@ func containsQuery(queries []string, query string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// SearchTrackByMetadata searches for a track using artist name and track name
|
||||
func (t *TidalDownloader) SearchTrackByMetadata(trackName, artistName string) (*TidalTrack, error) {
|
||||
return t.SearchTrackByMetadataWithISRC(trackName, artistName, "", 0)
|
||||
}
|
||||
@@ -614,7 +578,6 @@ type TidalDownloadInfo struct {
|
||||
SampleRate int
|
||||
}
|
||||
|
||||
// tidalAPIResult holds the result from a parallel API request
|
||||
type tidalAPIResult struct {
|
||||
apiURL string
|
||||
info TidalDownloadInfo
|
||||
@@ -622,9 +585,7 @@ type tidalAPIResult struct {
|
||||
duration time.Duration
|
||||
}
|
||||
|
||||
// getDownloadURLParallel requests download URL from all APIs in parallel
|
||||
// Returns the first successful result (supports both v1 and v2 API formats)
|
||||
// "Siapa cepat dia dapat" - first success wins
|
||||
func getDownloadURLParallel(apis []string, trackID int64, quality string) (string, TidalDownloadInfo, error) {
|
||||
if len(apis) == 0 {
|
||||
return "", TidalDownloadInfo{}, fmt.Errorf("no APIs available")
|
||||
@@ -671,8 +632,7 @@ func getDownloadURLParallel(apis []string, trackID int64, quality string) (strin
|
||||
|
||||
var v2Response TidalAPIResponseV2
|
||||
if err := json.Unmarshal(body, &v2Response); err == nil && v2Response.Data.Manifest != "" {
|
||||
// IMPORTANT: Reject PREVIEW responses - we need FULL tracks
|
||||
if v2Response.Data.AssetPresentation == "PREVIEW" {
|
||||
if v2Response.Data.AssetPresentation == "PREVIEW" {
|
||||
resultChan <- tidalAPIResult{apiURL: api, err: fmt.Errorf("returned PREVIEW instead of FULL"), duration: time.Since(reqStart)}
|
||||
return
|
||||
}
|
||||
@@ -715,7 +675,6 @@ func getDownloadURLParallel(apis []string, trackID int64, quality string) (strin
|
||||
GoLog("[Tidal] [Parallel] ✓ Got response from %s (%d-bit/%dHz) in %v\n",
|
||||
result.apiURL, result.info.BitDepth, result.info.SampleRate, result.duration)
|
||||
|
||||
// Don't return immediately - drain remaining results to avoid goroutine leaks
|
||||
go func(remaining int) {
|
||||
for j := 0; j < remaining; j++ {
|
||||
<-resultChan
|
||||
@@ -736,8 +695,6 @@ func getDownloadURLParallel(apis []string, trackID int64, quality string) (strin
|
||||
return "", TidalDownloadInfo{}, fmt.Errorf("all %d Tidal APIs failed. Errors: %v", len(apis), errors)
|
||||
}
|
||||
|
||||
// GetDownloadURL gets download URL for a track - tries ALL APIs in parallel
|
||||
// "Siapa cepat dia dapat" - first successful response wins
|
||||
func (t *TidalDownloader) GetDownloadURL(trackID int64, quality string) (TidalDownloadInfo, error) {
|
||||
apis := t.GetAvailableAPIs()
|
||||
if len(apis) == 0 {
|
||||
@@ -752,7 +709,6 @@ func (t *TidalDownloader) GetDownloadURL(trackID int64, quality string) (TidalDo
|
||||
return info, nil
|
||||
}
|
||||
|
||||
// parseManifest parses Tidal manifest (supports both BTS and DASH formats)
|
||||
func parseManifest(manifestB64 string) (directURL string, initURL string, mediaURLs []string, err error) {
|
||||
manifestBytes, err := base64.StdEncoding.DecodeString(manifestB64)
|
||||
if err != nil {
|
||||
@@ -859,7 +815,6 @@ func (t *TidalDownloader) DownloadFile(downloadURL, outputPath, itemID string) e
|
||||
return t.downloadFromManifest(ctx, strings.TrimPrefix(downloadURL, "MANIFEST:"), outputPath, itemID)
|
||||
}
|
||||
|
||||
// Initialize item progress for direct downloads
|
||||
if itemID != "" {
|
||||
StartItemProgress(itemID)
|
||||
defer CompleteItemProgress(itemID)
|
||||
@@ -952,9 +907,7 @@ func (t *TidalDownloader) downloadFromManifest(ctx context.Context, manifestB64,
|
||||
|
||||
if 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)
|
||||
|
||||
if isDownloadCancelled(itemID) {
|
||||
if isDownloadCancelled(itemID) {
|
||||
return ErrDownloadCancelled
|
||||
}
|
||||
|
||||
@@ -1135,7 +1088,6 @@ func (t *TidalDownloader) downloadFromManifest(ctx context.Context, manifestB64,
|
||||
return nil
|
||||
}
|
||||
|
||||
// TidalDownloadResult contains download result with quality info
|
||||
type TidalDownloadResult struct {
|
||||
FilePath string
|
||||
BitDepth int
|
||||
@@ -1149,12 +1101,10 @@ type TidalDownloadResult struct {
|
||||
ISRC string
|
||||
}
|
||||
|
||||
// artistsMatch checks if the artist names are similar enough
|
||||
func artistsMatch(spotifyArtist, tidalArtist string) bool {
|
||||
normSpotify := strings.ToLower(strings.TrimSpace(spotifyArtist))
|
||||
normTidal := strings.ToLower(strings.TrimSpace(tidalArtist))
|
||||
|
||||
// Exact match
|
||||
if normSpotify == normTidal {
|
||||
return true
|
||||
}
|
||||
@@ -1164,22 +1114,17 @@ func artistsMatch(spotifyArtist, tidalArtist string) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// Split artists by common separators (comma, feat, ft., &, and)
|
||||
// e.g., "RADWIMPS, Toko Miura" or "RADWIMPS feat. Toko Miura"
|
||||
spotifyArtists := splitArtists(normSpotify)
|
||||
tidalArtists := splitArtists(normTidal)
|
||||
|
||||
// Check if ANY expected artist matches ANY found artist
|
||||
for _, exp := range spotifyArtists {
|
||||
for _, fnd := range tidalArtists {
|
||||
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 sameWordsUnordered(exp, fnd) {
|
||||
GoLog("[Tidal] Artist names have same words in different order: '%s' vs '%s'\n", exp, fnd)
|
||||
return true
|
||||
@@ -1187,9 +1132,6 @@ func artistsMatch(spotifyArtist, tidalArtist 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
|
||||
// This handles cases like "鈴木雅之" vs "Masayuki Suzuki"
|
||||
spotifyLatin := isLatinScript(spotifyArtist)
|
||||
tidalLatin := isLatinScript(tidalArtist)
|
||||
if spotifyLatin != tidalLatin {
|
||||
@@ -1200,9 +1142,7 @@ func artistsMatch(spotifyArtist, tidalArtist string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// splitArtists splits artist string by common separators
|
||||
func splitArtists(artists string) []string {
|
||||
// Replace common separators with a standard one
|
||||
normalized := artists
|
||||
normalized = strings.ReplaceAll(normalized, " feat. ", "|")
|
||||
normalized = strings.ReplaceAll(normalized, " feat ", "|")
|
||||
@@ -1224,8 +1164,6 @@ func splitArtists(artists string) []string {
|
||||
return result
|
||||
}
|
||||
|
||||
// sameWordsUnordered checks if two strings have the same words regardless of order
|
||||
// Useful for Japanese names: "Sawano Hiroyuki" vs "Hiroyuki Sawano"
|
||||
func sameWordsUnordered(a, b string) bool {
|
||||
wordsA := strings.Fields(a)
|
||||
wordsB := strings.Fields(b)
|
||||
@@ -1235,13 +1173,11 @@ func sameWordsUnordered(a, b string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// Sort and compare
|
||||
sortedA := make([]string, len(wordsA))
|
||||
sortedB := make([]string, len(wordsB))
|
||||
copy(sortedA, wordsA)
|
||||
copy(sortedB, wordsB)
|
||||
|
||||
// Simple bubble sort (usually just 2-3 words)
|
||||
for i := 0; i < len(sortedA)-1; i++ {
|
||||
for j := i + 1; j < len(sortedA); j++ {
|
||||
if sortedA[i] > sortedA[j] {
|
||||
@@ -1261,7 +1197,6 @@ func sameWordsUnordered(a, b string) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// 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))
|
||||
@@ -1271,7 +1206,6 @@ func titlesMatch(expectedTitle, foundTitle string) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check if one contains the other
|
||||
if strings.Contains(normExpected, normFound) || strings.Contains(normFound, normExpected) {
|
||||
return true
|
||||
}
|
||||
@@ -1284,7 +1218,6 @@ func titlesMatch(expectedTitle, foundTitle string) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check if cleaned versions contain each other
|
||||
if cleanExpected != "" && cleanFound != "" {
|
||||
if strings.Contains(cleanExpected, cleanFound) || strings.Contains(cleanFound, cleanExpected) {
|
||||
return true
|
||||
@@ -1299,7 +1232,6 @@ func titlesMatch(expectedTitle, foundTitle string) bool {
|
||||
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)
|
||||
@@ -1311,9 +1243,7 @@ func titlesMatch(expectedTitle, foundTitle string) bool {
|
||||
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, " - ")
|
||||
@@ -1332,18 +1262,15 @@ func extractCoreTitle(title string) string {
|
||||
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, ")")
|
||||
@@ -1364,7 +1291,6 @@ func cleanTitle(title string) string {
|
||||
break
|
||||
}
|
||||
|
||||
// Same for brackets
|
||||
for {
|
||||
startBracket := strings.LastIndex(cleaned, "[")
|
||||
endBracket := strings.LastIndex(cleaned, "]")
|
||||
@@ -1385,7 +1311,6 @@ func cleanTitle(title string) string {
|
||||
break
|
||||
}
|
||||
|
||||
// Remove trailing " - version" patterns
|
||||
dashPatterns := []string{
|
||||
" - remaster", " - remastered", " - single version", " - radio edit",
|
||||
" - live", " - acoustic", " - demo", " - remix",
|
||||
@@ -1396,7 +1321,6 @@ func cleanTitle(title string) string {
|
||||
}
|
||||
}
|
||||
|
||||
// Remove multiple spaces
|
||||
for strings.Contains(cleaned, " ") {
|
||||
cleaned = strings.ReplaceAll(cleaned, " ", " ")
|
||||
}
|
||||
@@ -1404,48 +1328,29 @@ func cleanTitle(title string) string {
|
||||
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)
|
||||
if (r >= 0x0100 && r <= 0x024F) ||
|
||||
(r >= 0x1E00 && r <= 0x1EFF) ||
|
||||
(r >= 0x00C0 && r <= 0x00FF) {
|
||||
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
|
||||
if (r >= 0x4E00 && r <= 0x9FFF) ||
|
||||
(r >= 0x3040 && r <= 0x309F) ||
|
||||
(r >= 0x30A0 && r <= 0x30FF) ||
|
||||
(r >= 0xAC00 && r <= 0xD7AF) ||
|
||||
(r >= 0x0600 && r <= 0x06FF) ||
|
||||
(r >= 0x0400 && r <= 0x04FF) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// isASCIIString checks if a string contains only ASCII characters
|
||||
// Kept for potential future use
|
||||
// func isASCIIString(s string) bool {
|
||||
// for _, r := range s {
|
||||
// if r > 127 {
|
||||
// return false
|
||||
// }
|
||||
// }
|
||||
// return true
|
||||
// }
|
||||
|
||||
// downloadFromTidal downloads a track using the request parameters
|
||||
func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
||||
downloader := NewTidalDownloader()
|
||||
|
||||
@@ -1453,16 +1358,13 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
||||
return TidalDownloadResult{FilePath: "EXISTS:" + existingFile}, nil
|
||||
}
|
||||
|
||||
// Convert expected duration from ms to seconds
|
||||
expectedDurationSec := req.DurationMS / 1000
|
||||
|
||||
var track *TidalTrack
|
||||
var err error
|
||||
|
||||
// STRATEGY 0: Use pre-fetched Tidal ID from Odesli enrichment (highest priority)
|
||||
if req.TidalID != "" {
|
||||
GoLog("[Tidal] Using Tidal ID from Odesli enrichment: %s\n", req.TidalID)
|
||||
// Parse track ID (could be a number or extracted from URL)
|
||||
var trackID int64
|
||||
if _, parseErr := fmt.Sscanf(req.TidalID, "%d", &trackID); parseErr == nil && trackID > 0 {
|
||||
track, err = downloader.GetTrackInfoByID(trackID)
|
||||
@@ -1475,7 +1377,6 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// OPTIMIZATION: Check cache first for track ID
|
||||
if track == nil && req.ISRC != "" {
|
||||
if cached := GetTrackIDCache().Get(req.ISRC); cached != nil && cached.TidalTrackID > 0 {
|
||||
GoLog("[Tidal] Cache hit! Using cached track ID: %d\n", cached.TidalTrackID)
|
||||
@@ -1487,8 +1388,6 @@ 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 != "" {
|
||||
GoLog("[Tidal] Trying ISRC search: %s\n", req.ISRC)
|
||||
track, err = downloader.SearchTrackByMetadataWithISRC(req.TrackName, req.ArtistName, req.ISRC, expectedDurationSec)
|
||||
@@ -1510,7 +1409,6 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// Strategy 2: Try SongLink if we have Spotify ID
|
||||
if track == nil && req.SpotifyID != "" {
|
||||
GoLog("[Tidal] ISRC search failed, trying SongLink...\n")
|
||||
var tidalURL string
|
||||
@@ -1545,13 +1443,11 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
||||
track = nil
|
||||
}
|
||||
|
||||
// Verify duration if we have expected duration
|
||||
if track != nil && expectedDurationSec > 0 {
|
||||
durationDiff := track.Duration - expectedDurationSec
|
||||
if durationDiff < 0 {
|
||||
durationDiff = -durationDiff
|
||||
}
|
||||
// Allow 3 seconds tolerance (same as PC version)
|
||||
if durationDiff > 3 {
|
||||
GoLog("[Tidal] Duration mismatch from SongLink: expected %ds, got %ds. Rejecting.\n",
|
||||
expectedDurationSec, track.Duration)
|
||||
@@ -1563,11 +1459,9 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// Strategy 3: Search by metadata only (no ISRC requirement) - last resort
|
||||
if track == nil {
|
||||
GoLog("[Tidal] Trying metadata search as last resort...\n")
|
||||
track, err = downloader.SearchTrackByMetadataWithISRC(req.TrackName, req.ArtistName, "", expectedDurationSec)
|
||||
// Verify artist AND title for metadata search
|
||||
if track != nil {
|
||||
tidalArtist := track.Artist.Name
|
||||
if len(track.Artists) > 0 {
|
||||
@@ -1578,7 +1472,6 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
||||
tidalArtist = strings.Join(artistNames, ", ")
|
||||
}
|
||||
|
||||
// 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)
|
||||
@@ -1599,7 +1492,6 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
||||
return TidalDownloadResult{}, fmt.Errorf("tidal search failed: %s", errMsg)
|
||||
}
|
||||
|
||||
// Final verification logging
|
||||
tidalArtist := track.Artist.Name
|
||||
if len(track.Artists) > 0 {
|
||||
var artistNames []string
|
||||
@@ -1633,7 +1525,6 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
||||
return TidalDownloadResult{FilePath: "EXISTS:" + m4aPath}, nil
|
||||
}
|
||||
|
||||
// Clean up any leftover .tmp files from previous failed downloads
|
||||
tmpPath := outputPath + ".m4a.tmp"
|
||||
if _, err := os.Stat(tmpPath); err == nil {
|
||||
GoLog("[Tidal] Cleaning up leftover temp file: %s\n", tmpPath)
|
||||
@@ -1651,10 +1542,8 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
||||
return TidalDownloadResult{}, fmt.Errorf("failed to get download URL: %w", err)
|
||||
}
|
||||
|
||||
// Log actual quality received
|
||||
GoLog("[Tidal] Actual quality: %d-bit/%dHz\n", downloadInfo.BitDepth, downloadInfo.SampleRate)
|
||||
|
||||
// START PARALLEL: Fetch cover and lyrics while downloading audio
|
||||
var parallelResult *ParallelDownloadResult
|
||||
parallelDone := make(chan struct{})
|
||||
go func() {
|
||||
@@ -1670,7 +1559,6 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
||||
)
|
||||
}()
|
||||
|
||||
// Download audio file with item ID for progress tracking
|
||||
GoLog("[Tidal] Starting download to: %s\n", outputPath)
|
||||
GoLog("[Tidal] Download URL type: %s\n", func() string {
|
||||
if strings.HasPrefix(downloadInfo.URL, "MANIFEST:") {
|
||||
@@ -1688,7 +1576,6 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
||||
}
|
||||
fmt.Println("[Tidal] Download completed successfully")
|
||||
|
||||
// Wait for parallel operations to complete
|
||||
<-parallelDone
|
||||
|
||||
if req.ItemID != "" {
|
||||
@@ -1701,12 +1588,9 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
||||
actualOutputPath = m4aPath
|
||||
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)
|
||||
}
|
||||
|
||||
// Embed metadata using parallel-fetched cover data
|
||||
// Use release date from Tidal API if not provided in request
|
||||
releaseDate := req.ReleaseDate
|
||||
if releaseDate == "" && track.Album.ReleaseDate != "" {
|
||||
releaseDate = track.Album.ReleaseDate
|
||||
@@ -1719,13 +1603,13 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
||||
Album: req.AlbumName,
|
||||
AlbumArtist: req.AlbumArtist,
|
||||
Date: releaseDate,
|
||||
TrackNumber: track.TrackNumber, // Use actual track number from Tidal
|
||||
TrackNumber: track.TrackNumber,
|
||||
TotalTracks: req.TotalTracks,
|
||||
DiscNumber: track.VolumeNumber, // Use actual disc number from Tidal
|
||||
ISRC: track.ISRC, // Use actual ISRC from Tidal
|
||||
Genre: req.Genre, // From Deezer album metadata
|
||||
Label: req.Label, // From Deezer album metadata
|
||||
Copyright: req.Copyright, // From Deezer album metadata
|
||||
DiscNumber: track.VolumeNumber,
|
||||
ISRC: track.ISRC,
|
||||
Genre: req.Genre,
|
||||
Label: req.Label,
|
||||
Copyright: req.Copyright,
|
||||
}
|
||||
|
||||
var coverData []byte
|
||||
@@ -1734,21 +1618,17 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
||||
GoLog("[Tidal] Using parallel-fetched cover (%d bytes)\n", len(coverData))
|
||||
}
|
||||
|
||||
// Embed metadata based on file type
|
||||
if strings.HasSuffix(actualOutputPath, ".flac") {
|
||||
if err := EmbedMetadataWithCoverData(actualOutputPath, metadata, coverData); err != nil {
|
||||
fmt.Printf("Warning: failed to embed metadata: %v\n", err)
|
||||
}
|
||||
|
||||
// Handle lyrics based on LyricsMode setting
|
||||
// Mode: "embed" (default), "external" (.lrc file), "both"
|
||||
if req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" {
|
||||
lyricsMode := req.LyricsMode
|
||||
if lyricsMode == "" {
|
||||
lyricsMode = "embed" // default
|
||||
lyricsMode = "embed"
|
||||
}
|
||||
|
||||
// Save external .lrc file if mode is "external" or "both"
|
||||
if lyricsMode == "external" || lyricsMode == "both" {
|
||||
GoLog("[Tidal] Saving external LRC file...\n")
|
||||
if lrcPath, lrcErr := SaveLRCFile(actualOutputPath, parallelResult.LyricsLRC); lrcErr != nil {
|
||||
@@ -1758,7 +1638,6 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// Embed lyrics if mode is "embed" or "both"
|
||||
if lyricsMode == "embed" || lyricsMode == "both" {
|
||||
GoLog("[Tidal] Embedding parallel-fetched lyrics (%d lines)...\n", len(parallelResult.LyricsData.Lines))
|
||||
if embedErr := EmbedLyrics(actualOutputPath, parallelResult.LyricsLRC); embedErr != nil {
|
||||
@@ -1771,28 +1650,9 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
||||
fmt.Println("[Tidal] No lyrics available from parallel fetch")
|
||||
}
|
||||
} else if strings.HasSuffix(actualOutputPath, ".m4a") {
|
||||
// Embed metadata to M4A file
|
||||
// GoLog("[Tidal] Embedding metadata to M4A file...\n")
|
||||
|
||||
// Add lyrics to metadata if available
|
||||
// if req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" {
|
||||
// metadata.Lyrics = parallelResult.LyricsLRC
|
||||
// }
|
||||
|
||||
// SKIP metadata embedding for M4A to prevent issues with FFmpeg conversion
|
||||
// M4A files from DASH are often fragmented and editing metadata might corrupt the container
|
||||
// structure that FFmpeg expects. Metadata will be re-embedded after conversion to FLAC in Flutter.
|
||||
|
||||
fmt.Println("[Tidal] Skipping metadata embedding for M4A file (will be handled after FFmpeg conversion)")
|
||||
|
||||
// if err := EmbedM4AMetadata(actualOutputPath, metadata, coverData); err != nil {
|
||||
// GoLog("[Tidal] Warning: failed to embed M4A metadata: %v\n", err)
|
||||
// } else {
|
||||
// fmt.Println("[Tidal] M4A metadata embedded successfully")
|
||||
// }
|
||||
}
|
||||
|
||||
// Add to ISRC index for fast duplicate checking
|
||||
AddToISRCIndex(req.OutputDir, req.ISRC, actualOutputPath)
|
||||
|
||||
return TidalDownloadResult{
|
||||
|
||||
Reference in New Issue
Block a user