diff --git a/CHANGELOG.md b/CHANGELOG.md index b8b77cf3..2c6b0fc8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ Same as 3.3.1 but fixes crash issues caused by FFmpeg. ### Fixed - **FFmpeg Crash**: Fixed crash issues during M4A to MP3/Opus conversion +- **Service Selection Ignored**: Fixed bug where selecting Qobuz/Amazon from service picker was ignored and always used Tidal instead + +### Changed + +- **Amazon Fallback Only**: Amazon Music is now grayed out in service picker and can only be used as fallback provider --- diff --git a/go_backend/amazon.go b/go_backend/amazon.go index ac90429e..0349d936 100644 --- a/go_backend/amazon.go +++ b/go_backend/amazon.go @@ -55,7 +55,6 @@ func NewAmazonDownloader() *AmazonDownloader { } func (a *AmazonDownloader) downloadFromAfkarXYZ(amazonURL string) (string, string, error) { - // AfkarXYZ API endpoint apiURL := "https://amazon.afkarxyz.fun/convert?url=" + url.QueryEscape(amazonURL) GoLog("[Amazon] Fetching from AfkarXYZ API...\n") @@ -96,7 +95,6 @@ func (a *AmazonDownloader) downloadFromAfkarXYZ(amazonURL string) (string, strin fileName = "track.flac" } - // Sanitize filename reg := regexp.MustCompile(`[<>:"/\\|?*]`) fileName = reg.ReplaceAllString(fileName, "") @@ -108,7 +106,6 @@ func (a *AmazonDownloader) downloadFromAfkarXYZ(amazonURL string) (string, strin func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath, itemID string) error { ctx := context.Background() - // Initialize item progress (required for all downloads) if itemID != "" { StartItemProgress(itemID) defer CompleteItemProgress(itemID) @@ -160,7 +157,6 @@ func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath, itemID string) written, err = io.Copy(bufWriter, resp.Body) } - // Flush buffer before checking for errors flushErr := bufWriter.Flush() closeErr := out.Close() @@ -180,7 +176,6 @@ func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath, itemID string) return fmt.Errorf("failed to close file: %w", closeErr) } - // Verify file size if Content-Length was provided if expectedSize > 0 && written != expectedSize { os.Remove(outputPath) return fmt.Errorf("incomplete download: expected %d bytes, got %d bytes", expectedSize, written) @@ -302,7 +297,6 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) { actualArtist := req.ArtistName if metaErr == nil && existingMeta != nil { - // Use track/disc number from Amazon file if request has default values 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) @@ -311,23 +305,18 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) { actualDiscNum = existingMeta.DiscNumber GoLog("[Amazon] Using disc number from file: %d (request had: %d)\n", actualDiscNum, req.DiscNumber) } - // Use release date from Amazon file if request doesn't have it if existingMeta.Date != "" && req.ReleaseDate == "" { actualDate = existingMeta.Date GoLog("[Amazon] Using release date from file: %s\n", actualDate) } - // Use album from Amazon file if request doesn't have it if existingMeta.Album != "" && req.AlbumName == "" { actualAlbum = existingMeta.Album GoLog("[Amazon] Using album from file: %s\n", actualAlbum) } - // Log existing metadata for debugging GoLog("[Amazon] Existing metadata - Title: %s, Artist: %s, Album: %s, Date: %s\n", existingMeta.Title, existingMeta.Artist, existingMeta.Album, existingMeta.Date) } - // Embed metadata using Spotify/Deezer data (preferred for consistency) - // but use Amazon file data as fallback for missing fields metadata := Metadata{ Title: actualTitle, Artist: actualArtist, @@ -343,13 +332,11 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) { Copyright: req.Copyright, } - // Use cover data from parallel fetch, or extract from Amazon file if not available var coverData []byte if parallelResult != nil && parallelResult.CoverData != nil && len(parallelResult.CoverData) > 0 { coverData = parallelResult.CoverData GoLog("[Amazon] Using parallel-fetched cover (%d bytes)\n", len(coverData)) } else { - // Try to extract existing cover from Amazon file existingCover, coverErr := ExtractCoverArt(outputPath) if coverErr == nil && len(existingCover) > 0 { coverData = existingCover @@ -366,7 +353,7 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) { if req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" { lyricsMode := req.LyricsMode if lyricsMode == "" { - lyricsMode = "embed" // default + lyricsMode = "embed" } if lyricsMode == "external" || lyricsMode == "both" { diff --git a/go_backend/deezer.go b/go_backend/deezer.go index effa2437..5d26c391 100644 --- a/go_backend/deezer.go +++ b/go_backend/deezer.go @@ -55,7 +55,7 @@ func GetDeezerClient() *DeezerClient { type deezerTrack struct { ID int64 `json:"id"` Title string `json:"title"` - Duration int `json:"duration"` // in seconds + Duration int `json:"duration"` TrackPosition int `json:"track_position"` DiskNumber int `json:"disk_number"` ISRC string `json:"isrc"` @@ -121,7 +121,7 @@ func (c *DeezerClient) convertTrack(track deezerTrack) TrackMetadata { AlbumArtist: track.Artist.Name, DurationMS: track.Duration * 1000, Images: albumImage, - ReleaseDate: releaseDate, // Added this + ReleaseDate: releaseDate, TrackNumber: track.TrackPosition, DiscNumber: track.DiskNumber, ExternalURL: track.Link, @@ -182,15 +182,12 @@ type deezerPlaylistFull struct { } `json:"tracks"` } -// NOTE: ISRC is NOT fetched during search for performance - use GetTrackISRC when needed for download -// filter can be: "" (all), "track", "artist", "album", "playlist" func (c *DeezerClient) SearchAll(ctx context.Context, query string, trackLimit, artistLimit int, filter string) (*SearchAllResult, error) { GoLog("[Deezer] SearchAll: query=%q, trackLimit=%d, artistLimit=%d, filter=%q\n", query, trackLimit, artistLimit, filter) - albumLimit := 5 // Same as artistLimit for consistency + albumLimit := 5 playlistLimit := 5 - // When filter is specified, increase limits for that type only if filter != "" { switch filter { case "track": @@ -233,7 +230,6 @@ func (c *DeezerClient) SearchAll(ctx context.Context, query string, trackLimit, Playlists: make([]SearchPlaylistResult, 0, playlistLimit), } - // Search tracks - NO ISRC fetch for performance if trackLimit > 0 { trackURL := fmt.Sprintf("%s/track?q=%s&limit=%d", deezerSearchURL, url.QueryEscape(query), trackLimit) GoLog("[Deezer] Fetching tracks from: %s\n", trackURL) @@ -263,7 +259,6 @@ func (c *DeezerClient) SearchAll(ctx context.Context, query string, trackLimit, } } - // Search artists if artistLimit > 0 { artistURL := fmt.Sprintf("%s/artist?q=%s&limit=%d", deezerSearchURL, url.QueryEscape(query), artistLimit) GoLog("[Deezer] Fetching artists from: %s\n", artistURL) @@ -296,7 +291,6 @@ func (c *DeezerClient) SearchAll(ctx context.Context, query string, trackLimit, } } - // Search albums if albumLimit > 0 { albumURL := fmt.Sprintf("%s/album?q=%s&limit=%d", deezerSearchURL, url.QueryEscape(query), albumLimit) GoLog("[Deezer] Fetching albums from: %s\n", albumURL) @@ -358,7 +352,6 @@ func (c *DeezerClient) SearchAll(ctx context.Context, query string, trackLimit, } } - // Search playlists if playlistLimit > 0 { playlistURL := fmt.Sprintf("%s/playlist?q=%s&limit=%d", deezerSearchURL, url.QueryEscape(query), playlistLimit) GoLog("[Deezer] Fetching playlists from: %s\n", playlistURL) @@ -438,7 +431,6 @@ func (c *DeezerClient) GetTrack(ctx context.Context, trackID string) (*TrackResp }, nil } -// ISRC is fetched in parallel for better performance func (c *DeezerClient) GetAlbum(ctx context.Context, albumID string) (*AlbumResponsePayload, error) { c.cacheMu.RLock() if entry, ok := c.albumCache[albumID]; ok && !entry.isExpired() { @@ -464,7 +456,6 @@ func (c *DeezerClient) GetAlbum(ctx context.Context, albumID string) (*AlbumResp artistName = strings.Join(names, ", ") } - // Extract genres as comma-separated string var genres []string for _, g := range album.Genres.Data { if g.Name != "" { @@ -480,14 +471,12 @@ func (c *DeezerClient) GetAlbum(ctx context.Context, albumID string) (*AlbumResp Artists: artistName, ArtistId: fmt.Sprintf("deezer:%d", album.Artist.ID), Images: albumImage, - Genre: genreStr, // From Deezer album - Label: album.Label, // From Deezer album + Genre: genreStr, + Label: album.Label, } - // Fetch all tracks with pagination (Deezer default limit is 25) allTracks := album.Tracks.Data - // If album has more tracks than returned, fetch remaining pages if album.NbTracks > len(allTracks) { GoLog("[Deezer] Album has %d tracks but only got %d, fetching remaining...", album.NbTracks, len(allTracks)) @@ -522,7 +511,6 @@ func (c *DeezerClient) GetAlbum(ctx context.Context, albumID string) (*AlbumResp isrcMap := c.fetchISRCsParallel(ctx, allTracks) tracks := make([]AlbumTrackMetadata, 0, len(allTracks)) - // Normalize record_type (Deezer uses "compile" instead of "compilation") albumType := album.RecordType if albumType == "compile" { albumType = "compilation" @@ -532,7 +520,6 @@ func (c *DeezerClient) GetAlbum(ctx context.Context, albumID string) (*AlbumResp trackIDStr := fmt.Sprintf("%d", track.ID) isrc := isrcMap[trackIDStr] - // Use track position from API, fallback to index+1 if not provided trackNum := track.TrackPosition if trackNum == 0 { trackNum = i + 1 @@ -580,7 +567,6 @@ func (c *DeezerClient) GetArtist(ctx context.Context, artistID string) (*ArtistR } c.cacheMu.RUnlock() - // Fetch artist info artistURL := fmt.Sprintf(deezerArtistURL, artistID) var artist deezerArtistFull if err := c.getJSON(ctx, artistURL, &artist); err != nil { @@ -595,7 +581,6 @@ func (c *DeezerClient) GetArtist(ctx context.Context, artistID string) (*ArtistR Popularity: 0, } - // Fetch artist albums albumsURL := fmt.Sprintf("%s/albums?limit=100", fmt.Sprintf(deezerArtistURL, artistID)) var albumsResp struct { Data []struct { @@ -607,7 +592,7 @@ func (c *DeezerClient) GetArtist(ctx context.Context, artistID string) (*ArtistR CoverMedium string `json:"cover_medium"` CoverBig string `json:"cover_big"` CoverXL string `json:"cover_xl"` - RecordType string `json:"record_type"` // album, single, ep, compile + RecordType string `json:"record_type"` } `json:"data"` } @@ -679,10 +664,8 @@ func (c *DeezerClient) GetPlaylist(ctx context.Context, playlistID string) (*Pla info.Owner.Name = playlist.Title info.Owner.Images = playlistImage - // Fetch all tracks with pagination (Deezer default limit is 25) allTracks := playlist.Tracks.Data - // If playlist has more tracks than returned, fetch remaining pages if playlist.NbTracks > len(allTracks) { GoLog("[Deezer] Playlist has %d tracks but only got %d, fetching remaining...", playlist.NbTracks, len(allTracks)) @@ -788,7 +771,6 @@ func (c *DeezerClient) fetchFullTrack(ctx context.Context, trackID string) (*dee return &track, nil } -// fetchISRCsParallel fetches ISRCs for multiple tracks in parallel with caching func (c *DeezerClient) fetchISRCsParallel(ctx context.Context, tracks []deezerTrack) map[string]string { result := make(map[string]string, len(tracks)) var resultMu sync.Mutex @@ -827,7 +809,6 @@ func (c *DeezerClient) fetchISRCsParallel(ctx context.Context, tracks []deezerTr return result } - // Use semaphore to limit concurrent requests sem := make(chan struct{}, deezerMaxParallelISRC) var wg sync.WaitGroup @@ -849,7 +830,6 @@ func (c *DeezerClient) fetchISRCsParallel(ctx context.Context, tracks []deezerTr return } - // Store in result and cache resultMu.Lock() result[trackIDStr] = fullTrack.ISRC resultMu.Unlock() @@ -864,7 +844,6 @@ func (c *DeezerClient) fetchISRCsParallel(ctx context.Context, tracks []deezerTr return result } -// Use this when you need ISRC for download func (c *DeezerClient) GetTrackISRC(ctx context.Context, trackID string) (string, error) { c.cacheMu.RLock() if isrc, ok := c.isrcCache[trackID]; ok { @@ -925,11 +904,10 @@ func (c *DeezerClient) getBestAlbumImage(album deezerAlbumFull) string { } type AlbumExtendedMetadata struct { - Genre string // Comma-separated list of genres - Label string // Record label name + Genre string + Label string } -// 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") @@ -985,7 +963,6 @@ func (c *DeezerClient) GetTrackAlbumID(ctx context.Context, trackID string) (str return fmt.Sprintf("%d", track.Album.ID), nil } -// 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) if err != nil { @@ -995,26 +972,22 @@ func (c *DeezerClient) GetExtendedMetadataByTrackID(ctx context.Context, trackID return c.GetAlbumExtendedMetadata(ctx, albumID) } -// GetExtendedMetadataByISRC searches for a track by ISRC and fetches extended metadata (genre, label) func (c *DeezerClient) GetExtendedMetadataByISRC(ctx context.Context, isrc string) (*AlbumExtendedMetadata, error) { if isrc == "" { return nil, fmt.Errorf("empty ISRC") } - // First, search for track by ISRC track, err := c.SearchByISRC(ctx, isrc) if err != nil { return nil, fmt.Errorf("failed to find track by ISRC: %w", err) } - // SpotifyID contains "deezer:123" format, extract the ID deezerID := strings.TrimPrefix(track.SpotifyID, "deezer:") if deezerID == "" { return nil, fmt.Errorf("track found but no Deezer ID") } - // Then fetch extended metadata using the Deezer track ID return c.GetExtendedMetadataByTrackID(ctx, deezerID) } diff --git a/go_backend/extension_providers.go b/go_backend/extension_providers.go index 4f66aeca..ead2d28c 100644 --- a/go_backend/extension_providers.go +++ b/go_backend/extension_providers.go @@ -732,7 +732,7 @@ func GetMetadataProviderPriority() []string { // isBuiltInProvider checks if a provider ID is a built-in provider func isBuiltInProvider(providerID string) bool { switch providerID { - case "tidal", "qobuz", "amazon": + case "tidal", "qobuz", "amazon", "deezer": return true default: return false @@ -748,6 +748,21 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro priority := GetProviderPriority() extManager := GetExtensionManager() + // If req.Service is a built-in provider, prioritize it first + // This handles user's explicit selection from the service picker + if req.Service != "" && isBuiltInProvider(req.Service) { + GoLog("[DownloadWithExtensionFallback] User selected service: %s, prioritizing it first\n", req.Service) + // Reorder priority to put req.Service first + newPriority := []string{req.Service} + for _, p := range priority { + if p != req.Service { + newPriority = append(newPriority, p) + } + } + priority = newPriority + GoLog("[DownloadWithExtensionFallback] New priority order: %v\n", priority) + } + var lastErr error var skipBuiltIn bool // If source extension has skipBuiltInFallback, don't try built-in providers diff --git a/go_backend/extension_runtime_auth.go b/go_backend/extension_runtime_auth.go index ce63b1d8..37178339 100644 --- a/go_backend/extension_runtime_auth.go +++ b/go_backend/extension_runtime_auth.go @@ -70,13 +70,11 @@ func (r *ExtensionRuntime) authGetCode(call goja.FunctionCall) goja.Value { return r.vm.ToValue(state.AuthCode) } -// authSetCode sets auth code and tokens (can be called by extension after token exchange) func (r *ExtensionRuntime) authSetCode(call goja.FunctionCall) goja.Value { if len(call.Arguments) < 1 { return r.vm.ToValue(false) } - // Can accept either just auth code or an object with tokens arg := call.Arguments[0].Export() extensionAuthStateMu.Lock() diff --git a/go_backend/metadata.go b/go_backend/metadata.go index 17c94910..d677994c 100644 --- a/go_backend/metadata.go +++ b/go_backend/metadata.go @@ -353,7 +353,6 @@ func ExtractCoverArt(filePath string) ([]byte, error) { } } - // If no front cover found, return any picture for _, meta := range f.Meta { if meta.Type == flac.Picture { pic, err := flacpicture.ParseFromMetaDataBlock(*meta) diff --git a/go_backend/qobuz.go b/go_backend/qobuz.go index 22174483..c3132fa6 100644 --- a/go_backend/qobuz.go +++ b/go_backend/qobuz.go @@ -52,12 +52,10 @@ func qobuzArtistsMatch(expectedArtist, foundArtist string) bool { normExpected := strings.ToLower(strings.TrimSpace(expectedArtist)) normFound := strings.ToLower(strings.TrimSpace(foundArtist)) - // Exact match if normExpected == normFound { return true } - // Check if one contains the other if strings.Contains(normExpected, normFound) || strings.Contains(normFound, normExpected) { return true } @@ -116,18 +114,15 @@ func qobuzSameWordsUnordered(a, b string) bool { wordsA := strings.Fields(a) wordsB := strings.Fields(b) - // Must have same number of words if len(wordsA) != len(wordsB) || len(wordsA) == 0 { 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] { @@ -151,7 +146,6 @@ func qobuzTitlesMatch(expectedTitle, foundTitle string) bool { normExpected := strings.ToLower(strings.TrimSpace(expectedTitle)) normFound := strings.ToLower(strings.TrimSpace(foundTitle)) - // Exact match if normExpected == normFound { return true } @@ -180,8 +174,6 @@ func qobuzTitlesMatch(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 := qobuzIsLatinScript(expectedTitle) foundLatin := qobuzIsLatinScript(foundTitle) if expectedLatin != foundLatin { @@ -193,7 +185,6 @@ func qobuzTitlesMatch(expectedTitle, foundTitle string) bool { } func qobuzExtractCoreTitle(title string) string { - // Find first occurrence of ( or [ parenIdx := strings.Index(title, "(") bracketIdx := strings.Index(title, "[") dashIdx := strings.Index(title, " - ") @@ -280,26 +271,20 @@ func qobuzCleanTitle(title string) string { func qobuzIsLatinScript(s string) bool { for _, r := range s { - // Skip common punctuation and numbers if r < 128 { continue } - // Latin Extended-A: U+0100 to U+017F (Polish, Czech, etc.) - // Latin Extended-B: U+0180 to U+024F - // Latin Extended Additional: U+1E00 to U+1EFF - // Latin Extended-C/D/E: various ranges - if (r >= 0x0100 && r <= 0x024F) || // Latin Extended A & B - (r >= 0x1E00 && r <= 0x1EFF) || // Latin Extended Additional - (r >= 0x00C0 && r <= 0x00FF) { // Latin-1 Supplement (accented chars) + 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 } } @@ -318,7 +303,7 @@ func containsQueryQobuz(queries []string, query string) bool { func NewQobuzDownloader() *QobuzDownloader { qobuzDownloaderOnce.Do(func() { globalQobuzDownloader = &QobuzDownloader{ - client: NewHTTPClientWithTimeout(DefaultTimeout), // 60s timeout + client: NewHTTPClientWithTimeout(DefaultTimeout), appID: "798273057", } }) @@ -326,7 +311,6 @@ func NewQobuzDownloader() *QobuzDownloader { } func (q *QobuzDownloader) GetTrackByID(trackID int64) (*QobuzTrack, error) { - // Qobuz API: /track/get?track_id=XXX apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly93d3cucW9idXouY29tL2FwaS5qc29uLzAuMi90cmFjay9nZXQ/dHJhY2tfaWQ9") trackURL := fmt.Sprintf("%s%d&app_id=%s", string(apiBase), trackID, q.appID) @@ -354,12 +338,10 @@ func (q *QobuzDownloader) GetTrackByID(trackID int64) (*QobuzTrack, error) { } func (q *QobuzDownloader) GetAvailableAPIs() []string { - // Same APIs as PC version (referensi/backend/qobuz.go) - // Primary: dab.yeet.su, Fallback: dabmusic.xyz, qobuz.squid.wtf encodedAPIs := []string{ - "ZGFiLnllZXQuc3UvYXBpL3N0cmVhbT90cmFja0lkPQ==", // dab.yeet.su/api/stream?trackId= - "ZGFibXVzaWMueHl6L2FwaS9zdHJlYW0/dHJhY2tJZD0=", // dabmusic.xyz/api/stream?trackId= - "cW9idXouc3F1aWQud3RmL2FwaS9kb3dubG9hZC1tdXNpYz90cmFja19pZD0=", // qobuz.squid.wtf/api/download-music?track_id= + "ZGFiLnllZXQuc3UvYXBpL3N0cmVhbT90cmFja0lkPQ==", + "ZGFibXVzaWMueHl6L2FwaS9zdHJlYW0/dHJhY2tJZD0=", + "cW9idXouc3F1aWQud3RmL2FwaS9kb3dubG9hZC1tdXNpYz90cmFja19pZD0=", } var apis []string @@ -377,11 +359,11 @@ func (q *QobuzDownloader) GetAvailableAPIs() []string { func mapJumoQuality(quality string) int { switch quality { case "6": - return 6 // 16-bit FLAC + return 6 case "7": - return 7 // 24-bit 96kHz + return 7 case "27": - return 27 // 24-bit 192kHz + return 27 default: return 6 } @@ -401,8 +383,6 @@ func decodeXOR(data []byte) string { func (q *QobuzDownloader) downloadFromJumo(trackID int64, quality string) (string, error) { formatID := mapJumoQuality(quality) region := "US" - - // Jumo API endpoint jumoURL := fmt.Sprintf("https://jumo-dl.pages.dev/file?track_id=%d&format_id=%d®ion=%s", trackID, formatID, region) GoLog("[Qobuz] Trying Jumo API fallback...\n") @@ -429,17 +409,13 @@ func (q *QobuzDownloader) downloadFromJumo(trackID int64, quality string) (strin } var result map[string]any - - // Try parsing as plain JSON first if err := json.Unmarshal(body, &result); err != nil { - // Try XOR decoding decoded := decodeXOR(body) if err := json.Unmarshal([]byte(decoded), &result); err != nil { return "", fmt.Errorf("failed to parse Jumo response (plain or XOR): %w", err) } } - // Check for URL in various response formats if urlVal, ok := result["url"].(string); ok && urlVal != "" { GoLog("[Qobuz] Jumo API returned URL successfully\n") return urlVal, nil @@ -488,7 +464,6 @@ func (q *QobuzDownloader) SearchTrackByISRC(isrc string) (*QobuzTrack, error) { return nil, err } - // Find exact ISRC match for i := range result.Tracks.Items { if result.Tracks.Items[i].ISRC == isrc { return &result.Tracks.Items[i], nil @@ -502,7 +477,6 @@ func (q *QobuzDownloader) SearchTrackByISRC(isrc string) (*QobuzTrack, error) { return nil, fmt.Errorf("no exact ISRC match found for: %s", isrc) } -// 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) @@ -535,7 +509,6 @@ func (q *QobuzDownloader) SearchTrackByISRCWithDuration(isrc string, expectedDur GoLog("[Qobuz] ISRC search returned %d results\n", len(result.Tracks.Items)) - // Find ISRC matches var isrcMatches []*QobuzTrack for i := range result.Tracks.Items { if result.Tracks.Items[i].ISRC == isrc { @@ -589,35 +562,26 @@ func (q *QobuzDownloader) SearchTrackByMetadata(trackName, artistName string) (* return q.SearchTrackByMetadataWithDuration(trackName, artistName, 0) } -// Now includes romaji conversion for Japanese text (same as Tidal) -// Also includes title verification to prevent wrong song downloads func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistName string, expectedDurationSec int) (*QobuzTrack, error) { apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly93d3cucW9idXouY29tL2FwaS5qc29uLzAuMi90cmFjay9zZWFyY2g/cXVlcnk9") - // Try multiple search strategies (same as Tidal/PC version) queries := []string{} - // Strategy 1: Artist + Track name if artistName != "" && trackName != "" { queries = append(queries, artistName+" "+trackName) } - // Strategy 2: Track name only if trackName != "" { queries = append(queries, trackName) } - // Strategy 3: Romaji versions if Japanese detected if ContainsJapanese(trackName) || ContainsJapanese(artistName) { - // Convert to romaji (hiragana/katakana only, kanji stays) romajiTrack := JapaneseToRomaji(trackName) romajiArtist := JapaneseToRomaji(artistName) - // Clean and remove ALL non-ASCII characters (including kanji) cleanRomajiTrack := CleanToASCII(romajiTrack) cleanRomajiArtist := CleanToASCII(romajiArtist) - // Artist + Track romaji (cleaned to ASCII only) if cleanRomajiArtist != "" && cleanRomajiTrack != "" { romajiQuery := cleanRomajiArtist + " " + cleanRomajiTrack if !containsQueryQobuz(queries, romajiQuery) { @@ -626,7 +590,6 @@ func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistNam } } - // Track romaji only (cleaned) if cleanRomajiTrack != "" && cleanRomajiTrack != trackName { if !containsQueryQobuz(queries, cleanRomajiTrack) { queries = append(queries, cleanRomajiTrack) @@ -634,7 +597,6 @@ func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistNam } } - // Strategy 4: Artist only as last resort if artistName != "" { artistOnly := CleanToASCII(JapaneseToRomaji(artistName)) if artistOnly != "" && !containsQueryQobuz(queries, artistOnly) { @@ -693,7 +655,6 @@ func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistNam return nil, fmt.Errorf("no tracks found for: %s - %s", artistName, trackName) } - // Filter by title match first (NEW - like Tidal) var titleMatches []*QobuzTrack for i := range allTracks { track := &allTracks[i] @@ -704,7 +665,6 @@ func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistNam GoLog("[Qobuz] Title matches: %d out of %d results\n", len(titleMatches), len(allTracks)) - // If no title matches, log warning but continue with all tracks tracksToCheck := titleMatches if len(titleMatches) == 0 { GoLog("[Qobuz] WARNING: No title matches for '%s', checking all %d results\n", trackName, len(allTracks)) @@ -713,7 +673,6 @@ func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistNam } } - // If duration verification is requested if expectedDurationSec > 0 { var durationMatches []*QobuzTrack for _, track := range tracksToCheck { @@ -742,7 +701,6 @@ func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistNam return nil, fmt.Errorf("no tracks found with matching title and duration (expected '%s', %ds)", trackName, expectedDurationSec) } - // No duration verification, return best quality from title matches for _, track := range tracksToCheck { if track.MaximumBitDepth >= 24 { GoLog("[Qobuz] Match found: '%s' by '%s' (title verified, hi-res)\n", @@ -760,7 +718,6 @@ func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistNam return nil, fmt.Errorf("no matching track found for: %s - %s", artistName, trackName) } -// qobuzAPIResult holds the result from a parallel API request type qobuzAPIResult struct { apiURL string downloadURL string @@ -778,7 +735,6 @@ func getQobuzDownloadURLParallel(apis []string, trackID int64, quality string) ( resultChan := make(chan qobuzAPIResult, len(apis)) startTime := time.Now() - // Start all requests in parallel for _, apiURL := range apis { go func(api string) { reqStart := time.Now() @@ -811,13 +767,11 @@ func getQobuzDownloadURLParallel(apis []string, trackID int64, quality string) ( return } - // Check if response is HTML (error page) if len(body) > 0 && body[0] == '<' { resultChan <- qobuzAPIResult{apiURL: api, err: fmt.Errorf("received HTML instead of JSON"), duration: time.Since(reqStart)} return } - // Check for error in JSON response var errorResp struct { Error string `json:"error"` } @@ -843,7 +797,6 @@ func getQobuzDownloadURLParallel(apis []string, trackID int64, quality string) ( }(apiURL) } - // Collect results - return first success var errors []string for i := 0; i < len(apis); i++ { @@ -851,7 +804,6 @@ func getQobuzDownloadURLParallel(apis []string, trackID int64, quality string) ( if result.err == nil { GoLog("[Qobuz] [Parallel] Got response from %s in %v\n", result.apiURL, result.duration) - // Drain remaining results to avoid goroutine leaks go func(remaining int) { for j := 0; j < remaining; j++ { <-resultChan @@ -883,14 +835,12 @@ func (q *QobuzDownloader) GetDownloadURL(trackID int64, quality string) (string, return downloadURL, nil } - // All standard APIs failed, try Jumo as fallback GoLog("[Qobuz] Standard APIs failed, trying Jumo fallback...\n") jumoURL, jumoErr := q.downloadFromJumo(trackID, quality) if jumoErr == nil { return jumoURL, nil } - // If quality is 27 (hi-res), try fallback to lower quality if quality == "27" { GoLog("[Qobuz] Hi-res (27) failed, trying 24-bit (7)...\n") jumoURL, jumoErr = q.downloadFromJumo(trackID, "7") @@ -913,7 +863,6 @@ func (q *QobuzDownloader) GetDownloadURL(trackID int64, quality string) (string, func (q *QobuzDownloader) DownloadFile(downloadURL, outputPath, itemID string) error { ctx := context.Background() - // Initialize item progress (required for all downloads) if itemID != "" { StartItemProgress(itemID) defer CompleteItemProgress(itemID) @@ -963,7 +912,6 @@ func (q *QobuzDownloader) DownloadFile(downloadURL, outputPath, itemID string) e written, err = io.Copy(bufWriter, resp.Body) } - // Flush buffer before checking for errors flushErr := bufWriter.Flush() closeErr := out.Close() @@ -983,7 +931,6 @@ func (q *QobuzDownloader) DownloadFile(downloadURL, outputPath, itemID string) e return fmt.Errorf("failed to close file: %w", closeErr) } - // Verify file size if Content-Length was provided if expectedSize > 0 && written != expectedSize { os.Remove(outputPath) return fmt.Errorf("incomplete download: expected %d bytes, got %d bytes", expectedSize, written) @@ -1031,11 +978,9 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) { } } - // OPTIMIZATION: Check cache first for track ID if track == nil && req.ISRC != "" { if cached := GetTrackIDCache().Get(req.ISRC); cached != nil && cached.QobuzTrackID > 0 { GoLog("[Qobuz] Cache hit! Using cached track ID: %d\n", cached.QobuzTrackID) - // For Qobuz we need to search again to get full track info, but we can use the ID track, err = downloader.SearchTrackByISRC(req.ISRC) if err != nil { GoLog("[Qobuz] Cache hit but search failed: %v\n", err) @@ -1044,11 +989,9 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) { } } - // Strategy 1: Search by ISRC with duration verification if track == nil && req.ISRC != "" { GoLog("[Qobuz] Trying ISRC search: %s\n", req.ISRC) track, err = downloader.SearchTrackByISRCWithDuration(req.ISRC, expectedDurationSec) - // Verify artist AND title if track != nil { if !qobuzArtistsMatch(req.ArtistName, track.Performer.Name) { GoLog("[Qobuz] Artist mismatch from ISRC search: expected '%s', got '%s'. Rejecting.\n", @@ -1062,10 +1005,8 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) { } } - // Strategy 2: Search by metadata with duration verification (includes title verification) if track == nil { track, err = downloader.SearchTrackByMetadataWithDuration(req.TrackName, req.ArtistName, expectedDurationSec) - // Verify artist (title already verified in SearchTrackByMetadataWithDuration) if track != nil && !qobuzArtistsMatch(req.ArtistName, track.Performer.Name) { GoLog("[Qobuz] Artist mismatch from metadata search: expected '%s', got '%s'. Rejecting.\n", req.ArtistName, track.Performer.Name) @@ -1081,7 +1022,6 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) { return QobuzDownloadResult{}, fmt.Errorf("qobuz search failed: %s", errMsg) } - // Log match found and cache the track ID GoLog("[Qobuz] Match found: '%s' by '%s' (duration: %ds)\n", track.Title, track.Performer.Name, track.Duration) if req.ISRC != "" { GetTrackIDCache().SetQobuz(req.ISRC, track.ID) @@ -1102,22 +1042,19 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) { return QobuzDownloadResult{FilePath: "EXISTS:" + outputPath}, nil } - // Map quality from Tidal format to Qobuz format - // Tidal: LOSSLESS (16-bit), HI_RES (24-bit), HI_RES_LOSSLESS (24-bit hi-res) - // Qobuz: 5 (MP3 320), 6 (16-bit), 7 (24-bit 96kHz), 27 (24-bit 192kHz) - qobuzQuality := "27" // Default to highest quality + qobuzQuality := "27" switch req.Quality { case "LOSSLESS": - qobuzQuality = "6" // 16-bit FLAC + qobuzQuality = "6" case "HI_RES": - qobuzQuality = "7" // 24-bit 96kHz + qobuzQuality = "7" case "HI_RES_LOSSLESS": - qobuzQuality = "27" // 24-bit 192kHz + qobuzQuality = "27" } GoLog("[Qobuz] Using quality: %s (mapped from %s)\n", qobuzQuality, req.Quality) actualBitDepth := track.MaximumBitDepth - actualSampleRate := int(track.MaximumSamplingRate * 1000) // Convert kHz to Hz + actualSampleRate := int(track.MaximumSamplingRate * 1000) GoLog("[Qobuz] Actual quality: %d-bit/%.1fkHz\n", actualBitDepth, track.MaximumSamplingRate) downloadURL, err := downloader.GetDownloadURL(track.ID, qobuzQuality) @@ -1125,7 +1062,6 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) { return QobuzDownloadResult{}, fmt.Errorf("failed to get download URL: %w", err) } - // START PARALLEL: Fetch cover and lyrics while downloading audio var parallelResult *ParallelDownloadResult parallelDone := make(chan struct{}) go func() { @@ -1141,7 +1077,6 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) { ) }() - // Download audio file with item ID for progress tracking if err := downloader.DownloadFile(downloadURL, outputPath, req.ItemID); err != nil { if errors.Is(err, ErrDownloadCancelled) { return QobuzDownloadResult{}, ErrDownloadCancelled @@ -1149,7 +1084,6 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) { return QobuzDownloadResult{}, fmt.Errorf("download failed: %w", err) } - // Wait for parallel operations to complete <-parallelDone if req.ItemID != "" { @@ -1162,7 +1096,6 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) { albumName = req.AlbumName } - // Use track number from request if available, otherwise from Qobuz API actualTrackNumber := req.TrackNumber if actualTrackNumber == 0 { actualTrackNumber = track.TrackNumber @@ -1172,15 +1105,15 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) { Title: track.Title, Artist: track.Performer.Name, Album: albumName, - AlbumArtist: req.AlbumArtist, // Qobuz track struct might not have this handy, keep req or check album struct + AlbumArtist: req.AlbumArtist, Date: track.Album.ReleaseDate, TrackNumber: actualTrackNumber, TotalTracks: req.TotalTracks, - DiscNumber: req.DiscNumber, // QobuzTrack struct usually doesn't have disc info in simple search result + DiscNumber: req.DiscNumber, ISRC: track.ISRC, - Genre: req.Genre, // From Deezer album metadata - Label: req.Label, // From Deezer album metadata - Copyright: req.Copyright, // From Deezer album metadata + Genre: req.Genre, + Label: req.Label, + Copyright: req.Copyright, } var coverData []byte @@ -1220,7 +1153,6 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) { fmt.Println("[Qobuz] No lyrics available from parallel fetch") } - // Add to ISRC index for fast duplicate checking AddToISRCIndex(req.OutputDir, req.ISRC, outputPath) return QobuzDownloadResult{ @@ -1232,7 +1164,7 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) { Album: track.Album.Title, ReleaseDate: track.Album.ReleaseDate, TrackNumber: actualTrackNumber, - DiscNumber: req.DiscNumber, // Qobuz track struct limitations + DiscNumber: req.DiscNumber, ISRC: track.ISRC, }, nil } diff --git a/go_backend/tidal.go b/go_backend/tidal.go index 4af36d3f..92ce4e42 100644 --- a/go_backend/tidal.go +++ b/go_backend/tidal.go @@ -1112,7 +1112,6 @@ func artistsMatch(spotifyArtist, tidalArtist string) bool { return true } - // Check if one contains the other (for cases like "Artist" vs "Artist feat. Someone") if strings.Contains(normSpotify, normTidal) || strings.Contains(normTidal, normSpotify) { return true } @@ -1171,7 +1170,6 @@ func sameWordsUnordered(a, b string) bool { wordsA := strings.Fields(a) wordsB := strings.Fields(b) - // Must have same number of words if len(wordsA) != len(wordsB) || len(wordsA) == 0 { return false } @@ -1204,7 +1202,6 @@ func titlesMatch(expectedTitle, foundTitle string) bool { normExpected := strings.ToLower(strings.TrimSpace(expectedTitle)) normFound := strings.ToLower(strings.TrimSpace(foundTitle)) - // Exact match if normExpected == normFound { return true } @@ -1213,7 +1210,6 @@ func titlesMatch(expectedTitle, foundTitle string) bool { return true } - // Clean both titles and compare cleanExpected := cleanTitle(normExpected) cleanFound := cleanTitle(normFound) @@ -1227,7 +1223,6 @@ func titlesMatch(expectedTitle, foundTitle string) bool { } } - // Extract core title (before any parentheses/brackets) coreExpected := extractCoreTitle(normExpected) coreFound := extractCoreTitle(normFound) @@ -1235,7 +1230,6 @@ func titlesMatch(expectedTitle, foundTitle string) bool { return true } - // Don't treat Latin Extended (Polish, French, etc.) as different script expectedLatin := isLatinScript(expectedTitle) foundLatin := isLatinScript(foundTitle) if expectedLatin != foundLatin { @@ -1522,13 +1516,12 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) { "disc": req.DiscNumber, }) - // For HIGH quality (AAC 320kbps), use .m4a extension directly var outputPath string var m4aPath string if quality == "HIGH" { filename = sanitizeFilename(filename) + ".m4a" outputPath = filepath.Join(req.OutputDir, filename) - m4aPath = outputPath // Same path for HIGH quality + m4aPath = outputPath } else { filename = sanitizeFilename(filename) + ".flac" outputPath = filepath.Join(req.OutputDir, filename) @@ -1538,7 +1531,6 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) { if fileInfo, statErr := os.Stat(outputPath); statErr == nil && fileInfo.Size() > 0 { return TidalDownloadResult{FilePath: "EXISTS:" + outputPath}, nil } - // For non-HIGH quality, also check for existing M4A (DASH downloads) if quality != "HIGH" { if fileInfo, statErr := os.Stat(m4aPath); statErr == nil && fileInfo.Size() > 0 { return TidalDownloadResult{FilePath: "EXISTS:" + m4aPath}, nil @@ -1613,7 +1605,6 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) { GoLog("[Tidal] Using release date from Tidal API: %s\n", releaseDate) } - // Use track number from request if available, otherwise from Tidal API actualTrackNumber := req.TrackNumber actualDiscNumber := req.DiscNumber if actualTrackNumber == 0 { @@ -1676,19 +1667,15 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) { fmt.Println("[Tidal] No lyrics available from parallel fetch") } } else if strings.HasSuffix(actualOutputPath, ".m4a") { - // For HIGH quality (AAC 320kbps), skip metadata embedding as it can corrupt the file - // The M4A from Tidal server already has basic metadata if quality == "HIGH" { GoLog("[Tidal] HIGH quality M4A - skipping metadata embedding (file from server is already valid)\n") - // Handle lyrics based on lyricsMode setting 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 for M4A (mode: %s)...\n", lyricsMode) if lrcPath, lrcErr := SaveLRCFile(actualOutputPath, parallelResult.LyricsLRC); lrcErr != nil { @@ -1697,8 +1684,6 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) { GoLog("[Tidal] LRC file saved: %s\n", lrcPath) } } - // Note: For "embed" or "both" modes, LyricsLRC will be returned to Flutter - // for embedding into the converted MP3/Opus file } } else { fmt.Println("[Tidal] Skipping metadata embedding for M4A file (will be handled after FFmpeg conversion)") @@ -1707,15 +1692,12 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) { AddToISRCIndex(req.OutputDir, req.ISRC, actualOutputPath) - // For HIGH quality (AAC), set appropriate values bitDepth := downloadInfo.BitDepth sampleRate := downloadInfo.SampleRate lyricsLRC := "" if quality == "HIGH" { - // AAC 320kbps doesn't have traditional bit depth bitDepth = 0 sampleRate = 44100 - // Return lyrics for Flutter to embed in converted MP3/Opus if parallelResult != nil && parallelResult.LyricsLRC != "" { lyricsMode := req.LyricsMode if lyricsMode == "" { diff --git a/lib/main.dart b/lib/main.dart index a1fe6993..834632d4 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -43,7 +43,6 @@ class _EagerInitializationState extends ConsumerState<_EagerInitialization> { void initState() { super.initState(); _initializeExtensions(); - // Trigger history provider initialization without subscribing to updates. ref.read(downloadHistoryProvider); } diff --git a/lib/providers/download_queue_provider.dart b/lib/providers/download_queue_provider.dart index 5f2f0703..678e7837 100644 --- a/lib/providers/download_queue_provider.dart +++ b/lib/providers/download_queue_provider.dart @@ -1878,7 +1878,7 @@ class DownloadQueueNotifier extends Notifier { 'Quality: $quality${item.qualityOverride != null ? ' (override)' : ''}', ); _log.d('Output dir: $outputDir'); - result = await PlatformBridge.downloadWithExtensions( +result = await PlatformBridge.downloadWithExtensions( isrc: trackToDownload.isrc ?? '', spotifyId: trackToDownload.id, trackName: trackToDownload.name, @@ -1898,6 +1898,7 @@ class DownloadQueueNotifier extends Notifier { genre: genre, label: label, lyricsMode: settings.lyricsMode, + preferredService: item.service, ); } else if (state.autoFallback) { _log.d('Using auto-fallback mode'); diff --git a/lib/services/platform_bridge.dart b/lib/services/platform_bridge.dart index e404b487..08a233b4 100644 --- a/lib/services/platform_bridge.dart +++ b/lib/services/platform_bridge.dart @@ -323,7 +323,6 @@ class PlatformBridge { }); } - /// Returns true if credentials are available (custom or env vars) static Future hasSpotifyCredentials() async { final result = await _channel.invokeMethod('hasSpotifyCredentials'); return result as bool; @@ -410,7 +409,6 @@ class PlatformBridge { return logs.map((e) => e as Map).toList(); } - /// Get logs since a specific index (for incremental updates) static Future> getGoLogsSince(int index) async { final result = await _channel.invokeMethod('getLogsSince', {'index': index}); return jsonDecode(result as String) as Map; @@ -561,7 +559,7 @@ class PlatformBridge { return list.map((e) => e as Map).toList(); } - static Future> downloadWithExtensions({ +static Future> downloadWithExtensions({ required String isrc, required String spotifyId, required String trackName, @@ -584,8 +582,9 @@ class PlatformBridge { String? genre, String? label, String lyricsMode = 'embed', + String? preferredService, }) async { - _log.i('downloadWithExtensions: "$trackName" by $artistName${source != null ? ' (source: $source)' : ''}'); + _log.i('downloadWithExtensions: "$trackName" by $artistName${source != null ? ' (source: $source)' : ''}${preferredService != null ? ' (service: $preferredService)' : ''}'); final request = jsonEncode({ 'isrc': isrc, 'spotify_id': spotifyId, @@ -609,6 +608,7 @@ class PlatformBridge { 'genre': genre ?? '', 'label': label ?? '', 'lyrics_mode': lyricsMode, + 'service': preferredService ?? '', }); final result = await _channel.invokeMethod('downloadWithExtensions', request); @@ -795,7 +795,6 @@ class PlatformBridge { } } - /// Get extension home feed static Future?> getExtensionHomeFeed(String extensionId) async { try { final result = await _channel.invokeMethod('getExtensionHomeFeed', { @@ -809,7 +808,6 @@ class PlatformBridge { } } - /// Get extension browse categories static Future?> getExtensionBrowseCategories(String extensionId) async { try { final result = await _channel.invokeMethod('getExtensionBrowseCategories', { diff --git a/lib/widgets/download_service_picker.dart b/lib/widgets/download_service_picker.dart index 2a59dd95..591f07ac 100644 --- a/lib/widgets/download_service_picker.dart +++ b/lib/widgets/download_service_picker.dart @@ -10,11 +10,15 @@ class BuiltInService { final String id; final String label; final List qualityOptions; + final bool isDisabled; // If true, service is grayed out (fallback only) + final String? disabledReason; const BuiltInService({ required this.id, required this.label, required this.qualityOptions, + this.isDisabled = false, + this.disabledReason, }); } @@ -47,6 +51,8 @@ const _builtInServices = [ QualityOption(id: 'HI_RES', label: 'Hi-Res FLAC', description: '24-bit / up to 96kHz'), QualityOption(id: 'HI_RES_LOSSLESS', label: 'Hi-Res FLAC Max', description: '24-bit / up to 192kHz'), ], + isDisabled: true, + disabledReason: 'Fallback only', ), ]; @@ -169,7 +175,7 @@ class _DownloadServicePickerState extends ConsumerState { ), ), - Padding( +Padding( padding: const EdgeInsets.symmetric(horizontal: 20), child: Wrap( spacing: 8, @@ -177,9 +183,14 @@ class _DownloadServicePickerState extends ConsumerState { children: [ for (final service in _builtInServices) _ServiceChip( - label: service.label, + label: service.isDisabled + ? '${service.label} (${service.disabledReason})' + : service.label, isSelected: _selectedService == service.id, - onTap: () => setState(() => _selectedService = service.id), + isDisabled: service.isDisabled, + onTap: service.isDisabled + ? null + : () => setState(() => _selectedService = service.id), ), for (final ext in downloadExtensions) _ServiceChip( @@ -392,26 +403,32 @@ class _QualityOption extends StatelessWidget { class _ServiceChip extends StatelessWidget { final String label; final bool isSelected; - final VoidCallback onTap; + final VoidCallback? onTap; final String? iconPath; + final bool isDisabled; const _ServiceChip({ required this.label, required this.isSelected, required this.onTap, this.iconPath, + this.isDisabled = false, }); @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; return GestureDetector( - onTap: onTap, + onTap: isDisabled ? null : onTap, child: AnimatedContainer( duration: const Duration(milliseconds: 200), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), decoration: BoxDecoration( - color: isSelected ? colorScheme.primaryContainer : colorScheme.surfaceContainerHighest, + color: isDisabled + ? colorScheme.surfaceContainerHighest.withValues(alpha: 0.5) + : isSelected + ? colorScheme.primaryContainer + : colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(12), border: isSelected ? null : Border.all(color: colorScheme.outlineVariant.withValues(alpha: 0.5)), ), @@ -429,7 +446,11 @@ class _ServiceChip extends StatelessWidget { errorBuilder: (context, error, stackTrace) => Icon( Icons.extension, size: 18, - color: isSelected ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant, + color: isDisabled + ? colorScheme.onSurfaceVariant.withValues(alpha: 0.4) + : isSelected + ? colorScheme.onPrimaryContainer + : colorScheme.onSurfaceVariant, ), ), ), @@ -439,7 +460,11 @@ class _ServiceChip extends StatelessWidget { label, style: TextStyle( fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal, - color: isSelected ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant, + color: isDisabled + ? colorScheme.onSurfaceVariant.withValues(alpha: 0.4) + : isSelected + ? colorScheme.onPrimaryContainer + : colorScheme.onSurfaceVariant, ), ), ],