mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-05-28 18:41:31 +02:00
refactor: cleanup unused code and imports
This commit is contained in:
@@ -60,12 +60,10 @@ func amazonArtistsMatch(expectedArtist, foundArtist string) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check if one contains the other
|
||||
if strings.Contains(normExpected, normFound) || strings.Contains(normFound, normExpected) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check first artist (before comma or feat)
|
||||
expectedFirst := strings.Split(normExpected, ",")[0]
|
||||
expectedFirst = strings.Split(expectedFirst, " feat")[0]
|
||||
expectedFirst = strings.Split(expectedFirst, " ft.")[0]
|
||||
@@ -80,7 +78,6 @@ func amazonArtistsMatch(expectedArtist, foundArtist string) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check if first artist is contained in the other
|
||||
if strings.Contains(expectedFirst, foundFirst) || strings.Contains(foundFirst, expectedFirst) {
|
||||
return true
|
||||
}
|
||||
@@ -127,7 +124,6 @@ func (a *AmazonDownloader) waitForRateLimit() {
|
||||
|
||||
now := time.Now()
|
||||
|
||||
// Reset counter every minute
|
||||
if now.Sub(a.apiCallResetTime) >= time.Minute {
|
||||
a.apiCallCount = 0
|
||||
a.apiCallResetTime = now
|
||||
@@ -155,7 +151,6 @@ func (a *AmazonDownloader) waitForRateLimit() {
|
||||
}
|
||||
}
|
||||
|
||||
// Update tracking
|
||||
a.lastAPICallTime = time.Now()
|
||||
a.apiCallCount++
|
||||
}
|
||||
@@ -181,8 +176,6 @@ func (a *AmazonDownloader) downloadFromDoubleDoubleService(amazonURL, _ string)
|
||||
for _, region := range a.regions {
|
||||
GoLog("[Amazon] Trying region: %s...\n", region)
|
||||
|
||||
// Build base URL for DoubleDouble service
|
||||
// Decode base64 service URL (same as PC)
|
||||
serviceBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly8=") // https://
|
||||
serviceDomain, _ := base64.StdEncoding.DecodeString("LmRvdWJsZWRvdWJsZS50b3A=") // .doubledouble.top
|
||||
baseURL := fmt.Sprintf("%s%s%s", string(serviceBase), region, string(serviceDomain))
|
||||
@@ -301,7 +294,6 @@ func (a *AmazonDownloader) downloadFromDoubleDoubleService(amazonURL, _ string)
|
||||
if status.Status == "done" {
|
||||
fmt.Println("\n[Amazon] Download ready!")
|
||||
|
||||
// Build download URL
|
||||
fileURL := status.URL
|
||||
if strings.HasPrefix(fileURL, "./") {
|
||||
fileURL = fmt.Sprintf("%s/%s", baseURL, fileURL[2:])
|
||||
@@ -383,7 +375,6 @@ func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath, itemID string)
|
||||
}
|
||||
|
||||
expectedSize := resp.ContentLength
|
||||
// Set total bytes if available
|
||||
if expectedSize > 0 && itemID != "" {
|
||||
SetItemBytesTotal(itemID, expectedSize)
|
||||
}
|
||||
@@ -393,16 +384,13 @@ func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath, itemID string)
|
||||
return err
|
||||
}
|
||||
|
||||
// Use buffered writer for better performance (256KB buffer)
|
||||
bufWriter := bufio.NewWriterSize(out, 256*1024)
|
||||
|
||||
// Use item progress writer with buffered output
|
||||
var written int64
|
||||
if itemID != "" {
|
||||
pw := NewItemProgressWriter(bufWriter, itemID)
|
||||
written, err = io.Copy(pw, resp.Body)
|
||||
} else {
|
||||
// Fallback: direct copy without progress tracking
|
||||
written, err = io.Copy(bufWriter, resp.Body)
|
||||
}
|
||||
|
||||
@@ -410,7 +398,6 @@ func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath, itemID string)
|
||||
flushErr := bufWriter.Flush()
|
||||
closeErr := out.Close()
|
||||
|
||||
// Check for any errors
|
||||
if err != nil {
|
||||
os.Remove(outputPath)
|
||||
if isDownloadCancelled(itemID) {
|
||||
@@ -456,24 +443,19 @@ type AmazonDownloadResult struct {
|
||||
func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
||||
downloader := NewAmazonDownloader()
|
||||
|
||||
// Check for existing file first
|
||||
if existingFile, exists := checkISRCExistsInternal(req.OutputDir, req.ISRC); exists {
|
||||
return AmazonDownloadResult{FilePath: "EXISTS:" + existingFile}, nil
|
||||
}
|
||||
|
||||
// Get Amazon URL from SongLink
|
||||
songlink := NewSongLinkClient()
|
||||
var availability *TrackAvailability
|
||||
var err error
|
||||
|
||||
// Check if SpotifyID is actually a Deezer ID (format: "deezer:xxxxx")
|
||||
if strings.HasPrefix(req.SpotifyID, "deezer:") {
|
||||
// Extract Deezer ID and use Deezer-based lookup
|
||||
deezerID := strings.TrimPrefix(req.SpotifyID, "deezer:")
|
||||
GoLog("[Amazon] Using Deezer ID for SongLink lookup: %s\n", deezerID)
|
||||
availability, err = songlink.CheckAvailabilityFromDeezer(deezerID)
|
||||
} else if req.SpotifyID != "" {
|
||||
// Use Spotify ID
|
||||
availability, err = songlink.CheckTrackAvailability(req.SpotifyID, req.ISRC)
|
||||
} else {
|
||||
return AmazonDownloadResult{}, fmt.Errorf("no valid Spotify or Deezer ID provided for Amazon lookup")
|
||||
@@ -487,7 +469,6 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
||||
return AmazonDownloadResult{}, fmt.Errorf("track not available on Amazon Music (SongLink returned no Amazon URL)")
|
||||
}
|
||||
|
||||
// Create output directory if needed
|
||||
if req.OutputDir != "." {
|
||||
if err := os.MkdirAll(req.OutputDir, 0755); err != nil {
|
||||
return AmazonDownloadResult{}, fmt.Errorf("failed to create output directory: %w", err)
|
||||
@@ -506,10 +487,8 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
||||
return AmazonDownloadResult{}, fmt.Errorf("artist mismatch: expected '%s', got '%s'", req.ArtistName, artistName)
|
||||
}
|
||||
|
||||
// Log match found
|
||||
GoLog("[Amazon] Match found: '%s' by '%s'\n", trackName, artistName)
|
||||
|
||||
// Build filename using Spotify metadata (more accurate)
|
||||
filename := buildFilenameFromTemplate(req.FilenameFormat, map[string]interface{}{
|
||||
"title": req.TrackName,
|
||||
"artist": req.ArtistName,
|
||||
@@ -521,7 +500,6 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
||||
filename = sanitizeFilename(filename) + ".flac"
|
||||
outputPath := filepath.Join(req.OutputDir, filename)
|
||||
|
||||
// Check if file already exists
|
||||
if fileInfo, statErr := os.Stat(outputPath); statErr == nil && fileInfo.Size() > 0 {
|
||||
return AmazonDownloadResult{FilePath: "EXISTS:" + outputPath}, nil
|
||||
}
|
||||
@@ -552,8 +530,6 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
||||
// Wait for parallel operations to complete
|
||||
<-parallelDone
|
||||
|
||||
// Set progress to 100% and status to finalizing (before embedding)
|
||||
// This makes the UI show "Finalizing..." while embedding happens
|
||||
if req.ItemID != "" {
|
||||
SetItemProgress(req.ItemID, 1.0, 0, 0)
|
||||
SetItemFinalizing(req.ItemID)
|
||||
@@ -564,14 +540,11 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
||||
GoLog("[Amazon] DoubleDouble returned: %s - %s\n", artistName, trackName)
|
||||
}
|
||||
|
||||
// Read existing metadata from downloaded file BEFORE embedding
|
||||
// Amazon/DoubleDouble files often have correct track/disc numbers that we should preserve
|
||||
existingMeta, metaErr := ReadMetadata(outputPath)
|
||||
actualTrackNum := req.TrackNumber
|
||||
actualDiscNum := req.DiscNumber
|
||||
|
||||
if metaErr == nil && existingMeta != nil {
|
||||
// Use file metadata if it has valid track/disc numbers and request doesn't have them
|
||||
if existingMeta.TrackNumber > 0 && (req.TrackNumber == 0 || req.TrackNumber == 1) {
|
||||
actualTrackNum = existingMeta.TrackNumber
|
||||
GoLog("[Amazon] Using track number from file: %d (request had: %d)\n", actualTrackNum, req.TrackNumber)
|
||||
@@ -621,8 +594,6 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
||||
|
||||
fmt.Println("[Amazon] ✓ Downloaded successfully from Amazon Music")
|
||||
|
||||
// Read actual quality from the downloaded FLAC file
|
||||
// Amazon API doesn't provide quality info, but we can read it from the file itself
|
||||
quality, err := GetAudioQuality(outputPath)
|
||||
if err != nil {
|
||||
GoLog("[Amazon] Warning: couldn't read quality from file: %v\n", err)
|
||||
@@ -630,8 +601,6 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
||||
GoLog("[Amazon] Actual quality: %d-bit/%dHz\n", quality.BitDepth, quality.SampleRate)
|
||||
}
|
||||
|
||||
// Read metadata from file AFTER embedding to get accurate values
|
||||
// This ensures we return what's actually in the file
|
||||
finalMeta, metaReadErr := ReadMetadata(outputPath)
|
||||
if metaReadErr == nil && finalMeta != nil {
|
||||
GoLog("[Amazon] Final metadata from file - Track: %d, Disc: %d, Date: %s\n",
|
||||
@@ -639,7 +608,6 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
||||
actualTrackNum = finalMeta.TrackNumber
|
||||
actualDiscNum = finalMeta.DiscNumber
|
||||
if finalMeta.Date != "" {
|
||||
// Use date from file if available
|
||||
req.ReleaseDate = finalMeta.Date
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,7 +113,6 @@ func (c *DeezerClient) convertTrack(track deezerTrack) TrackMetadata {
|
||||
albumImage = track.Album.Cover
|
||||
}
|
||||
|
||||
// Try to find release date
|
||||
releaseDate := track.ReleaseDate
|
||||
if releaseDate == "" {
|
||||
releaseDate = track.Album.ReleaseDate
|
||||
@@ -541,7 +540,6 @@ func (c *DeezerClient) SearchByISRC(ctx context.Context, isrc string) (*TrackMet
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
// Check if we got a valid response (ID > 0)
|
||||
if track.ID == 0 {
|
||||
return nil, fmt.Errorf("no track found for ISRC: %s", isrc)
|
||||
}
|
||||
@@ -564,7 +562,6 @@ func (c *DeezerClient) fetchISRCsParallel(ctx context.Context, tracks []deezerTr
|
||||
result := make(map[string]string)
|
||||
var resultMu sync.Mutex
|
||||
|
||||
// First, check cache for existing ISRCs
|
||||
var tracksToFetch []deezerTrack
|
||||
c.cacheMu.RLock()
|
||||
for _, track := range tracks {
|
||||
@@ -622,7 +619,6 @@ func (c *DeezerClient) fetchISRCsParallel(ctx context.Context, tracks []deezerTr
|
||||
// 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) {
|
||||
// Check cache first
|
||||
c.cacheMu.RLock()
|
||||
if isrc, ok := c.isrcCache[trackID]; ok {
|
||||
c.cacheMu.RUnlock()
|
||||
|
||||
@@ -36,7 +36,6 @@ func GetISRCIndex(outputDir string) *ISRCIndex {
|
||||
return idx
|
||||
}
|
||||
|
||||
// Build new index
|
||||
return buildISRCIndex(outputDir)
|
||||
}
|
||||
|
||||
@@ -56,7 +55,6 @@ func buildISRCIndex(outputDir string) *ISRCIndex {
|
||||
startTime := time.Now()
|
||||
fileCount := 0
|
||||
|
||||
// Walk directory - only check .flac files
|
||||
filepath.Walk(outputDir, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil || info.IsDir() {
|
||||
return nil
|
||||
@@ -67,13 +65,11 @@ func buildISRCIndex(outputDir string) *ISRCIndex {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Read ISRC from file
|
||||
metadata, err := ReadMetadata(path)
|
||||
if err != nil || metadata.ISRC == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Store in index (uppercase for case-insensitive matching)
|
||||
idx.index[strings.ToUpper(metadata.ISRC)] = path
|
||||
fileCount++
|
||||
return nil
|
||||
@@ -82,7 +78,6 @@ func buildISRCIndex(outputDir string) *ISRCIndex {
|
||||
fmt.Printf("[ISRCIndex] Built index for %s: %d files in %v\n",
|
||||
outputDir, fileCount, time.Since(startTime).Round(time.Millisecond))
|
||||
|
||||
// Cache the index
|
||||
isrcIndexCacheMu.Lock()
|
||||
isrcIndexCache[outputDir] = idx
|
||||
isrcIndexCacheMu.Unlock()
|
||||
@@ -205,10 +200,8 @@ func CheckFilesExistParallel(outputDir string, tracksJSON string) (string, error
|
||||
|
||||
results := make([]FileExistenceResult, len(tracks))
|
||||
|
||||
// Build ISRC index from output directory (scan once)
|
||||
isrcIdx := GetISRCIndex(outputDir)
|
||||
|
||||
// Check each track against the index (parallel)
|
||||
var wg sync.WaitGroup
|
||||
for i, track := range tracks {
|
||||
wg.Add(1)
|
||||
|
||||
@@ -283,10 +283,8 @@ func DownloadTrack(requestJSON string) (string, error) {
|
||||
return errorResponse(err.Error())
|
||||
}
|
||||
|
||||
// Check if file already exists
|
||||
if len(result.FilePath) > 7 && result.FilePath[:7] == "EXISTS:" {
|
||||
actualPath := result.FilePath[7:]
|
||||
// Read actual quality from existing file
|
||||
quality, qErr := GetAudioQuality(actualPath)
|
||||
if qErr == nil {
|
||||
result.BitDepth = quality.BitDepth
|
||||
@@ -312,7 +310,6 @@ func DownloadTrack(requestJSON string) (string, error) {
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
// Read actual quality from downloaded file (more accurate than API)
|
||||
quality, qErr := GetAudioQuality(result.FilePath)
|
||||
if qErr == nil {
|
||||
result.BitDepth = quality.BitDepth
|
||||
@@ -362,7 +359,6 @@ func DownloadWithFallback(requestJSON string) (string, error) {
|
||||
AddAllowedDownloadDir(req.OutputDir)
|
||||
}
|
||||
|
||||
// Build service order starting with preferred service
|
||||
allServices := []string{"tidal", "qobuz", "amazon"}
|
||||
preferredService := req.Service
|
||||
if preferredService == "" {
|
||||
@@ -371,7 +367,6 @@ func DownloadWithFallback(requestJSON string) (string, error) {
|
||||
|
||||
GoLog("[DownloadWithFallback] Preferred service from request: '%s'\n", req.Service)
|
||||
|
||||
// Create ordered list: preferred first, then others
|
||||
services := []string{preferredService}
|
||||
for _, s := range allServices {
|
||||
if s != preferredService {
|
||||
@@ -455,10 +450,8 @@ func DownloadWithFallback(requestJSON string) (string, error) {
|
||||
}
|
||||
|
||||
if err == nil {
|
||||
// Check if file already exists
|
||||
if len(result.FilePath) > 7 && result.FilePath[:7] == "EXISTS:" {
|
||||
actualPath := result.FilePath[7:]
|
||||
// Read actual quality from existing file
|
||||
quality, qErr := GetAudioQuality(actualPath)
|
||||
if qErr == nil {
|
||||
result.BitDepth = quality.BitDepth
|
||||
@@ -484,7 +477,6 @@ func DownloadWithFallback(requestJSON string) (string, error) {
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
// Read actual quality from downloaded file (more accurate than API)
|
||||
quality, qErr := GetAudioQuality(result.FilePath)
|
||||
if qErr == nil {
|
||||
result.BitDepth = quality.BitDepth
|
||||
@@ -567,10 +559,8 @@ func ReadFileMetadata(filePath string) (string, error) {
|
||||
return "", fmt.Errorf("failed to read metadata: %w", err)
|
||||
}
|
||||
|
||||
// Also get audio quality info
|
||||
quality, qualityErr := GetAudioQuality(filePath)
|
||||
|
||||
// Get duration from FLAC stream info
|
||||
duration := 0
|
||||
if qualityErr == nil && quality.SampleRate > 0 && quality.TotalSamples > 0 {
|
||||
duration = int(quality.TotalSamples / int64(quality.SampleRate))
|
||||
@@ -640,7 +630,6 @@ func PreBuildDuplicateIndex(outputDir string) error {
|
||||
}
|
||||
|
||||
// InvalidateDuplicateIndex clears the ISRC index cache for a directory
|
||||
// Call this when files are deleted or moved
|
||||
func InvalidateDuplicateIndex(outputDir string) {
|
||||
InvalidateISRCCache(outputDir)
|
||||
}
|
||||
@@ -703,7 +692,6 @@ func GetLyricsLRC(spotifyID, trackName, artistName string, filePath string) (str
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Convert to LRC format with metadata headers (like PC version)
|
||||
lrcContent := convertToLRCWithMetadata(lyricsData, trackName, artistName)
|
||||
return lrcContent, nil
|
||||
}
|
||||
@@ -740,7 +728,6 @@ func PreWarmTrackCacheJSON(tracksJSON string) (string, error) {
|
||||
return errorResponse("Invalid JSON: " + err.Error())
|
||||
}
|
||||
|
||||
// Convert to PreWarmCacheRequest
|
||||
requests := make([]PreWarmCacheRequest, len(tracks))
|
||||
for i, t := range tracks {
|
||||
requests[i] = PreWarmCacheRequest{
|
||||
@@ -872,7 +859,6 @@ func SearchDeezerByISRC(isrc string) (string, error) {
|
||||
}
|
||||
|
||||
// ConvertSpotifyToDeezer converts a Spotify track/album ID to Deezer and fetches metadata
|
||||
// This uses SongLink API to find the Deezer equivalent, then fetches from Deezer
|
||||
// Useful when Spotify API is rate limited
|
||||
func ConvertSpotifyToDeezer(resourceType, spotifyID string) (string, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
@@ -881,7 +867,6 @@ func ConvertSpotifyToDeezer(resourceType, spotifyID string) (string, error) {
|
||||
songlink := NewSongLinkClient()
|
||||
deezerClient := GetDeezerClient()
|
||||
|
||||
// For tracks, we can use SongLink to get Deezer ID
|
||||
if resourceType == "track" {
|
||||
deezerID, err := songlink.GetDeezerIDFromSpotify(spotifyID)
|
||||
if err != nil {
|
||||
@@ -902,7 +887,6 @@ func ConvertSpotifyToDeezer(resourceType, spotifyID string) (string, error) {
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
// For albums, SongLink also provides mapping
|
||||
if resourceType == "album" {
|
||||
deezerID, err := songlink.GetDeezerAlbumIDFromSpotify(spotifyID)
|
||||
if err != nil {
|
||||
@@ -947,7 +931,6 @@ func GetSpotifyMetadataWithDeezerFallback(spotifyURL string) (string, error) {
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
// Check if it's a rate limit error
|
||||
errStr := strings.ToLower(err.Error())
|
||||
if !strings.Contains(errStr, "429") && !strings.Contains(errStr, "rate") && !strings.Contains(errStr, "limit") {
|
||||
// Not a rate limit error, return original error
|
||||
@@ -964,7 +947,6 @@ func GetSpotifyMetadataWithDeezerFallback(spotifyURL string) (string, error) {
|
||||
GoLog("[Fallback] Spotify rate limited for %s, trying Deezer...\n", parsed.Type)
|
||||
|
||||
if parsed.Type == "track" || parsed.Type == "album" {
|
||||
// Convert to Deezer
|
||||
return ConvertSpotifyToDeezer(parsed.Type, parsed.ID)
|
||||
}
|
||||
|
||||
@@ -1372,7 +1354,6 @@ func IsExtensionAuthenticatedByID(extensionID string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check if token is expired
|
||||
if state.IsAuthenticated && !state.ExpiresAt.IsZero() && time.Now().After(state.ExpiresAt) {
|
||||
return false
|
||||
}
|
||||
@@ -1518,7 +1499,6 @@ func CustomSearchWithExtensionJSON(extensionID, query string, optionsJSON string
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Convert to map format for Flutter, ensuring images field is set
|
||||
result := make([]map[string]interface{}, len(tracks))
|
||||
for i, track := range tracks {
|
||||
result[i] = map[string]interface{}{
|
||||
@@ -1585,12 +1565,10 @@ func HandleURLWithExtensionJSON(url string) (string, error) {
|
||||
result := resultWithID.Result
|
||||
extensionID := resultWithID.ExtensionID
|
||||
|
||||
// Check if result is nil (handler found but returned error)
|
||||
if result == nil {
|
||||
return "", fmt.Errorf("extension %s failed to handle URL", extensionID)
|
||||
}
|
||||
|
||||
// Build response
|
||||
response := map[string]interface{}{
|
||||
"type": result.Type,
|
||||
"extension_id": extensionID,
|
||||
@@ -1758,10 +1736,8 @@ func GetAlbumWithExtensionJSON(extensionID, albumID string) (string, error) {
|
||||
return "", fmt.Errorf("album not found")
|
||||
}
|
||||
|
||||
// Convert tracks to map format
|
||||
tracks := make([]map[string]interface{}, len(album.Tracks))
|
||||
for i, track := range album.Tracks {
|
||||
// Use album cover as fallback if track doesn't have its own cover
|
||||
trackCover := track.ResolvedCoverURL()
|
||||
if trackCover == "" {
|
||||
trackCover = album.CoverURL
|
||||
@@ -1818,7 +1794,6 @@ func GetPlaylistWithExtensionJSON(extensionID, playlistID string) (string, error
|
||||
|
||||
provider := NewExtensionProviderWrapper(ext)
|
||||
|
||||
// Try getPlaylist first, fall back to getAlbum (some extensions use album for playlists)
|
||||
script := fmt.Sprintf(`
|
||||
(function() {
|
||||
if (typeof extension !== 'undefined' && typeof extension.getPlaylist === 'function') {
|
||||
@@ -1856,10 +1831,8 @@ func GetPlaylistWithExtensionJSON(extensionID, playlistID string) (string, error
|
||||
album.Tracks[i].ProviderID = ext.ID
|
||||
}
|
||||
|
||||
// Convert tracks to map format
|
||||
tracks := make([]map[string]interface{}, len(album.Tracks))
|
||||
for i, track := range album.Tracks {
|
||||
// Use playlist cover as fallback if track doesn't have its own cover
|
||||
trackCover := track.ResolvedCoverURL()
|
||||
if trackCover == "" {
|
||||
trackCover = album.CoverURL
|
||||
@@ -1922,7 +1895,6 @@ func GetArtistWithExtensionJSON(extensionID, artistID string) (string, error) {
|
||||
return "", fmt.Errorf("artist not found")
|
||||
}
|
||||
|
||||
// Convert albums to map format
|
||||
albums := make([]map[string]interface{}, len(artist.Albums))
|
||||
for i, album := range artist.Albums {
|
||||
albums[i] = map[string]interface{}{
|
||||
|
||||
@@ -92,7 +92,6 @@ func (m *ExtensionManager) SetDirectories(extensionsDir, dataDir string) error {
|
||||
m.extensionsDir = extensionsDir
|
||||
m.dataDir = dataDir
|
||||
|
||||
// Create directories if they don't exist
|
||||
if err := os.MkdirAll(extensionsDir, 0755); err != nil {
|
||||
return fmt.Errorf("failed to create extensions directory: %w", err)
|
||||
}
|
||||
@@ -117,7 +116,6 @@ func (m *ExtensionManager) LoadExtensionFromFile(filePath string) (*LoadedExtens
|
||||
}
|
||||
defer zipReader.Close()
|
||||
|
||||
// Find and read manifest.json
|
||||
var manifestData []byte
|
||||
var hasIndexJS bool
|
||||
for _, file := range zipReader.File {
|
||||
@@ -146,13 +144,11 @@ func (m *ExtensionManager) LoadExtensionFromFile(filePath string) (*LoadedExtens
|
||||
return nil, fmt.Errorf("Invalid extension package: index.js not found")
|
||||
}
|
||||
|
||||
// Parse and validate manifest
|
||||
manifest, err := ParseManifest(manifestData)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Invalid extension manifest: %w", err)
|
||||
}
|
||||
|
||||
// Check if extension already loaded - if so, try upgrade (check without holding lock for long)
|
||||
m.mu.RLock()
|
||||
existing, exists := m.extensions[manifest.Name]
|
||||
var existingVersion string
|
||||
@@ -164,7 +160,6 @@ func (m *ExtensionManager) LoadExtensionFromFile(filePath string) (*LoadedExtens
|
||||
m.mu.RUnlock()
|
||||
|
||||
if exists {
|
||||
// Check if this is an upgrade
|
||||
versionCompare := compareVersions(manifest.Version, existingVersion)
|
||||
if versionCompare > 0 {
|
||||
// This is an upgrade - call UpgradeExtension
|
||||
@@ -176,16 +171,13 @@ func (m *ExtensionManager) LoadExtensionFromFile(filePath string) (*LoadedExtens
|
||||
}
|
||||
}
|
||||
|
||||
// Now acquire write lock for the rest of the operation
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
// Double-check extension wasn't added while we were waiting for lock
|
||||
if _, exists := m.extensions[manifest.Name]; exists {
|
||||
return nil, fmt.Errorf("Extension '%s' was installed by another process", manifest.DisplayName)
|
||||
}
|
||||
|
||||
// Create extension directory
|
||||
extDir := filepath.Join(m.extensionsDir, manifest.Name)
|
||||
if err := os.MkdirAll(extDir, 0755); err != nil {
|
||||
return nil, fmt.Errorf("failed to create extension directory: %w", err)
|
||||
@@ -206,19 +198,16 @@ func (m *ExtensionManager) LoadExtensionFromFile(filePath string) (*LoadedExtens
|
||||
}
|
||||
destPath := filepath.Join(extDir, relPath)
|
||||
|
||||
// Create parent directories if needed
|
||||
destDir := filepath.Dir(destPath)
|
||||
if err := os.MkdirAll(destDir, 0755); err != nil {
|
||||
return nil, fmt.Errorf("failed to create directory %s: %w", destDir, err)
|
||||
}
|
||||
|
||||
// Create destination file
|
||||
destFile, err := os.Create(destPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create file %s: %w", destPath, err)
|
||||
}
|
||||
|
||||
// Copy content
|
||||
srcFile, err := file.Open()
|
||||
if err != nil {
|
||||
destFile.Close()
|
||||
@@ -233,13 +222,11 @@ func (m *ExtensionManager) LoadExtensionFromFile(filePath string) (*LoadedExtens
|
||||
}
|
||||
}
|
||||
|
||||
// Create data directory for extension
|
||||
extDataDir := filepath.Join(m.dataDir, manifest.Name)
|
||||
if err := os.MkdirAll(extDataDir, 0755); err != nil {
|
||||
return nil, fmt.Errorf("failed to create extension data directory: %w", err)
|
||||
}
|
||||
|
||||
// Create loaded extension
|
||||
ext := &LoadedExtension{
|
||||
ID: manifest.Name,
|
||||
Manifest: manifest,
|
||||
@@ -263,23 +250,19 @@ func (m *ExtensionManager) LoadExtensionFromFile(filePath string) (*LoadedExtens
|
||||
|
||||
// initializeVM creates and initializes the Goja VM for an extension
|
||||
func (m *ExtensionManager) initializeVM(ext *LoadedExtension) error {
|
||||
// Create new Goja runtime
|
||||
vm := goja.New()
|
||||
ext.VM = vm
|
||||
|
||||
// Read index.js
|
||||
indexPath := filepath.Join(ext.SourceDir, "index.js")
|
||||
jsCode, err := os.ReadFile(indexPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read index.js: %w", err)
|
||||
}
|
||||
|
||||
// Create extension runtime and register sandboxed APIs
|
||||
runtime := NewExtensionRuntime(ext)
|
||||
runtime.RegisterAPIs(vm)
|
||||
runtime.RegisterGoBackendAPIs(vm)
|
||||
|
||||
// Set up console.log for debugging
|
||||
console := vm.NewObject()
|
||||
console.Set("log", func(call goja.FunctionCall) goja.Value {
|
||||
args := make([]interface{}, len(call.Arguments))
|
||||
@@ -291,12 +274,10 @@ func (m *ExtensionManager) initializeVM(ext *LoadedExtension) error {
|
||||
})
|
||||
vm.Set("console", console)
|
||||
|
||||
// Set up registerExtension function
|
||||
var registeredExtension goja.Value
|
||||
vm.Set("registerExtension", func(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) > 0 {
|
||||
registeredExtension = call.Arguments[0]
|
||||
// Also set it as global 'extension' variable for later access
|
||||
vm.Set("extension", call.Arguments[0])
|
||||
}
|
||||
return goja.Undefined()
|
||||
@@ -406,7 +387,6 @@ func (m *ExtensionManager) LoadExtensionsFromDirectory(dirPath string) ([]string
|
||||
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
// Check if it's an extracted extension directory
|
||||
manifestPath := filepath.Join(dirPath, entry.Name(), "manifest.json")
|
||||
if _, err := os.Stat(manifestPath); err == nil {
|
||||
ext, err := m.loadExtensionFromDirectory(filepath.Join(dirPath, entry.Name()))
|
||||
@@ -418,7 +398,6 @@ func (m *ExtensionManager) LoadExtensionsFromDirectory(dirPath string) ([]string
|
||||
}
|
||||
}
|
||||
} else if strings.HasSuffix(strings.ToLower(entry.Name()), ".spotiflac-ext") {
|
||||
// Load from package file
|
||||
ext, err := m.LoadExtensionFromFile(filepath.Join(dirPath, entry.Name()))
|
||||
if err != nil {
|
||||
GoLog("[Extension] Failed to load %s: %v\n", entry.Name(), err)
|
||||
@@ -437,7 +416,6 @@ func (m *ExtensionManager) loadExtensionFromDirectory(dirPath string) (*LoadedEx
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
// Read manifest
|
||||
manifestPath := filepath.Join(dirPath, "manifest.json")
|
||||
manifestData, err := os.ReadFile(manifestPath)
|
||||
if err != nil {
|
||||
@@ -450,25 +428,21 @@ func (m *ExtensionManager) loadExtensionFromDirectory(dirPath string) (*LoadedEx
|
||||
return nil, fmt.Errorf("Invalid extension manifest: %w", err)
|
||||
}
|
||||
|
||||
// Check if index.js exists
|
||||
indexPath := filepath.Join(dirPath, "index.js")
|
||||
if _, err := os.Stat(indexPath); os.IsNotExist(err) {
|
||||
return nil, fmt.Errorf("Extension is missing index.js file")
|
||||
}
|
||||
|
||||
// Check if extension already loaded - skip silently (for directory loading on startup)
|
||||
if existing, exists := m.extensions[manifest.Name]; exists {
|
||||
GoLog("[Extension] Extension '%s' already loaded, skipping\n", manifest.DisplayName)
|
||||
return existing, nil
|
||||
}
|
||||
|
||||
// Create data directory for extension
|
||||
extDataDir := filepath.Join(m.dataDir, manifest.Name)
|
||||
if err := os.MkdirAll(extDataDir, 0755); err != nil {
|
||||
return nil, fmt.Errorf("failed to create extension data directory: %w", err)
|
||||
}
|
||||
|
||||
// Create loaded extension
|
||||
ext := &LoadedExtension{
|
||||
ID: manifest.Name,
|
||||
Manifest: manifest,
|
||||
@@ -541,7 +515,6 @@ func (m *ExtensionManager) UpgradeExtension(filePath string) (*LoadedExtension,
|
||||
}
|
||||
defer zipReader.Close()
|
||||
|
||||
// Find and read manifest.json
|
||||
var manifestData []byte
|
||||
var hasIndexJS bool
|
||||
for _, file := range zipReader.File {
|
||||
@@ -570,13 +543,11 @@ func (m *ExtensionManager) UpgradeExtension(filePath string) (*LoadedExtension,
|
||||
return nil, fmt.Errorf("Invalid extension package: index.js not found")
|
||||
}
|
||||
|
||||
// Parse and validate manifest
|
||||
newManifest, err := ParseManifest(manifestData)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Invalid extension manifest: %w", err)
|
||||
}
|
||||
|
||||
// Check if extension exists
|
||||
m.mu.RLock()
|
||||
existing, exists := m.extensions[newManifest.Name]
|
||||
m.mu.RUnlock()
|
||||
@@ -612,19 +583,15 @@ func (m *ExtensionManager) UpgradeExtension(filePath string) (*LoadedExtension,
|
||||
}
|
||||
}
|
||||
|
||||
// Recreate extension directory
|
||||
if err := os.MkdirAll(extDir, 0755); err != nil {
|
||||
return nil, fmt.Errorf("failed to create extension directory: %w", err)
|
||||
}
|
||||
|
||||
// Extract all files from new package (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)
|
||||
@@ -632,19 +599,16 @@ func (m *ExtensionManager) UpgradeExtension(filePath string) (*LoadedExtension,
|
||||
}
|
||||
destPath := filepath.Join(extDir, relPath)
|
||||
|
||||
// Create parent directories if needed
|
||||
destDir := filepath.Dir(destPath)
|
||||
if err := os.MkdirAll(destDir, 0755); err != nil {
|
||||
return nil, fmt.Errorf("failed to create directory %s: %w", destDir, err)
|
||||
}
|
||||
|
||||
// Create destination file
|
||||
destFile, err := os.Create(destPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create file %s: %w", destPath, err)
|
||||
}
|
||||
|
||||
// Copy content
|
||||
srcFile, err := file.Open()
|
||||
if err != nil {
|
||||
destFile.Close()
|
||||
@@ -659,7 +623,6 @@ func (m *ExtensionManager) UpgradeExtension(filePath string) (*LoadedExtension,
|
||||
}
|
||||
}
|
||||
|
||||
// Create new loaded extension (reusing data directory, preserving enabled state)
|
||||
ext := &LoadedExtension{
|
||||
ID: newManifest.Name,
|
||||
Manifest: newManifest,
|
||||
@@ -708,7 +671,6 @@ func (m *ExtensionManager) checkExtensionUpgradeInternal(filePath string) (*Exte
|
||||
}
|
||||
defer zipReader.Close()
|
||||
|
||||
// Find and read manifest.json
|
||||
var manifestData []byte
|
||||
for _, file := range zipReader.File {
|
||||
name := filepath.Base(file.Name)
|
||||
@@ -730,13 +692,11 @@ func (m *ExtensionManager) checkExtensionUpgradeInternal(filePath string) (*Exte
|
||||
return nil, fmt.Errorf("manifest.json not found")
|
||||
}
|
||||
|
||||
// Parse manifest
|
||||
newManifest, err := ParseManifest(manifestData)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Invalid manifest: %w", err)
|
||||
}
|
||||
|
||||
// Check if extension exists
|
||||
m.mu.RLock()
|
||||
existing, exists := m.extensions[newManifest.Name]
|
||||
m.mu.RUnlock()
|
||||
@@ -752,7 +712,6 @@ func (m *ExtensionManager) checkExtensionUpgradeInternal(filePath string) (*Exte
|
||||
info.CurrentVersion = ""
|
||||
info.CanUpgrade = false
|
||||
} else {
|
||||
// Compare versions
|
||||
info.CurrentVersion = existing.Manifest.Version
|
||||
info.CanUpgrade = compareVersions(newManifest.Version, existing.Manifest.Version) > 0
|
||||
}
|
||||
@@ -805,7 +764,6 @@ func (m *ExtensionManager) GetInstalledExtensionsJSON() (string, error) {
|
||||
|
||||
infos := make([]ExtensionInfo, len(extensions))
|
||||
for i, ext := range extensions {
|
||||
// Build permissions list
|
||||
permissions := []string{}
|
||||
for _, domain := range ext.Manifest.Permissions.Network {
|
||||
permissions = append(permissions, "network:"+domain)
|
||||
@@ -822,7 +780,6 @@ func (m *ExtensionManager) GetInstalledExtensionsJSON() (string, error) {
|
||||
status = "disabled"
|
||||
}
|
||||
|
||||
// Check for icon file
|
||||
iconPath := ""
|
||||
if ext.Manifest.Icon != "" && ext.SourceDir != "" {
|
||||
possibleIcon := filepath.Join(ext.SourceDir, ext.Manifest.Icon)
|
||||
@@ -830,7 +787,6 @@ func (m *ExtensionManager) GetInstalledExtensionsJSON() (string, error) {
|
||||
iconPath = possibleIcon
|
||||
}
|
||||
}
|
||||
// Fallback: check for icon.png if not specified in manifest
|
||||
if iconPath == "" && ext.SourceDir != "" {
|
||||
possibleIcon := filepath.Join(ext.SourceDir, "icon.png")
|
||||
if _, err := os.Stat(possibleIcon); err == nil {
|
||||
@@ -887,13 +843,11 @@ func (m *ExtensionManager) InitializeExtension(extensionID string, settings map[
|
||||
return fmt.Errorf("Extension failed to load. Please reinstall the extension")
|
||||
}
|
||||
|
||||
// Convert settings to JSON for passing to JS
|
||||
settingsJSON, err := json.Marshal(settings)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Failed to save settings")
|
||||
}
|
||||
|
||||
// Call initialize function
|
||||
script := fmt.Sprintf(`
|
||||
(function() {
|
||||
var settings = %s;
|
||||
@@ -917,7 +871,6 @@ func (m *ExtensionManager) InitializeExtension(extensionID string, settings map[
|
||||
return err
|
||||
}
|
||||
|
||||
// Check result
|
||||
if result != nil && !goja.IsUndefined(result) {
|
||||
exported := result.Export()
|
||||
if resultMap, ok := exported.(map[string]interface{}); ok {
|
||||
@@ -973,7 +926,6 @@ func (m *ExtensionManager) CleanupExtension(extensionID string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// Check result
|
||||
if result != nil && !goja.IsUndefined(result) {
|
||||
exported := result.Export()
|
||||
if resultMap, ok := exported.(map[string]interface{}); ok {
|
||||
|
||||
@@ -189,7 +189,6 @@ func (p *ExtensionProviderWrapper) SearchTracks(query string, limit int) (*ExtSe
|
||||
}
|
||||
}
|
||||
|
||||
// Set provider ID on all tracks
|
||||
for i := range searchResult.Tracks {
|
||||
searchResult.Tracks[i].ProviderID = p.extension.ID
|
||||
}
|
||||
@@ -737,12 +736,10 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
||||
|
||||
enrichedTrack, err := provider.EnrichTrack(trackMeta)
|
||||
if err == nil && enrichedTrack != nil {
|
||||
// Update request with enriched data
|
||||
if enrichedTrack.ISRC != "" && enrichedTrack.ISRC != req.ISRC {
|
||||
GoLog("[DownloadWithExtensionFallback] ISRC enriched: %s -> %s\n", req.ISRC, enrichedTrack.ISRC)
|
||||
req.ISRC = enrichedTrack.ISRC
|
||||
}
|
||||
// Update service-specific IDs from Odesli enrichment
|
||||
if enrichedTrack.TidalID != "" {
|
||||
GoLog("[DownloadWithExtensionFallback] Tidal ID from Odesli: %s\n", enrichedTrack.TidalID)
|
||||
req.TidalID = enrichedTrack.TidalID
|
||||
@@ -755,7 +752,6 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
||||
GoLog("[DownloadWithExtensionFallback] Deezer ID from Odesli: %s\n", enrichedTrack.DeezerID)
|
||||
req.DeezerID = enrichedTrack.DeezerID
|
||||
}
|
||||
// Can also update other fields if needed
|
||||
if enrichedTrack.Name != "" {
|
||||
req.TrackName = enrichedTrack.Name
|
||||
}
|
||||
@@ -772,7 +768,6 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
||||
|
||||
ext, err := extManager.GetExtension(req.Source)
|
||||
if err == nil && ext.Enabled && ext.Error == "" && ext.Manifest.IsDownloadProvider() {
|
||||
// Check if this extension wants to skip built-in fallback
|
||||
skipBuiltIn = ext.Manifest.SkipBuiltInFallback
|
||||
|
||||
provider := NewExtensionProviderWrapper(ext)
|
||||
@@ -783,7 +778,6 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
||||
|
||||
GoLog("[DownloadWithExtensionFallback] Downloading from source extension with trackID: %s (skipBuiltInFallback: %v)\n", trackID, skipBuiltIn)
|
||||
|
||||
// Build output path
|
||||
outputPath := buildOutputPath(req)
|
||||
|
||||
// Download directly using the track ID from the extension
|
||||
@@ -916,7 +910,6 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
||||
|
||||
provider := NewExtensionProviderWrapper(ext)
|
||||
|
||||
// Check availability first
|
||||
availability, err := provider.CheckAvailability(req.ISRC, req.TrackName, req.ArtistName)
|
||||
if err != nil || !availability.Available {
|
||||
GoLog("[DownloadWithExtensionFallback] %s: not available\n", providerID)
|
||||
@@ -926,12 +919,9 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
||||
continue
|
||||
}
|
||||
|
||||
// Build output path
|
||||
outputPath := buildOutputPath(req)
|
||||
|
||||
// Download
|
||||
result, err := provider.Download(availability.TrackID, req.Quality, outputPath, func(percent int) {
|
||||
// Update progress
|
||||
if req.ItemID != "" {
|
||||
SetItemProgress(req.ItemID, float64(percent), 0, 0)
|
||||
}
|
||||
@@ -1171,7 +1161,6 @@ func (p *ExtensionProviderWrapper) CustomSearch(query string, options map[string
|
||||
tracks = []ExtTrackMetadata{}
|
||||
}
|
||||
|
||||
// Set provider ID on all tracks
|
||||
for i := range tracks {
|
||||
tracks[i].ProviderID = p.extension.ID
|
||||
}
|
||||
@@ -1255,7 +1244,6 @@ func (p *ExtensionProviderWrapper) HandleURL(url string) (*ExtURLHandleResult, e
|
||||
handleResult.Artist.Albums[i].Tracks[j].ProviderID = p.extension.ID
|
||||
}
|
||||
}
|
||||
// Set provider ID on top tracks
|
||||
for i := range handleResult.Artist.TopTracks {
|
||||
handleResult.Artist.TopTracks[i].ProviderID = p.extension.ID
|
||||
}
|
||||
@@ -1493,12 +1481,10 @@ func (m *ExtensionManager) RunPostProcessing(filePath string, metadata map[strin
|
||||
for _, provider := range providers {
|
||||
hooks := provider.extension.Manifest.GetPostProcessingHooks()
|
||||
for _, hook := range hooks {
|
||||
// Check if hook is enabled (TODO: check user settings)
|
||||
if !hook.DefaultEnabled {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if format is supported
|
||||
ext := strings.ToLower(filepath.Ext(currentPath))
|
||||
if len(hook.SupportedFormats) > 0 {
|
||||
supported := false
|
||||
|
||||
@@ -73,12 +73,10 @@ func (lb *LogBuffer) Add(level, tag, message string) {
|
||||
}
|
||||
|
||||
if len(lb.entries) >= lb.maxSize {
|
||||
// Remove oldest entry
|
||||
lb.entries = lb.entries[1:]
|
||||
}
|
||||
lb.entries = append(lb.entries, entry)
|
||||
|
||||
// Also print to logcat for debugging
|
||||
fmt.Printf("[%s] %s\n", tag, message)
|
||||
}
|
||||
|
||||
|
||||
@@ -51,7 +51,6 @@ func EmbedMetadata(filePath string, metadata Metadata, coverPath string) error {
|
||||
cmt = flacvorbis.New()
|
||||
}
|
||||
|
||||
// Set metadata fields
|
||||
setComment(cmt, "TITLE", metadata.Title)
|
||||
setComment(cmt, "ARTIST", metadata.Artist)
|
||||
setComment(cmt, "ALBUM", metadata.Album)
|
||||
@@ -83,7 +82,6 @@ func EmbedMetadata(filePath string, metadata Metadata, coverPath string) error {
|
||||
setComment(cmt, "UNSYNCEDLYRICS", metadata.Lyrics)
|
||||
}
|
||||
|
||||
// Update or add vorbis comment block
|
||||
cmtBlock := cmt.Marshal()
|
||||
if cmtIdx >= 0 {
|
||||
f.Meta[cmtIdx] = &cmtBlock
|
||||
@@ -151,7 +149,6 @@ func EmbedMetadataWithCoverData(filePath string, metadata Metadata, coverData []
|
||||
cmt = flacvorbis.New()
|
||||
}
|
||||
|
||||
// Set metadata fields
|
||||
setComment(cmt, "TITLE", metadata.Title)
|
||||
setComment(cmt, "ARTIST", metadata.Artist)
|
||||
setComment(cmt, "ALBUM", metadata.Album)
|
||||
@@ -183,7 +180,6 @@ func EmbedMetadataWithCoverData(filePath string, metadata Metadata, coverData []
|
||||
setComment(cmt, "UNSYNCEDLYRICS", metadata.Lyrics)
|
||||
}
|
||||
|
||||
// Update or add vorbis comment block
|
||||
cmtBlock := cmt.Marshal()
|
||||
if cmtIdx >= 0 {
|
||||
f.Meta[cmtIdx] = &cmtBlock
|
||||
@@ -309,7 +305,6 @@ func getComment(cmt *flacvorbis.MetaDataBlockVorbisComment, key string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// fileExists checks if a file exists
|
||||
func fileExists(path string) bool {
|
||||
_, err := os.Stat(path)
|
||||
return err == nil
|
||||
@@ -367,13 +362,11 @@ func ExtractLyrics(filePath string) (string, error) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Try LYRICS tag first
|
||||
lyrics, err := cmt.Get("LYRICS")
|
||||
if err == nil && len(lyrics) > 0 && lyrics[0] != "" {
|
||||
return lyrics[0], nil
|
||||
}
|
||||
|
||||
// Fallback to UNSYNCEDLYRICS
|
||||
lyrics, err = cmt.Get("UNSYNCEDLYRICS")
|
||||
if err == nil && len(lyrics) > 0 && lyrics[0] != "" {
|
||||
return lyrics[0], nil
|
||||
@@ -406,10 +399,7 @@ func GetAudioQuality(filePath string) (AudioQuality, error) {
|
||||
return AudioQuality{}, fmt.Errorf("failed to read marker: %w", err)
|
||||
}
|
||||
|
||||
// Check if it's a FLAC file
|
||||
if string(marker) == "fLaC" {
|
||||
// Continue reading FLAC metadata
|
||||
// Read metadata block header (4 bytes)
|
||||
header := make([]byte, 4)
|
||||
if _, err := file.Read(header); err != nil {
|
||||
return AudioQuality{}, fmt.Errorf("failed to read header: %w", err)
|
||||
@@ -420,7 +410,6 @@ func GetAudioQuality(filePath string) (AudioQuality, error) {
|
||||
return AudioQuality{}, fmt.Errorf("first block is not STREAMINFO")
|
||||
}
|
||||
|
||||
// Read STREAMINFO block (34 bytes minimum)
|
||||
streamInfo := make([]byte, 34)
|
||||
if _, err := file.Read(streamInfo); err != nil {
|
||||
return AudioQuality{}, fmt.Errorf("failed to read STREAMINFO: %w", err)
|
||||
@@ -468,7 +457,6 @@ func EmbedM4AMetadata(filePath string, metadata Metadata, coverData []byte) erro
|
||||
return fmt.Errorf("failed to read M4A file: %w", err)
|
||||
}
|
||||
|
||||
// Find moov atom position
|
||||
moovPos := findAtom(data, "moov", 0)
|
||||
if moovPos < 0 {
|
||||
return fmt.Errorf("moov atom not found in M4A file")
|
||||
@@ -481,7 +469,6 @@ func EmbedM4AMetadata(filePath string, metadata Metadata, coverData []byte) erro
|
||||
|
||||
var newData []byte
|
||||
if udtaPos >= 0 && udtaPos < moovPos+moovSize {
|
||||
// udta exists, find meta inside it or replace
|
||||
udtaSize := int(uint32(data[udtaPos])<<24 | uint32(data[udtaPos+1])<<16 | uint32(data[udtaPos+2])<<8 | uint32(data[udtaPos+3]))
|
||||
metaPos := findAtom(data, "meta", udtaPos+8)
|
||||
|
||||
@@ -522,7 +509,6 @@ func EmbedM4AMetadata(filePath string, metadata Metadata, coverData []byte) erro
|
||||
newData = append(newData, data[insertPos:]...)
|
||||
}
|
||||
|
||||
// Update moov size
|
||||
newMoovSize := moovSize + len(newData) - len(data)
|
||||
newData[moovPos] = byte(newMoovSize >> 24)
|
||||
newData[moovPos+1] = byte(newMoovSize >> 16)
|
||||
@@ -557,52 +543,42 @@ func findAtom(data []byte, name string, offset int) int {
|
||||
func buildMetaAtom(metadata Metadata, coverData []byte) []byte {
|
||||
var ilst []byte
|
||||
|
||||
// ©nam - Title
|
||||
if metadata.Title != "" {
|
||||
ilst = append(ilst, buildTextAtom("©nam", metadata.Title)...)
|
||||
}
|
||||
|
||||
// ©ART - Artist
|
||||
if metadata.Artist != "" {
|
||||
ilst = append(ilst, buildTextAtom("©ART", metadata.Artist)...)
|
||||
}
|
||||
|
||||
// ©alb - Album
|
||||
if metadata.Album != "" {
|
||||
ilst = append(ilst, buildTextAtom("©alb", metadata.Album)...)
|
||||
}
|
||||
|
||||
// aART - Album Artist
|
||||
if metadata.AlbumArtist != "" {
|
||||
ilst = append(ilst, buildTextAtom("aART", metadata.AlbumArtist)...)
|
||||
}
|
||||
|
||||
// ©day - Year/Date
|
||||
if metadata.Date != "" {
|
||||
ilst = append(ilst, buildTextAtom("©day", metadata.Date)...)
|
||||
}
|
||||
|
||||
// trkn - Track Number
|
||||
if metadata.TrackNumber > 0 {
|
||||
ilst = append(ilst, buildTrackNumberAtom(metadata.TrackNumber, metadata.TotalTracks)...)
|
||||
}
|
||||
|
||||
// disk - Disc Number
|
||||
if metadata.DiscNumber > 0 {
|
||||
ilst = append(ilst, buildDiscNumberAtom(metadata.DiscNumber, 0)...)
|
||||
}
|
||||
|
||||
// ©lyr - Lyrics
|
||||
if metadata.Lyrics != "" {
|
||||
ilst = append(ilst, buildTextAtom("©lyr", metadata.Lyrics)...)
|
||||
}
|
||||
|
||||
// covr - Cover Art
|
||||
if len(coverData) > 0 {
|
||||
ilst = append(ilst, buildCoverAtom(coverData)...)
|
||||
}
|
||||
|
||||
// Build ilst atom
|
||||
ilstSize := 8 + len(ilst)
|
||||
ilstAtom := make([]byte, 4)
|
||||
ilstAtom[0] = byte(ilstSize >> 24)
|
||||
@@ -624,7 +600,6 @@ func buildMetaAtom(metadata Metadata, coverData []byte) []byte {
|
||||
0, // null terminator
|
||||
}
|
||||
|
||||
// Build meta atom
|
||||
metaContent := append([]byte{0, 0, 0, 0}, hdlr...) // version + flags + hdlr
|
||||
metaContent = append(metaContent, ilstAtom...)
|
||||
|
||||
@@ -644,7 +619,6 @@ func buildMetaAtom(metadata Metadata, coverData []byte) []byte {
|
||||
func buildTextAtom(name, value string) []byte {
|
||||
valueBytes := []byte(value)
|
||||
|
||||
// data atom
|
||||
dataSize := 16 + len(valueBytes)
|
||||
dataAtom := make([]byte, 4)
|
||||
dataAtom[0] = byte(dataSize >> 24)
|
||||
@@ -656,7 +630,6 @@ func buildTextAtom(name, value string) []byte {
|
||||
dataAtom = append(dataAtom, 0, 0, 0, 0) // locale
|
||||
dataAtom = append(dataAtom, valueBytes...)
|
||||
|
||||
// container atom
|
||||
atomSize := 8 + len(dataAtom)
|
||||
atom := make([]byte, 4)
|
||||
atom[0] = byte(atomSize >> 24)
|
||||
@@ -671,7 +644,6 @@ func buildTextAtom(name, value string) []byte {
|
||||
|
||||
// buildTrackNumberAtom builds trkn atom
|
||||
func buildTrackNumberAtom(track, total int) []byte {
|
||||
// data atom with track number
|
||||
dataAtom := []byte{
|
||||
0, 0, 0, 24, // size
|
||||
'd', 'a', 't', 'a',
|
||||
@@ -683,7 +655,6 @@ func buildTrackNumberAtom(track, total int) []byte {
|
||||
0, 0, // padding
|
||||
}
|
||||
|
||||
// trkn atom
|
||||
atomSize := 8 + len(dataAtom)
|
||||
atom := make([]byte, 4)
|
||||
atom[0] = byte(atomSize >> 24)
|
||||
@@ -698,7 +669,6 @@ func buildTrackNumberAtom(track, total int) []byte {
|
||||
|
||||
// buildDiscNumberAtom builds disk atom
|
||||
func buildDiscNumberAtom(disc, total int) []byte {
|
||||
// data atom with disc number
|
||||
dataAtom := []byte{
|
||||
0, 0, 0, 22, // size
|
||||
'd', 'a', 't', 'a',
|
||||
@@ -709,7 +679,6 @@ func buildDiscNumberAtom(disc, total int) []byte {
|
||||
byte(total >> 8), byte(total), // total discs
|
||||
}
|
||||
|
||||
// disk atom
|
||||
atomSize := 8 + len(dataAtom)
|
||||
atom := make([]byte, 4)
|
||||
atom[0] = byte(atomSize >> 24)
|
||||
@@ -724,13 +693,11 @@ func buildDiscNumberAtom(disc, total int) []byte {
|
||||
|
||||
// buildCoverAtom builds covr atom with image data
|
||||
func buildCoverAtom(coverData []byte) []byte {
|
||||
// Detect image type (JPEG = 13, PNG = 14)
|
||||
imageType := byte(13) // default JPEG
|
||||
if len(coverData) > 8 && coverData[0] == 0x89 && coverData[1] == 'P' && coverData[2] == 'N' && coverData[3] == 'G' {
|
||||
imageType = 14 // PNG
|
||||
}
|
||||
|
||||
// data atom
|
||||
dataSize := 16 + len(coverData)
|
||||
dataAtom := make([]byte, 4)
|
||||
dataAtom[0] = byte(dataSize >> 24)
|
||||
@@ -742,7 +709,6 @@ func buildCoverAtom(coverData []byte) []byte {
|
||||
dataAtom = append(dataAtom, 0, 0, 0, 0) // locale
|
||||
dataAtom = append(dataAtom, coverData...)
|
||||
|
||||
// covr atom
|
||||
atomSize := 8 + len(dataAtom)
|
||||
atom := make([]byte, 4)
|
||||
atom[0] = byte(atomSize >> 24)
|
||||
@@ -762,7 +728,6 @@ func GetM4AQuality(filePath string) (AudioQuality, error) {
|
||||
return AudioQuality{}, fmt.Errorf("failed to read M4A file: %w", err)
|
||||
}
|
||||
|
||||
// Find moov -> trak -> mdia -> minf -> stbl -> stsd
|
||||
moovPos := findAtom(data, "moov", 0)
|
||||
if moovPos < 0 {
|
||||
return AudioQuality{}, fmt.Errorf("moov atom not found")
|
||||
|
||||
@@ -49,7 +49,6 @@ func getProgress() DownloadProgress {
|
||||
multiMu.RLock()
|
||||
defer multiMu.RUnlock()
|
||||
|
||||
// Find first active item
|
||||
for _, item := range multiProgress.Items {
|
||||
return DownloadProgress{
|
||||
CurrentFile: item.ItemID,
|
||||
@@ -249,10 +248,7 @@ func (pw *ItemProgressWriter) Write(p []byte) (int, error) {
|
||||
}
|
||||
pw.current += int64(n)
|
||||
|
||||
// Update progress when we've received at least 64KB since last update
|
||||
// Also update on first write to show download has started
|
||||
if pw.lastReported == 0 || pw.current-pw.lastReported >= progressUpdateThreshold {
|
||||
// Calculate speed (MB/s) based on bytes received since last update
|
||||
now := time.Now()
|
||||
elapsed := now.Sub(pw.lastTime).Seconds()
|
||||
var speedMBps float64
|
||||
|
||||
@@ -855,7 +855,6 @@ func (q *QobuzDownloader) GetDownloadURL(trackID int64, quality string) (string,
|
||||
return "", fmt.Errorf("no Qobuz API available")
|
||||
}
|
||||
|
||||
// Use parallel approach - request from all APIs simultaneously
|
||||
_, downloadURL, err := getQobuzDownloadURLParallel(apis, trackID, quality)
|
||||
if err != nil {
|
||||
return "", err
|
||||
@@ -899,7 +898,6 @@ func (q *QobuzDownloader) DownloadFile(downloadURL, outputPath, itemID string) e
|
||||
}
|
||||
|
||||
expectedSize := resp.ContentLength
|
||||
// Set total bytes if available
|
||||
if expectedSize > 0 && itemID != "" {
|
||||
SetItemBytesTotal(itemID, expectedSize)
|
||||
}
|
||||
@@ -909,16 +907,13 @@ func (q *QobuzDownloader) DownloadFile(downloadURL, outputPath, itemID string) e
|
||||
return err
|
||||
}
|
||||
|
||||
// Use buffered writer for better performance (256KB buffer)
|
||||
bufWriter := bufio.NewWriterSize(out, 256*1024)
|
||||
|
||||
// Use item progress writer with buffered output
|
||||
var written int64
|
||||
if itemID != "" {
|
||||
progressWriter := NewItemProgressWriter(bufWriter, itemID)
|
||||
written, err = io.Copy(progressWriter, resp.Body)
|
||||
} else {
|
||||
// Fallback: direct copy without progress tracking
|
||||
written, err = io.Copy(bufWriter, resp.Body)
|
||||
}
|
||||
|
||||
@@ -926,7 +921,6 @@ func (q *QobuzDownloader) DownloadFile(downloadURL, outputPath, itemID string) e
|
||||
flushErr := bufWriter.Flush()
|
||||
closeErr := out.Close()
|
||||
|
||||
// Check for any errors
|
||||
if err != nil {
|
||||
os.Remove(outputPath)
|
||||
if isDownloadCancelled(itemID) {
|
||||
@@ -970,18 +964,15 @@ type QobuzDownloadResult struct {
|
||||
func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
|
||||
downloader := NewQobuzDownloader()
|
||||
|
||||
// Check for existing file first
|
||||
if existingFile, exists := checkISRCExistsInternal(req.OutputDir, req.ISRC); exists {
|
||||
return QobuzDownloadResult{FilePath: "EXISTS:" + existingFile}, nil
|
||||
}
|
||||
|
||||
// Convert expected duration from ms to seconds
|
||||
expectedDurationSec := req.DurationMS / 1000
|
||||
|
||||
var track *QobuzTrack
|
||||
var err error
|
||||
|
||||
// STRATEGY 0: Use pre-fetched Qobuz ID from Odesli enrichment (highest priority)
|
||||
if req.QobuzID != "" {
|
||||
GoLog("[Qobuz] Using Qobuz ID from Odesli enrichment: %s\n", req.QobuzID)
|
||||
var trackID int64
|
||||
@@ -1052,7 +1043,6 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
|
||||
GetTrackIDCache().SetQobuz(req.ISRC, track.ID)
|
||||
}
|
||||
|
||||
// Build filename
|
||||
filename := buildFilenameFromTemplate(req.FilenameFormat, map[string]interface{}{
|
||||
"title": req.TrackName,
|
||||
"artist": req.ArtistName,
|
||||
@@ -1064,7 +1054,6 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
|
||||
filename = sanitizeFilename(filename) + ".flac"
|
||||
outputPath := filepath.Join(req.OutputDir, filename)
|
||||
|
||||
// Check if file already exists
|
||||
if fileInfo, statErr := os.Stat(outputPath); statErr == nil && fileInfo.Size() > 0 {
|
||||
return QobuzDownloadResult{FilePath: "EXISTS:" + outputPath}, nil
|
||||
}
|
||||
@@ -1083,12 +1072,10 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
|
||||
}
|
||||
GoLog("[Qobuz] Using quality: %s (mapped from %s)\n", qobuzQuality, req.Quality)
|
||||
|
||||
// Get actual quality from track metadata
|
||||
actualBitDepth := track.MaximumBitDepth
|
||||
actualSampleRate := int(track.MaximumSamplingRate * 1000) // Convert kHz to Hz
|
||||
GoLog("[Qobuz] Actual quality: %d-bit/%.1fkHz\n", actualBitDepth, track.MaximumSamplingRate)
|
||||
|
||||
// Get download URL using parallel API requests
|
||||
downloadURL, err := downloader.GetDownloadURL(track.ID, qobuzQuality)
|
||||
if err != nil {
|
||||
return QobuzDownloadResult{}, fmt.Errorf("failed to get download URL: %w", err)
|
||||
@@ -1120,16 +1107,11 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
|
||||
// Wait for parallel operations to complete
|
||||
<-parallelDone
|
||||
|
||||
// Set progress to 100% and status to finalizing (before embedding)
|
||||
// This makes the UI show "Finalizing..." while embedding happens
|
||||
if req.ItemID != "" {
|
||||
SetItemProgress(req.ItemID, 1.0, 0, 0)
|
||||
SetItemFinalizing(req.ItemID)
|
||||
}
|
||||
|
||||
// Embed metadata using parallel-fetched cover data
|
||||
// Use metadata from the actual Qobuz track found (more accurate than request) but prefer
|
||||
// requested Album Name to avoid ISRC version mismatches (e.g. Compilations vs Original)
|
||||
albumName := track.Album.Title
|
||||
if req.AlbumName != "" {
|
||||
albumName = req.AlbumName
|
||||
@@ -1147,7 +1129,6 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
|
||||
ISRC: track.ISRC,
|
||||
}
|
||||
|
||||
// Use cover data from parallel fetch
|
||||
var coverData []byte
|
||||
if parallelResult != nil && parallelResult.CoverData != nil {
|
||||
coverData = parallelResult.CoverData
|
||||
|
||||
@@ -89,7 +89,6 @@ func HasSpotifyCredentials() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check environment variables
|
||||
if os.Getenv("SPOTIFY_CLIENT_ID") != "" && os.Getenv("SPOTIFY_CLIENT_SECRET") != "" {
|
||||
return true
|
||||
}
|
||||
@@ -102,12 +101,10 @@ func getCredentials() (string, string, error) {
|
||||
credentialsMu.RLock()
|
||||
defer credentialsMu.RUnlock()
|
||||
|
||||
// Check custom credentials first
|
||||
if customClientID != "" && customClientSecret != "" {
|
||||
return customClientID, customClientSecret, nil
|
||||
}
|
||||
|
||||
// Check environment variables
|
||||
clientID := os.Getenv("SPOTIFY_CLIENT_ID")
|
||||
clientSecret := os.Getenv("SPOTIFY_CLIENT_SECRET")
|
||||
|
||||
@@ -393,10 +390,8 @@ func (c *SpotifyMetadataClient) SearchTracks(ctx context.Context, query string,
|
||||
|
||||
// SearchAll searches for tracks and artists on Spotify
|
||||
func (c *SpotifyMetadataClient) SearchAll(ctx context.Context, query string, trackLimit, artistLimit int) (*SearchAllResult, error) {
|
||||
// Create cache key
|
||||
cacheKey := fmt.Sprintf("all:%s:%d:%d", query, trackLimit, artistLimit)
|
||||
|
||||
// Check cache first
|
||||
c.cacheMu.RLock()
|
||||
if entry, ok := c.searchCache[cacheKey]; ok && !entry.isExpired() {
|
||||
c.cacheMu.RUnlock()
|
||||
@@ -510,7 +505,6 @@ func (c *SpotifyMetadataClient) fetchTrack(ctx context.Context, trackID, token s
|
||||
}
|
||||
|
||||
func (c *SpotifyMetadataClient) fetchAlbum(ctx context.Context, albumID, token string) (*AlbumResponsePayload, error) {
|
||||
// Check cache first
|
||||
c.cacheMu.RLock()
|
||||
if entry, ok := c.albumCache[albumID]; ok && !entry.isExpired() {
|
||||
c.cacheMu.RUnlock()
|
||||
@@ -768,7 +762,6 @@ func (c *SpotifyMetadataClient) fetchPlaylist(ctx context.Context, playlistID, t
|
||||
}
|
||||
|
||||
func (c *SpotifyMetadataClient) fetchArtist(ctx context.Context, artistID, token string) (*ArtistResponsePayload, error) {
|
||||
// Check cache first
|
||||
c.cacheMu.RLock()
|
||||
if entry, ok := c.artistCache[artistID]; ok && !entry.isExpired() {
|
||||
c.cacheMu.RUnlock()
|
||||
|
||||
@@ -118,7 +118,6 @@ func NewTidalDownloader() *TidalDownloader {
|
||||
clientSecret: string(clientSecret),
|
||||
}
|
||||
|
||||
// Get first available API
|
||||
apis := globalTidalDownloader.GetAvailableAPIs()
|
||||
if len(apis) > 0 {
|
||||
globalTidalDownloader.apiURL = apis[0]
|
||||
@@ -1451,7 +1450,6 @@ func isLatinScript(s string) bool {
|
||||
func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
||||
downloader := NewTidalDownloader()
|
||||
|
||||
// Check for existing file first
|
||||
if existingFile, exists := checkISRCExistsInternal(req.OutputDir, req.ISRC); exists {
|
||||
return TidalDownloadResult{FilePath: "EXISTS:" + existingFile}, nil
|
||||
}
|
||||
@@ -1519,7 +1517,6 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
||||
var tidalURL string
|
||||
var slErr error
|
||||
|
||||
// Check if SpotifyID is actually a Deezer ID (format: "deezer:xxxxx")
|
||||
if strings.HasPrefix(req.SpotifyID, "deezer:") {
|
||||
deezerID := strings.TrimPrefix(req.SpotifyID, "deezer:")
|
||||
GoLog("[Tidal] Using Deezer ID for SongLink lookup: %s\n", deezerID)
|
||||
@@ -1530,12 +1527,10 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
||||
}
|
||||
|
||||
if slErr == nil && tidalURL != "" {
|
||||
// Extract track ID and get track info
|
||||
trackID, idErr := downloader.GetTrackIDFromURL(tidalURL)
|
||||
if idErr == nil {
|
||||
track, err = downloader.GetTrackInfoByID(trackID)
|
||||
if track != nil {
|
||||
// Get artist name from track
|
||||
tidalArtist := track.Artist.Name
|
||||
if len(track.Artists) > 0 {
|
||||
var artistNames []string
|
||||
@@ -1545,7 +1540,6 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
||||
tidalArtist = strings.Join(artistNames, ", ")
|
||||
}
|
||||
|
||||
// Verify artist matches (SongLink is already accurate, no title check needed)
|
||||
if !artistsMatch(req.ArtistName, tidalArtist) {
|
||||
GoLog("[Tidal] Artist mismatch from SongLink: expected '%s', got '%s'. Rejecting.\n",
|
||||
req.ArtistName, tidalArtist)
|
||||
@@ -1617,12 +1611,10 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
||||
}
|
||||
GoLog("[Tidal] Match found: '%s' by '%s' (duration: %ds)\n", track.Title, tidalArtist, track.Duration)
|
||||
|
||||
// Cache the track ID for future use
|
||||
if req.ISRC != "" {
|
||||
GetTrackIDCache().SetTidal(req.ISRC, track.ID)
|
||||
}
|
||||
|
||||
// Build filename
|
||||
filename := buildFilenameFromTemplate(req.FilenameFormat, map[string]interface{}{
|
||||
"title": req.TrackName,
|
||||
"artist": req.ArtistName,
|
||||
@@ -1634,7 +1626,6 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
||||
filename = sanitizeFilename(filename) + ".flac"
|
||||
outputPath := filepath.Join(req.OutputDir, filename)
|
||||
|
||||
// Check if file already exists (both FLAC and M4A)
|
||||
if fileInfo, statErr := os.Stat(outputPath); statErr == nil && fileInfo.Size() > 0 {
|
||||
return TidalDownloadResult{FilePath: "EXISTS:" + outputPath}, nil
|
||||
}
|
||||
@@ -1650,14 +1641,12 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
||||
os.Remove(tmpPath)
|
||||
}
|
||||
|
||||
// Determine quality to use (default to LOSSLESS if not specified)
|
||||
quality := req.Quality
|
||||
if quality == "" {
|
||||
quality = "LOSSLESS"
|
||||
}
|
||||
GoLog("[Tidal] Using quality: %s\n", quality)
|
||||
|
||||
// Get download URL using parallel API requests
|
||||
downloadInfo, err := downloader.GetDownloadURL(track.ID, quality)
|
||||
if err != nil {
|
||||
return TidalDownloadResult{}, fmt.Errorf("failed to get download URL: %w", err)
|
||||
@@ -1702,18 +1691,13 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
||||
// Wait for parallel operations to complete
|
||||
<-parallelDone
|
||||
|
||||
// Set progress to 100% and status to finalizing (before embedding)
|
||||
// This makes the UI show "Finalizing..." while embedding happens
|
||||
if req.ItemID != "" {
|
||||
SetItemProgress(req.ItemID, 1.0, 0, 0)
|
||||
SetItemFinalizing(req.ItemID)
|
||||
}
|
||||
|
||||
// Check if file was saved as M4A (DASH stream) instead of FLAC
|
||||
// downloadFromManifest saves DASH streams as .m4a (m4aPath already defined above)
|
||||
actualOutputPath := outputPath
|
||||
if _, err := os.Stat(m4aPath); err == nil {
|
||||
// File was saved as M4A, use that path
|
||||
actualOutputPath = m4aPath
|
||||
GoLog("[Tidal] File saved as M4A (DASH stream): %s\n", actualOutputPath)
|
||||
} else if _, err := os.Stat(outputPath); err != nil {
|
||||
@@ -1734,7 +1718,6 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
||||
ISRC: track.ISRC, // Use actual ISRC from Tidal
|
||||
}
|
||||
|
||||
// Use cover data from parallel fetch
|
||||
var coverData []byte
|
||||
if parallelResult != nil && parallelResult.CoverData != nil {
|
||||
coverData = parallelResult.CoverData
|
||||
|
||||
@@ -9,7 +9,6 @@ import 'package:spotiflac_android/theme/dynamic_color_wrapper.dart';
|
||||
import 'package:spotiflac_android/l10n/app_localizations.dart';
|
||||
|
||||
final _routerProvider = Provider<GoRouter>((ref) {
|
||||
// Only watch isFirstLaunch to prevent router rebuild on other settings changes
|
||||
final isFirstLaunch = ref.watch(settingsProvider.select((s) => s.isFirstLaunch));
|
||||
|
||||
return GoRouter(
|
||||
@@ -35,7 +34,6 @@ class SpotiFLACApp extends ConsumerWidget {
|
||||
final router = ref.watch(_routerProvider);
|
||||
final localeString = ref.watch(settingsProvider.select((s) => s.locale));
|
||||
|
||||
// Convert locale string to Locale object
|
||||
Locale? locale;
|
||||
if (localeString != 'system') {
|
||||
locale = Locale(localeString);
|
||||
|
||||
@@ -48,7 +48,6 @@ class _EagerInitializationState extends ConsumerState<_EagerInitialization> {
|
||||
final extensionsDir = '${appDir.path}/extensions';
|
||||
final dataDir = '${appDir.path}/extension_data';
|
||||
|
||||
// Create directories if needed
|
||||
await Directory(extensionsDir).create(recursive: true);
|
||||
await Directory(dataDir).create(recursive: true);
|
||||
|
||||
|
||||
@@ -415,11 +415,9 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
state = state.copyWith(items: pendingItems);
|
||||
_log.i('Restored ${pendingItems.length} pending items from storage');
|
||||
|
||||
// Auto-resume queue processing
|
||||
Future.microtask(() => _processQueue());
|
||||
} else {
|
||||
_log.d('No pending items to restore');
|
||||
// Clear storage since nothing to restore
|
||||
await prefs.remove(_queueStorageKey);
|
||||
}
|
||||
} else {
|
||||
@@ -603,7 +601,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
if (state.outputDir.isEmpty) {
|
||||
try {
|
||||
if (Platform.isIOS) {
|
||||
// iOS: Use Documents directory (accessible via Files app)
|
||||
final dir = await getApplicationDocumentsDirectory();
|
||||
final musicDir = Directory('${dir.path}/SpotiFLAC');
|
||||
if (!await musicDir.exists()) {
|
||||
@@ -1347,7 +1344,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
return;
|
||||
}
|
||||
|
||||
// Set currentDownload for UI reference
|
||||
state = state.copyWith(currentDownload: item);
|
||||
|
||||
updateItemStatus(item.id, DownloadStatus.downloading);
|
||||
@@ -1408,7 +1404,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
releaseDate: data['release_date'] as String?,
|
||||
deezerId: rawId,
|
||||
availability: trackToDownload.availability,
|
||||
// Preserve albumType from API response or original track
|
||||
albumType: (data['album_type'] as String?) ?? trackToDownload.albumType,
|
||||
source: trackToDownload.source,
|
||||
);
|
||||
@@ -1439,7 +1434,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
albumFolderStructure: settings.albumFolderStructure,
|
||||
);
|
||||
|
||||
// Use quality override if set, otherwise use default from settings
|
||||
final quality = item.qualityOverride ?? state.audioQuality;
|
||||
|
||||
Map<String, dynamic> result;
|
||||
@@ -1449,7 +1443,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
final useExtensions = settings.useExtensionProviders && hasActiveExtensions;
|
||||
|
||||
if (useExtensions) {
|
||||
// Use extension providers (includes fallback to built-in services)
|
||||
_log.d('Using extension providers for download');
|
||||
_log.d(
|
||||
'Quality: $quality${item.qualityOverride != null ? ' (override)' : ''}',
|
||||
@@ -1528,7 +1521,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
);
|
||||
if (currentItem.status == DownloadStatus.skipped) {
|
||||
_log.i('Download was cancelled, skipping result processing');
|
||||
// Delete the downloaded file if it exists
|
||||
final filePath = result['file_path'] as String?;
|
||||
if (filePath != null && result['success'] == true) {
|
||||
try {
|
||||
@@ -1614,7 +1606,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
'Backend metadata - Track: $backendTrackNum, Disc: $backendDiscNum, Year: $backendYear',
|
||||
);
|
||||
|
||||
// Create updated track object with safety check for 0/null
|
||||
final newTrackNumber =
|
||||
(backendTrackNum != null && backendTrackNum > 0)
|
||||
? backendTrackNum
|
||||
@@ -1647,7 +1638,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
);
|
||||
}
|
||||
|
||||
// Use enriched/updated track for metadata embedding
|
||||
await _embedMetadataAndCover(flacPath, finalTrack);
|
||||
_log.d('Metadata and cover embedded successfully');
|
||||
} catch (e) {
|
||||
@@ -1714,7 +1704,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
final backendSampleRate = result['actual_sample_rate'] as int?;
|
||||
final backendISRC = result['isrc'] as String?;
|
||||
|
||||
// Log cover URL for debugging
|
||||
_log.d('Saving to history - coverUrl: ${trackToDownload.coverUrl}');
|
||||
|
||||
final historyAlbumArtist =
|
||||
@@ -1782,7 +1771,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
return;
|
||||
}
|
||||
|
||||
// Convert error type string to enum
|
||||
DownloadErrorType errorType;
|
||||
switch (errorTypeStr) {
|
||||
case 'not_found':
|
||||
|
||||
@@ -175,12 +175,10 @@ class SearchBehavior {
|
||||
/// Get thumbnail size based on configuration
|
||||
/// Returns (width, height) tuple
|
||||
(double, double) getThumbnailSize({double defaultSize = 56}) {
|
||||
// If custom dimensions specified, use them
|
||||
if (thumbnailWidth != null && thumbnailHeight != null) {
|
||||
return (thumbnailWidth!.toDouble(), thumbnailHeight!.toDouble());
|
||||
}
|
||||
|
||||
// Otherwise use ratio presets
|
||||
switch (thumbnailRatio) {
|
||||
case 'wide': // 16:9 - YouTube style
|
||||
return (defaultSize * 16 / 9, defaultSize);
|
||||
@@ -558,10 +556,8 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
|
||||
await PlatformBridge.setExtensionEnabled(extensionId, enabled);
|
||||
_log.d('Set extension $extensionId enabled: $enabled');
|
||||
|
||||
// Get extension info before updating state
|
||||
final ext = state.extensions.where((e) => e.id == extensionId).firstOrNull;
|
||||
|
||||
// Update local state
|
||||
final extensions = state.extensions.map((e) {
|
||||
if (e.id == extensionId) {
|
||||
return e.copyWith(enabled: enabled);
|
||||
@@ -571,18 +567,15 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
|
||||
|
||||
state = state.copyWith(extensions: extensions);
|
||||
|
||||
// If disabling an extension, reset related settings
|
||||
if (!enabled && ext != null) {
|
||||
final settings = ref.read(settingsProvider);
|
||||
|
||||
// If this extension was the search provider, clear it and reset to Deezer
|
||||
if (settings.searchProvider == extensionId) {
|
||||
ref.read(settingsProvider.notifier).setSearchProvider(null);
|
||||
ref.read(settingsProvider.notifier).setMetadataSource('deezer');
|
||||
_log.d('Cleared search provider and reset to Deezer because extension $extensionId was disabled');
|
||||
}
|
||||
|
||||
// If this extension was the default download service, reset to Tidal
|
||||
if (ext.hasDownloadProvider && settings.defaultService == extensionId) {
|
||||
ref.read(settingsProvider.notifier).setDefaultService('tidal');
|
||||
_log.d('Reset default service to Tidal because extension $extensionId was disabled');
|
||||
|
||||
@@ -89,14 +89,12 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
||||
try {
|
||||
Map<String, dynamic> metadata;
|
||||
|
||||
// Check if this is a Deezer album ID (format: "deezer:123456")
|
||||
if (widget.albumId.startsWith('deezer:')) {
|
||||
final deezerAlbumId = widget.albumId.replaceFirst('deezer:', '');
|
||||
// ignore: avoid_print
|
||||
print('[AlbumScreen] Fetching from Deezer: $deezerAlbumId');
|
||||
metadata = await PlatformBridge.getDeezerMetadata('album', deezerAlbumId);
|
||||
} else {
|
||||
// Spotify album - use fallback method
|
||||
// ignore: avoid_print
|
||||
print('[AlbumScreen] Fetching from Spotify with fallback: ${widget.albumId}');
|
||||
final url = 'https://open.spotify.com/album/${widget.albumId}';
|
||||
@@ -448,7 +446,6 @@ class _AlbumTrackItem extends ConsumerWidget {
|
||||
return state.items.where((item) => item.track.id == track.id).firstOrNull;
|
||||
}));
|
||||
|
||||
// Check if track is in history (already downloaded before)
|
||||
final isInHistory = ref.watch(downloadHistoryProvider.select((state) {
|
||||
return state.isDownloaded(track.id);
|
||||
}));
|
||||
|
||||
@@ -112,7 +112,6 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
||||
);
|
||||
});
|
||||
|
||||
// If this is an extension artist, use provided data only - don't fetch from Spotify/Deezer
|
||||
if (widget.extensionId != null) {
|
||||
_albums = widget.albums;
|
||||
_topTracks = widget.topTracks;
|
||||
@@ -122,8 +121,6 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
||||
return;
|
||||
}
|
||||
|
||||
// Priority: widget data > cache > fetch
|
||||
// But always fetch if topTracks is missing (to get popular tracks)
|
||||
final cached = _ArtistCache.get(widget.artistId);
|
||||
|
||||
if (widget.albums != null) {
|
||||
@@ -132,7 +129,6 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
||||
_headerImageUrl = widget.headerImageUrl;
|
||||
_monthlyListeners = widget.monthlyListeners;
|
||||
|
||||
// If we have albums but no top tracks, fetch to get them
|
||||
if (_topTracks == null || _topTracks!.isEmpty) {
|
||||
_fetchDiscography();
|
||||
}
|
||||
@@ -159,14 +155,12 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
||||
String? headerImage;
|
||||
int? listeners;
|
||||
|
||||
// Check if this is a Deezer artist ID (format: "deezer:123456")
|
||||
if (widget.artistId.startsWith('deezer:')) {
|
||||
final deezerArtistId = widget.artistId.replaceFirst('deezer:', '');
|
||||
final metadata = await PlatformBridge.getDeezerMetadata('artist', deezerArtistId);
|
||||
final albumsList = metadata['albums'] as List<dynamic>;
|
||||
albums = albumsList.map((a) => _parseArtistAlbum(a as Map<String, dynamic>)).toList();
|
||||
} else {
|
||||
// Spotify artist - use extension handler via URL
|
||||
final url = 'https://open.spotify.com/artist/${widget.artistId}';
|
||||
final result = await PlatformBridge.handleURLWithExtension(url);
|
||||
|
||||
@@ -302,8 +296,6 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
||||
|
||||
/// Build Spotify-style header with full-width image and artist name overlay
|
||||
Widget _buildHeader(BuildContext context, ColorScheme colorScheme) {
|
||||
// Use header image if available, otherwise fall back to cover URL
|
||||
// Prefer: fetched header > widget header > widget cover
|
||||
String? imageUrl = _headerImageUrl;
|
||||
if (imageUrl == null || imageUrl.isEmpty) {
|
||||
imageUrl = widget.headerImageUrl;
|
||||
@@ -467,7 +459,6 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
||||
return state.items.where((item) => item.track.id == track.id).firstOrNull;
|
||||
}));
|
||||
|
||||
// Check if track is in history (already downloaded before)
|
||||
final isInHistory = ref.watch(downloadHistoryProvider.select((state) {
|
||||
return state.isDownloaded(track.id);
|
||||
}));
|
||||
|
||||
@@ -159,7 +159,6 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final bottomPadding = MediaQuery.of(context).padding.bottom;
|
||||
|
||||
// Watch history and get tracks for this album (reactive!)
|
||||
final allHistoryItems = ref.watch(downloadHistoryProvider.select((s) => s.items));
|
||||
final tracks = _getAlbumTracks(allHistoryItems);
|
||||
|
||||
|
||||
@@ -329,7 +329,6 @@ class _HomeScreenState extends ConsumerState<HomeScreen> {
|
||||
}
|
||||
|
||||
Future<void> _openCollection(Track track) async {
|
||||
// Get the extension ID from the track source
|
||||
final extensionId = track.source;
|
||||
if (extensionId == null) return;
|
||||
|
||||
|
||||
@@ -76,10 +76,8 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
||||
} void _onSearchChanged() {
|
||||
final text = _urlController.text.trim();
|
||||
|
||||
// Update search text state for MainShell back button handling
|
||||
ref.read(trackProvider.notifier).setSearchText(text.isNotEmpty);
|
||||
|
||||
// Update typing state immediately for UI transition
|
||||
if (text.isNotEmpty && !_isTyping) {
|
||||
setState(() => _isTyping = true);
|
||||
} else if (text.isEmpty && _isTyping) {
|
||||
@@ -103,17 +101,13 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
||||
if (_lastSearchQuery == searchKey) return;
|
||||
_lastSearchQuery = searchKey;
|
||||
|
||||
// Check if extension search provider is set AND still enabled
|
||||
final isExtensionEnabled = searchProvider != null &&
|
||||
searchProvider.isNotEmpty &&
|
||||
extState.extensions.any((e) => e.id == searchProvider && e.enabled);
|
||||
|
||||
if (isExtensionEnabled) {
|
||||
// Use custom search from extension
|
||||
await ref.read(trackProvider.notifier).customSearch(searchProvider, query);
|
||||
} else {
|
||||
// Use default search (Deezer/Spotify)
|
||||
// Also clear searchProvider if it was set but extension is disabled
|
||||
if (searchProvider != null && searchProvider.isNotEmpty && !isExtensionEnabled) {
|
||||
ref.read(settingsProvider.notifier).setSearchProvider(null);
|
||||
}
|
||||
@@ -238,7 +232,6 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
||||
int currentProgress = 0;
|
||||
int totalTracks = 0;
|
||||
|
||||
// Use StatefulBuilder to update dialog content
|
||||
bool dialogShown = false;
|
||||
StateSetter? setDialogState;
|
||||
|
||||
@@ -322,8 +315,6 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
||||
action: SnackBarAction(
|
||||
label: l10n.snackbarViewQueue,
|
||||
onPressed: () {
|
||||
// Navigate to queue tab (handled by main_shell index)
|
||||
// We don't have direct access to set index here easily without provider
|
||||
},
|
||||
),
|
||||
),
|
||||
@@ -348,14 +339,12 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
||||
}
|
||||
});
|
||||
|
||||
// Use select() to only rebuild when specific fields change
|
||||
final tracks = ref.watch(trackProvider.select((s) => s.tracks));
|
||||
final searchArtists = ref.watch(trackProvider.select((s) => s.searchArtists));
|
||||
final isLoading = ref.watch(trackProvider.select((s) => s.isLoading));
|
||||
final error = ref.watch(trackProvider.select((s) => s.error));
|
||||
final hasSearchedBefore = ref.watch(settingsProvider.select((s) => s.hasSearchedBefore));
|
||||
|
||||
// Watch extension state to update search hint when extensions load/change
|
||||
ref.watch(extensionProvider.select((s) => s.isInitialized));
|
||||
ref.watch(extensionProvider.select((s) => s.extensions));
|
||||
|
||||
@@ -612,7 +601,6 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
||||
// Merge with recent downloads to make the list more populated
|
||||
final historyItems = ref.read(downloadHistoryProvider).items;
|
||||
|
||||
// Convert download history to RecentAccessItem format
|
||||
final downloadItems = historyItems.take(10).where((h) => h.spotifyId != null && h.spotifyId!.isNotEmpty).map((h) => RecentAccessItem(
|
||||
id: h.spotifyId!,
|
||||
name: h.trackName,
|
||||
@@ -748,7 +736,6 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
||||
],
|
||||
),
|
||||
),
|
||||
// Delete button (like Spotify's X)
|
||||
IconButton(
|
||||
icon: Icon(Icons.close, size: 20, color: colorScheme.onSurfaceVariant),
|
||||
onPressed: () {
|
||||
@@ -767,7 +754,6 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
||||
|
||||
switch (item.type) {
|
||||
case RecentAccessType.artist:
|
||||
// Check if artist is from extension (not spotify/deezer)
|
||||
if (item.providerId != null && item.providerId!.isNotEmpty && item.providerId != 'deezer' && item.providerId != 'spotify') {
|
||||
Navigator.push(context, MaterialPageRoute(
|
||||
builder: (context) => ExtensionArtistScreen(
|
||||
@@ -1389,16 +1375,13 @@ class _TrackItemWithStatus extends ConsumerWidget {
|
||||
return state.items.where((item) => item.track.id == track.id).firstOrNull;
|
||||
}));
|
||||
|
||||
// Check if track is in history (already downloaded before)
|
||||
final isInHistory = ref.watch(downloadHistoryProvider.select((state) {
|
||||
return state.isDownloaded(track.id);
|
||||
}));
|
||||
|
||||
// Get thumbnail size from extension if track is from extension
|
||||
double thumbWidth = 56;
|
||||
double thumbHeight = 56;
|
||||
|
||||
// Get extension ID from track.source or from TrackState.searchExtensionId
|
||||
final trackState = ref.watch(trackProvider);
|
||||
final extensionId = track.source ?? trackState.searchExtensionId;
|
||||
|
||||
@@ -1499,7 +1482,6 @@ class _TrackItemWithStatus extends ConsumerWidget {
|
||||
// If already in queue, do nothing
|
||||
if (isQueued) return;
|
||||
|
||||
// If in history, check if file still exists
|
||||
if (isInHistory) {
|
||||
final historyItem = ref.read(downloadHistoryProvider.notifier).getBySpotifyId(track.id);
|
||||
if (historyItem != null) {
|
||||
|
||||
@@ -36,7 +36,6 @@ class _MainShellState extends ConsumerState<MainShell> {
|
||||
void initState() {
|
||||
super.initState();
|
||||
_pageController = PageController(initialPage: _currentIndex);
|
||||
// Check for updates after first frame
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_checkForUpdates();
|
||||
_setupShareListener();
|
||||
@@ -44,7 +43,6 @@ class _MainShellState extends ConsumerState<MainShell> {
|
||||
}
|
||||
|
||||
void _setupShareListener() {
|
||||
// Check for pending URL that was received before listener was ready
|
||||
final pendingUrl = ShareIntentService().consumePendingUrl();
|
||||
if (pendingUrl != null) {
|
||||
_log.d('Processing pending shared URL: $pendingUrl');
|
||||
@@ -124,8 +122,6 @@ class _MainShellState extends ConsumerState<MainShell> {
|
||||
void _onPageChanged(int index) {
|
||||
if (_currentIndex != index) {
|
||||
setState(() => _currentIndex = index);
|
||||
// Unfocus any text field when switching tabs to prevent keyboard from appearing
|
||||
// Use primaryFocus for more aggressive unfocus that works with keep-alive widgets
|
||||
FocusManager.instance.primaryFocus?.unfocus();
|
||||
}
|
||||
}
|
||||
@@ -134,7 +130,6 @@ class _MainShellState extends ConsumerState<MainShell> {
|
||||
void _handleBackPress() {
|
||||
final trackState = ref.read(trackProvider);
|
||||
|
||||
// Check if keyboard is visible - if so, just dismiss keyboard, don't clear search
|
||||
final isKeyboardVisible = MediaQuery.of(context).viewInsets.bottom > 0;
|
||||
if (isKeyboardVisible) {
|
||||
FocusManager.instance.primaryFocus?.unfocus();
|
||||
@@ -144,7 +139,6 @@ class _MainShellState extends ConsumerState<MainShell> {
|
||||
// If on Home tab and showing recent access mode, exit it
|
||||
if (_currentIndex == 0 && trackState.isShowingRecentAccess) {
|
||||
ref.read(trackProvider.notifier).setShowingRecentAccess(false);
|
||||
// Also unfocus search bar when exiting recent access mode
|
||||
FocusManager.instance.primaryFocus?.unfocus();
|
||||
return;
|
||||
}
|
||||
@@ -189,7 +183,6 @@ class _MainShellState extends ConsumerState<MainShell> {
|
||||
final showStore = ref.watch(settingsProvider.select((s) => s.showExtensionStore));
|
||||
final storeUpdatesCount = ref.watch(storeProvider.select((s) => s.updatesAvailableCount));
|
||||
|
||||
// Check if keyboard is visible (bottom inset > 0 means keyboard is showing)
|
||||
final isKeyboardVisible = MediaQuery.of(context).viewInsets.bottom > 0;
|
||||
|
||||
// Determine if we can pop (for predictive back animation)
|
||||
@@ -202,7 +195,6 @@ class _MainShellState extends ConsumerState<MainShell> {
|
||||
!trackState.isShowingRecentAccess &&
|
||||
!isKeyboardVisible;
|
||||
|
||||
// Build tabs and destinations based on settings
|
||||
final tabs = <Widget>[
|
||||
const HomeTab(),
|
||||
QueueTab(
|
||||
|
||||
@@ -222,7 +222,6 @@ class _PlaylistTrackItem extends ConsumerWidget {
|
||||
return state.items.where((item) => item.track.id == track.id).firstOrNull;
|
||||
}));
|
||||
|
||||
// Check if track is in history (already downloaded before)
|
||||
final isInHistory = ref.watch(downloadHistoryProvider.select((state) {
|
||||
return state.isDownloaded(track.id);
|
||||
}));
|
||||
|
||||
@@ -473,7 +473,6 @@ class DownloadSettingsPage extends ConsumerWidget {
|
||||
// iOS: Show options dialog
|
||||
_showIOSDirectoryOptions(context, ref);
|
||||
} else {
|
||||
// Android: Use file picker
|
||||
final result = await FilePicker.platform.getDirectoryPath();
|
||||
if (result != null) {
|
||||
ref.read(settingsProvider.notifier).setDownloadDirectory(result);
|
||||
|
||||
@@ -500,7 +500,6 @@ class _MetadataPriorityItem extends ConsumerWidget {
|
||||
final extState = ref.watch(extensionProvider);
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
// Check if any extension has metadata provider
|
||||
final hasMetadataExtensions = extState.extensions
|
||||
.any((e) => e.enabled && e.hasMetadataProvider);
|
||||
|
||||
|
||||
@@ -838,7 +838,6 @@ class _MetadataSourceSelector extends ConsumerWidget {
|
||||
// Not selected if extension is active
|
||||
isSelected: currentSource == 'deezer' && !hasExtensionSearch,
|
||||
onTap: () {
|
||||
// If extension was active, reset it to default
|
||||
if (hasExtensionSearch) {
|
||||
ref.read(settingsProvider.notifier).setSearchProvider(null);
|
||||
}
|
||||
|
||||
@@ -123,16 +123,12 @@ class SettingsTab extends ConsumerWidget {
|
||||
}
|
||||
|
||||
void _navigateTo(BuildContext context, Widget page) {
|
||||
// Unfocus any focused widget before navigating to prevent keyboard from appearing on return
|
||||
FocusManager.instance.primaryFocus?.unfocus();
|
||||
|
||||
Navigator.of(context).push(
|
||||
// Use PageRouteBuilder for better predictive back gesture support
|
||||
// MaterialPageRoute can cause freeze on some devices with gesture navigation
|
||||
PageRouteBuilder(
|
||||
pageBuilder: (context, animation, secondaryAnimation) => page,
|
||||
transitionsBuilder: (context, animation, secondaryAnimation, child) {
|
||||
// Use slide transition similar to MaterialPageRoute
|
||||
const begin = Offset(1.0, 0.0);
|
||||
const end = Offset.zero;
|
||||
const curve = Curves.easeInOut;
|
||||
|
||||
@@ -45,7 +45,6 @@ class DynamicColorWrapper extends ConsumerWidget {
|
||||
darkScheme = _applyAmoledColors(darkScheme);
|
||||
}
|
||||
|
||||
// Build themes
|
||||
final lightTheme = AppTheme.light(dynamicScheme: lightScheme);
|
||||
final darkTheme = AppTheme.dark(dynamicScheme: darkScheme, isAmoled: themeSettings.useAmoled);
|
||||
|
||||
|
||||
@@ -55,7 +55,6 @@ class LogBuffer extends ChangeNotifier {
|
||||
static bool get loggingEnabled => _loggingEnabled;
|
||||
static set loggingEnabled(bool value) {
|
||||
_loggingEnabled = value;
|
||||
// Also notify Go backend about logging state
|
||||
if (value) {
|
||||
PlatformBridge.setGoLoggingEnabled(true).catchError((_) {});
|
||||
} else {
|
||||
@@ -121,7 +120,6 @@ class LogBuffer extends ChangeNotifier {
|
||||
);
|
||||
}
|
||||
} catch (_) {
|
||||
// Use current time if parsing fails
|
||||
}
|
||||
}
|
||||
|
||||
@@ -146,7 +144,6 @@ class LogBuffer extends ChangeNotifier {
|
||||
void clear() {
|
||||
_entries.clear();
|
||||
_lastGoLogIndex = 0;
|
||||
// Also clear Go backend logs
|
||||
PlatformBridge.clearGoLogs().catchError((_) {});
|
||||
notifyListeners();
|
||||
}
|
||||
@@ -249,8 +246,6 @@ class AppLogger {
|
||||
late final Logger? _logger;
|
||||
|
||||
AppLogger(this._tag) {
|
||||
// Only create Logger instance in debug mode
|
||||
// In release mode, we write directly to LogBuffer
|
||||
if (kDebugMode) {
|
||||
_logger = Logger(
|
||||
printer: SimplePrinter(printTime: false, colors: false),
|
||||
@@ -276,7 +271,6 @@ class AppLogger {
|
||||
if (kDebugMode) {
|
||||
_logger?.d(message);
|
||||
} else {
|
||||
// In release mode, write directly to buffer
|
||||
_addToBuffer('DEBUG', message);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user