From 803cd2de96a3a58ec63cacc326e8ffa066836051 Mon Sep 17 00:00:00 2001 From: zarzet Date: Sat, 18 Apr 2026 23:32:16 +0700 Subject: [PATCH] refactor: remove Qobuz built-in provider and delete qobuz.go Delete the entire Qobuz downloader implementation (qobuz.go, qobuz_test.go) including all API clients, search, metadata, download, and track matching code. Empty the builtInProviderRegistry now that all built-in providers are retired. Remove Qobuz-specific exports (SearchQobuzAll, GetQobuzMetadata, ParseQobuzURLExport) and the downloadWithBuiltInQobuz adapter. Stub out PreWarmTrackCache and cache management since no built-in providers remain. Move qobuz cover upgrade regex to cover.go. Update Dart screens, providers, and localization strings for the provider-agnostic UI. --- go_backend/cover.go | 4 +- go_backend/exports.go | 64 - go_backend/extension_providers.go | 21 - go_backend/parallel.go | 152 +- go_backend/qobuz.go | 2896 ----------------- go_backend/qobuz_test.go | 553 ---- go_backend/title_match_utils_test.go | 15 - lib/l10n/app_localizations.dart | 16 +- lib/l10n/app_localizations_de.dart | 4 +- lib/l10n/app_localizations_en.dart | 8 +- lib/l10n/app_localizations_es.dart | 4 +- lib/l10n/app_localizations_fr.dart | 4 +- lib/l10n/app_localizations_hi.dart | 4 +- lib/l10n/app_localizations_id.dart | 9 +- lib/l10n/app_localizations_ja.dart | 9 +- lib/l10n/app_localizations_ko.dart | 4 +- lib/l10n/app_localizations_nl.dart | 4 +- lib/l10n/app_localizations_pt.dart | 4 +- lib/l10n/app_localizations_ru.dart | 4 +- lib/l10n/app_localizations_tr.dart | 9 +- lib/l10n/app_localizations_zh.dart | 4 +- lib/l10n/arb/app_en.arb | 16 +- lib/l10n/arb/app_id.arb | 8 +- lib/l10n/arb/app_ja.arb | 10 +- lib/l10n/arb/app_tr.arb | 16 +- lib/providers/download_queue_provider.dart | 2 +- lib/providers/extension_provider.dart | 79 + lib/providers/track_provider.dart | 1 - lib/screens/album_screen.dart | 27 +- lib/screens/artist_screen.dart | 14 +- lib/screens/home_tab.dart | 43 +- lib/screens/library_tracks_folder_screen.dart | 27 +- lib/screens/local_album_screen.dart | 8 + lib/screens/playlist_screen.dart | 14 +- lib/screens/queue_tab.dart | 24 +- lib/screens/search_screen.dart | 14 +- .../local_track_redownload_service.dart | 30 +- 37 files changed, 331 insertions(+), 3794 deletions(-) delete mode 100644 go_backend/qobuz.go delete mode 100644 go_backend/qobuz_test.go diff --git a/go_backend/cover.go b/go_backend/cover.go index a368fb8f..87fc82a1 100644 --- a/go_backend/cover.go +++ b/go_backend/cover.go @@ -19,6 +19,8 @@ var deezerSizeRegex = regexp.MustCompile(`/(\d+)x(\d+)-\d+-\d+-\d+-\d+\.jpg$`) var tidalSizeRegex = regexp.MustCompile(`/\d+x\d+\.jpg$`) +var qobuzSizeRegex = regexp.MustCompile(`_\d+\.jpg$`) + func convertSmallToMedium(imageURL string) string { if strings.Contains(imageURL, spotifySize300) { return strings.Replace(imageURL, spotifySize300, spotifySize640, 1) @@ -135,7 +137,7 @@ func upgradeQobuzCover(coverURL string) string { return coverURL } - upgraded := qobuzImageSizeRe.ReplaceAllString(coverURL, "_max.jpg") + upgraded := qobuzSizeRegex.ReplaceAllString(coverURL, "_max.jpg") if upgraded != coverURL { GoLog("[Cover] Qobuz: upgraded to max resolution") } diff --git a/go_backend/exports.go b/go_backend/exports.go index 1fcc22ee..edaa30dc 100644 --- a/go_backend/exports.go +++ b/go_backend/exports.go @@ -1955,21 +1955,6 @@ func ClearTrackIDCache() { ClearTrackCache() } -func SearchQobuzAll(query string, trackLimit, artistLimit int, filter string) (string, error) { - downloader := NewQobuzDownloader() - results, err := downloader.SearchAll(query, trackLimit, artistLimit, filter) - if err != nil { - return "", err - } - - jsonBytes, err := json.Marshal(results) - if err != nil { - return "", err - } - - return string(jsonBytes), nil -} - func SearchProviderAllJSON( providerID, query string, @@ -2045,36 +2030,6 @@ func GetDeezerMetadata(resourceType, resourceID string) (string, error) { return string(jsonBytes), nil } -func GetQobuzMetadata(resourceType, resourceID string) (string, error) { - downloader := NewQobuzDownloader() - - var data interface{} - var err error - - switch resourceType { - case "track": - data, err = downloader.GetTrackMetadata(resourceID) - case "album": - data, err = downloader.GetAlbumMetadata(resourceID) - case "artist": - data, err = downloader.GetArtistMetadata(resourceID) - case "playlist": - data, err = downloader.GetPlaylistMetadata(resourceID) - default: - return "", fmt.Errorf("unsupported Qobuz resource type: %s", resourceType) - } - if err != nil { - return "", err - } - - jsonBytes, err := json.Marshal(data) - if err != nil { - return "", err - } - - return string(jsonBytes), nil -} - func normalizeExtensionTrackMetadataMap( track ExtTrackMetadata, fallbackCover string, @@ -2333,25 +2288,6 @@ func ParseDeezerURLExport(url string) (string, error) { return string(jsonBytes), nil } -func ParseQobuzURLExport(url string) (string, error) { - resourceType, resourceID, err := parseQobuzURL(url) - if err != nil { - return "", err - } - - result := map[string]string{ - "type": resourceType, - "id": resourceID, - } - - jsonBytes, err := json.Marshal(result) - if err != nil { - return "", err - } - - return string(jsonBytes), nil -} - func ParseProviderURLJSON(url string) (string, error) { parsers := []struct { providerID string diff --git a/go_backend/extension_providers.go b/go_backend/extension_providers.go index bba2d890..638f5065 100644 --- a/go_backend/extension_providers.go +++ b/go_backend/extension_providers.go @@ -158,27 +158,6 @@ func downloadWithBuiltInProvider(providerID string, req DownloadRequest) (Downlo return spec.Download(req) } -func downloadWithBuiltInQobuz(req DownloadRequest) (DownloadResult, error) { - result, err := downloadFromQobuz(req) - if err != nil { - return DownloadResult{}, err - } - return DownloadResult{ - FilePath: result.FilePath, - BitDepth: result.BitDepth, - SampleRate: result.SampleRate, - Title: result.Title, - Artist: result.Artist, - Album: result.Album, - ReleaseDate: result.ReleaseDate, - TrackNumber: result.TrackNumber, - DiscNumber: result.DiscNumber, - ISRC: result.ISRC, - CoverURL: result.CoverURL, - LyricsLRC: result.LyricsLRC, - }, nil -} - func normalizeExtensionDownloadResult(result *ExtDownloadResult) (DownloadResult, bool) { if result == nil { return DownloadResult{}, false diff --git a/go_backend/parallel.go b/go_backend/parallel.go index 65f2816a..611b971d 100644 --- a/go_backend/parallel.go +++ b/go_backend/parallel.go @@ -4,100 +4,8 @@ import ( "encoding/json" "fmt" "sync" - "time" ) -type TrackIDCacheEntry struct { - QobuzTrackID int64 - ExpiresAt time.Time -} - -type TrackIDCache struct { - cache map[string]*TrackIDCacheEntry - mu sync.RWMutex - ttl time.Duration - lastCleanup time.Time - cleanupInterval time.Duration -} - -var ( - globalTrackIDCache *TrackIDCache - trackIDCacheOnce sync.Once -) - -func GetTrackIDCache() *TrackIDCache { - trackIDCacheOnce.Do(func() { - globalTrackIDCache = &TrackIDCache{ - cache: make(map[string]*TrackIDCacheEntry), - ttl: 30 * time.Minute, - cleanupInterval: 5 * time.Minute, - } - }) - return globalTrackIDCache -} - -func (c *TrackIDCache) Get(isrc string) *TrackIDCacheEntry { - c.mu.RLock() - entry, exists := c.cache[isrc] - if !exists { - c.mu.RUnlock() - return nil - } - expired := time.Now().After(entry.ExpiresAt) - c.mu.RUnlock() - - if !expired { - return entry - } - - c.mu.Lock() - entry, exists = c.cache[isrc] - if exists && time.Now().After(entry.ExpiresAt) { - delete(c.cache, isrc) - } - c.mu.Unlock() - return nil -} - -func (c *TrackIDCache) pruneExpiredLocked(now time.Time) { - for key, entry := range c.cache { - if now.After(entry.ExpiresAt) { - delete(c.cache, key) - } - } -} - -func (c *TrackIDCache) SetQobuz(isrc string, trackID int64) { - c.mu.Lock() - defer c.mu.Unlock() - - entry, exists := c.cache[isrc] - if !exists { - entry = &TrackIDCacheEntry{} - c.cache[isrc] = entry - } - entry.QobuzTrackID = trackID - now := time.Now() - entry.ExpiresAt = now.Add(c.ttl) - - if c.cleanupInterval > 0 && (c.lastCleanup.IsZero() || now.Sub(c.lastCleanup) >= c.cleanupInterval) { - c.pruneExpiredLocked(now) - c.lastCleanup = now - } -} - -func (c *TrackIDCache) Clear() { - c.mu.Lock() - defer c.mu.Unlock() - c.cache = make(map[string]*TrackIDCacheEntry) -} - -func (c *TrackIDCache) Size() int { - c.mu.RLock() - defer c.mu.RUnlock() - return len(c.cache) -} - type ParallelDownloadResult struct { CoverData []byte LyricsData *LyricsResponse @@ -167,62 +75,7 @@ type PreWarmCacheRequest struct { } func PreWarmTrackCache(requests []PreWarmCacheRequest) { - if len(requests) == 0 { - return - } - - cache := GetTrackIDCache() - - semaphore := make(chan struct{}, 3) - var wg sync.WaitGroup - - for _, req := range requests { - if req.ISRC == "" { - continue - } - if cached := cache.Get(req.ISRC); cached != nil { - continue - } - - wg.Add(1) - go func(r PreWarmCacheRequest) { - defer wg.Done() - semaphore <- struct{}{} - defer func() { <-semaphore }() - - switch r.Service { - case "qobuz": - preWarmQobuzCache(r.ISRC, r.SpotifyID) - } - }(req) - } - - wg.Wait() -} - -// preWarmQobuzCache tries to get Qobuz Track ID in the following order: -// 1. From SongLink (fast, no Qobuz API call needed) -// 2. Direct ISRC search on Qobuz API (slower, may fail if ISRC not in Qobuz database) -func preWarmQobuzCache(isrc, spotifyID string) { - if spotifyID != "" { - client := NewSongLinkClient() - availability, err := client.CheckTrackAvailability(spotifyID, isrc) - if err == nil && availability != nil && availability.QobuzID != "" { - var trackID int64 - if _, parseErr := fmt.Sscanf(availability.QobuzID, "%d", &trackID); parseErr == nil && trackID > 0 { - GoLog("[Qobuz] Pre-warm cache: Got Qobuz ID %d from SongLink for ISRC %s\n", trackID, isrc) - GetTrackIDCache().SetQobuz(isrc, trackID) - return - } - } - } - - downloader := NewQobuzDownloader() - track, err := downloader.SearchTrackByISRC(isrc) - if err == nil && track != nil { - GoLog("[Qobuz] Pre-warm cache: Got Qobuz ID %d from direct ISRC search for %s\n", track.ID, isrc) - GetTrackIDCache().SetQobuz(isrc, track.ID) - } + _ = requests } func PreWarmCache(tracksJSON string) error { @@ -254,9 +107,8 @@ func PreWarmCache(tracksJSON string) error { } func ClearTrackCache() { - GetTrackIDCache().Clear() } func GetCacheSize() int { - return GetTrackIDCache().Size() + return 0 } diff --git a/go_backend/qobuz.go b/go_backend/qobuz.go deleted file mode 100644 index 4d50e109..00000000 --- a/go_backend/qobuz.go +++ /dev/null @@ -1,2896 +0,0 @@ -package gobackend - -import ( - "bufio" - "bytes" - "context" - "encoding/json" - "errors" - "fmt" - "io" - "net/http" - "net/url" - "os" - "path/filepath" - "regexp" - "sort" - "strconv" - "strings" - "sync" - "time" -) - -type QobuzDownloader struct { - client *http.Client - appID string - apiURL string -} - -var ( - globalQobuzDownloader *QobuzDownloader - qobuzDownloaderOnce sync.Once - qobuzGetTrackByIDFunc = func(q *QobuzDownloader, trackID int64) (*QobuzTrack, error) { - return q.GetTrackByID(trackID) - } - qobuzSearchTrackByISRCWithDurationFunc = func(q *QobuzDownloader, isrc string, expectedDurationSec int) (*QobuzTrack, error) { - return q.SearchTrackByISRCWithDuration(isrc, expectedDurationSec) - } - qobuzSearchTrackByMetadataWithDurationFunc = func(q *QobuzDownloader, trackName, artistName string, expectedDurationSec int) (*QobuzTrack, error) { - return q.SearchTrackByMetadataWithDuration(trackName, artistName, expectedDurationSec) - } - songLinkCheckTrackAvailabilityFunc = func(client *SongLinkClient, spotifyTrackID string, isrc string) (*TrackAvailability, error) { - return client.CheckTrackAvailability(spotifyTrackID, isrc) - } -) - -const ( - qobuzAPIBaseURL = "https://api.zarz.moe/v1/qbz/" - qobuzTrackGetBaseURL = qobuzAPIBaseURL + "track/get?track_id=" - qobuzTrackSearchBaseURL = qobuzAPIBaseURL + "track/search?query=" - qobuzAlbumGetBaseURL = qobuzAPIBaseURL + "album/get?album_id=" - qobuzArtistGetBaseURL = qobuzAPIBaseURL + "artist/get?artist_id=" - qobuzPlaylistGetBaseURL = qobuzAPIBaseURL + "playlist/get?playlist_id=" - qobuzStoreSearchBaseURL = "https://www.qobuz.com/us-en/search/tracks/" - qobuzTrackOpenBaseURL = "https://open.qobuz.com/track/" - qobuzTrackPlayBaseURL = "https://play.qobuz.com/track/" - qobuzStoreBaseURL = "https://www.qobuz.com/us-en" - qobuzDownloadAPIURL = "https://dl.musicdl.me/qobuz/download" - qobuzZarzDownloadAPIURL = "https://api.zarz.moe/dl/qbz" - qobuzDabMusicAPIURL = "https://dabmusic.xyz/api/stream?trackId=" - qobuzDeebAPIURL = "https://dab.yeet.su/api/stream?trackId=" - qobuzAfkarAPIURL = "https://qbz.afkarxyz.qzz.io/api/track/" - qobuzSquidAPIURL = "https://qobuz.squid.wtf/api/download-music?country=US&track_id=" - - qobuzFallbackAPIBaseURL = "https://api.zarz.moe/v1/qbz2/" - qobuzFallbackTrackGetBaseURL = qobuzFallbackAPIBaseURL + "track/get?track_id=" - qobuzFallbackTrackSearchBaseURL = qobuzFallbackAPIBaseURL + "track/search?query=" - qobuzFallbackAlbumGetBaseURL = qobuzFallbackAPIBaseURL + "album/get?album_id=" - qobuzFallbackArtistGetBaseURL = qobuzFallbackAPIBaseURL + "artist/get?artist_id=" - qobuzFallbackPlaylistGetBaseURL = qobuzFallbackAPIBaseURL + "playlist/get?playlist_id=" -) - -var qobuzStoreTrackIDRegex = regexp.MustCompile(`/v4/ajax/popin-add-cart/track/([0-9]+)`) -var qobuzArtistAlbumIDRegex = regexp.MustCompile(`data-itemtype="album"\s+data-itemId="([A-Za-z0-9]+)"`) -var qobuzLocaleSegmentRegex = regexp.MustCompile(`^[a-z]{2}-[a-z]{2}$`) - -type QobuzTrack struct { - ID int64 `json:"id"` - Title string `json:"title"` - ISRC string `json:"isrc"` - Duration int `json:"duration"` - TrackNumber int `json:"track_number"` - MediaNumber int `json:"media_number"` - MaximumBitDepth int `json:"maximum_bit_depth"` - MaximumSamplingRate float64 `json:"maximum_sampling_rate"` - Version string `json:"version"` - Album struct { - ID string `json:"id"` - QobuzID int64 `json:"qobuz_id"` - TracksCount int `json:"tracks_count"` - Title string `json:"title"` - ReleaseDate string `json:"release_date_original"` - ProductType string `json:"product_type"` - ReleaseType string `json:"release_type"` - Artist struct { - ID int64 `json:"id"` - Name string `json:"name"` - } `json:"artist"` - Artists []qobuzArtistRef `json:"artists"` - Image struct { - Thumbnail string `json:"thumbnail"` - Small string `json:"small"` - Large string `json:"large"` - } `json:"image"` - } `json:"album"` - Performer struct { - ID int64 `json:"id"` - Name string `json:"name"` - } `json:"performer"` - Composer struct { - ID int64 `json:"id"` - Name string `json:"name"` - } `json:"composer"` -} - -type qobuzImageSet struct { - Thumbnail string `json:"thumbnail"` - Small string `json:"small"` - Large string `json:"large"` -} - -type qobuzArtistRef struct { - ID int64 `json:"id"` - Name string `json:"name"` - Slug string `json:"slug"` -} - -type qobuzLabelRef struct { - Name string `json:"name"` -} - -type qobuzGenreRef struct { - Name string `json:"name"` -} - -type qobuzAlbumDetails struct { - ID string `json:"id"` - QobuzID int64 `json:"qobuz_id"` - Title string `json:"title"` - ReleaseDateOriginal string `json:"release_date_original"` - TracksCount int `json:"tracks_count"` - ProductType string `json:"product_type"` - ReleaseType string `json:"release_type"` - Image qobuzImageSet `json:"image"` - Artist qobuzArtistRef `json:"artist"` - Artists []qobuzArtistRef `json:"artists"` - Genre qobuzGenreRef `json:"genre"` - Label qobuzLabelRef `json:"label"` - Copyright string `json:"copyright"` - Tracks struct { - Items []QobuzTrack `json:"items"` - } `json:"tracks"` -} - -type qobuzArtistDetails struct { - ID int64 `json:"id"` - Name string `json:"name"` - Slug string `json:"slug"` - Image qobuzImageSet `json:"image"` -} - -type qobuzPlaylistDetails struct { - ID int64 `json:"id"` - Name string `json:"name"` - Description string `json:"description"` - ImageRectangle []string `json:"image_rectangle"` - ImageRectangleMini []string `json:"image_rectangle_mini"` - TracksCount int `json:"tracks_count"` - Owner struct { - ID int64 `json:"id"` - Name string `json:"name"` - } `json:"owner"` - Tracks struct { - Total int `json:"total"` - Offset int `json:"offset"` - Limit int `json:"limit"` - Items []QobuzTrack `json:"items"` - } `json:"tracks"` -} - -func qobuzFirstNonEmpty(values ...string) string { - for _, value := range values { - trimmed := strings.TrimSpace(value) - if trimmed != "" { - return trimmed - } - } - return "" -} - -func qobuzPrefixedID(id string) string { - trimmed := strings.TrimSpace(id) - if trimmed == "" { - return "" - } - if strings.HasPrefix(trimmed, "qobuz:") { - return trimmed - } - return "qobuz:" + trimmed -} - -func qobuzPrefixedNumericID(id int64) string { - if id <= 0 { - return "" - } - return fmt.Sprintf("qobuz:%d", id) -} - -func qobuzNormalizeReleaseDate(value string) string { - trimmed := strings.TrimSpace(value) - if trimmed == "" { - return "" - } - if _, err := time.Parse("2006-01-02", trimmed); err == nil { - return trimmed - } - if parsed, err := time.Parse("Jan 2, 2006", trimmed); err == nil { - return parsed.Format("2006-01-02") - } - return trimmed -} - -func qobuzNormalizeAlbumType(releaseType, productType string, totalTracks int) string { - kind := strings.ToLower(strings.TrimSpace(releaseType)) - if kind == "" { - kind = strings.ToLower(strings.TrimSpace(productType)) - } - switch kind { - case "album", "single", "ep", "compilation": - return kind - } - if totalTracks > 0 && totalTracks <= 3 { - return "single" - } - return "album" -} - -func qobuzArtistsDisplayName(artists []qobuzArtistRef, fallback string) string { - names := make([]string, 0, len(artists)) - seen := make(map[string]struct{}, len(artists)) - for _, artist := range artists { - name := strings.TrimSpace(artist.Name) - if name == "" { - continue - } - key := strings.ToLower(name) - if _, ok := seen[key]; ok { - continue - } - seen[key] = struct{}{} - names = append(names, name) - } - if len(names) == 0 { - return strings.TrimSpace(fallback) - } - return strings.Join(names, ", ") -} - -func qobuzTrackDisplayTitle(track *QobuzTrack) string { - if track == nil { - return "" - } - title := strings.TrimSpace(track.Title) - version := strings.TrimSpace(track.Version) - if title == "" || version == "" { - return title - } - return fmt.Sprintf("%s (%s)", title, version) -} - -var qobuzImageSizeRe = regexp.MustCompile(`_\d+\.jpg$`) - -func qobuzUpscaleImageURL(url string) string { - if url == "" { - return "" - } - return qobuzImageSizeRe.ReplaceAllString(url, "_max.jpg") -} - -func qobuzTrackAlbumImage(track *QobuzTrack) string { - if track == nil { - return "" - } - return qobuzUpscaleImageURL(qobuzFirstNonEmpty( - track.Album.Image.Large, - track.Album.Image.Small, - track.Album.Image.Thumbnail, - )) -} - -func qobuzAlbumImage(album *qobuzAlbumDetails) string { - if album == nil { - return "" - } - return qobuzUpscaleImageURL(qobuzFirstNonEmpty( - album.Image.Large, - album.Image.Small, - album.Image.Thumbnail, - )) -} - -func qobuzTrackArtistID(track *QobuzTrack) string { - if track == nil { - return "" - } - if track.Performer.ID > 0 { - return qobuzPrefixedNumericID(track.Performer.ID) - } - return qobuzPrefixedNumericID(track.Album.Artist.ID) -} - -func qobuzTrackArtistName(track *QobuzTrack) string { - if track == nil { - return "" - } - return strings.TrimSpace(track.Performer.Name) -} - -func qobuzTrackAlbumArtist(track *QobuzTrack) string { - if track == nil { - return "" - } - return qobuzArtistsDisplayName(track.Album.Artists, track.Album.Artist.Name) -} - -func qobuzTrackAlbumType(track *QobuzTrack) string { - if track == nil { - return "album" - } - return qobuzNormalizeAlbumType( - track.Album.ReleaseType, - track.Album.ProductType, - track.Album.TracksCount, - ) -} - -func qobuzTrackToTrackMetadata(track *QobuzTrack) TrackMetadata { - if track == nil { - return TrackMetadata{} - } - return TrackMetadata{ - SpotifyID: qobuzPrefixedNumericID(track.ID), - Artists: qobuzTrackArtistName(track), - Name: qobuzTrackDisplayTitle(track), - AlbumName: strings.TrimSpace(track.Album.Title), - AlbumArtist: qobuzTrackAlbumArtist(track), - DurationMS: track.Duration * 1000, - Images: qobuzTrackAlbumImage(track), - ReleaseDate: qobuzNormalizeReleaseDate(track.Album.ReleaseDate), - TrackNumber: track.TrackNumber, - TotalTracks: track.Album.TracksCount, - DiscNumber: track.MediaNumber, - ExternalURL: fmt.Sprintf("%s%d", qobuzTrackPlayBaseURL, track.ID), - ISRC: strings.TrimSpace(track.ISRC), - AlbumID: qobuzPrefixedID(track.Album.ID), - ArtistID: qobuzTrackArtistID(track), - AlbumType: qobuzTrackAlbumType(track), - Composer: strings.TrimSpace(track.Composer.Name), - } -} - -func qobuzTrackToAlbumTrackMetadata(track *QobuzTrack) AlbumTrackMetadata { - if track == nil { - return AlbumTrackMetadata{} - } - return AlbumTrackMetadata{ - SpotifyID: qobuzPrefixedNumericID(track.ID), - Artists: qobuzTrackArtistName(track), - Name: qobuzTrackDisplayTitle(track), - AlbumName: strings.TrimSpace(track.Album.Title), - AlbumArtist: qobuzTrackAlbumArtist(track), - DurationMS: track.Duration * 1000, - Images: qobuzTrackAlbumImage(track), - ReleaseDate: qobuzNormalizeReleaseDate(track.Album.ReleaseDate), - TrackNumber: track.TrackNumber, - TotalTracks: track.Album.TracksCount, - DiscNumber: track.MediaNumber, - ExternalURL: fmt.Sprintf("%s%d", qobuzTrackPlayBaseURL, track.ID), - ISRC: strings.TrimSpace(track.ISRC), - AlbumID: qobuzPrefixedID(track.Album.ID), - AlbumURL: fmt.Sprintf("https://play.qobuz.com/album/%s", strings.TrimSpace(track.Album.ID)), - AlbumType: qobuzTrackAlbumType(track), - Composer: strings.TrimSpace(track.Composer.Name), - } -} - -func qobuzAlbumToAlbumInfo(album *qobuzAlbumDetails) AlbumInfoMetadata { - if album == nil { - return AlbumInfoMetadata{} - } - return AlbumInfoMetadata{ - TotalTracks: album.TracksCount, - Name: strings.TrimSpace(album.Title), - ReleaseDate: qobuzNormalizeReleaseDate(album.ReleaseDateOriginal), - Artists: qobuzArtistsDisplayName(album.Artists, album.Artist.Name), - ArtistId: qobuzPrefixedNumericID(album.Artist.ID), - Images: qobuzAlbumImage(album), - Genre: strings.TrimSpace(album.Genre.Name), - Label: strings.TrimSpace(album.Label.Name), - Copyright: strings.TrimSpace(album.Copyright), - } -} - -func qobuzAlbumToArtistAlbum(album *qobuzAlbumDetails) ArtistAlbumMetadata { - if album == nil { - return ArtistAlbumMetadata{} - } - return ArtistAlbumMetadata{ - ID: qobuzPrefixedID(album.ID), - Name: strings.TrimSpace(album.Title), - ReleaseDate: qobuzNormalizeReleaseDate(album.ReleaseDateOriginal), - TotalTracks: album.TracksCount, - Images: qobuzAlbumImage(album), - AlbumType: qobuzNormalizeAlbumType(album.ReleaseType, album.ProductType, album.TracksCount), - Artists: qobuzArtistsDisplayName(album.Artists, album.Artist.Name), - } -} - -func qobuzSplitPathSegments(path string) []string { - rawSegments := strings.Split(strings.TrimSpace(path), "/") - segments := make([]string, 0, len(rawSegments)) - for _, segment := range rawSegments { - trimmed := strings.TrimSpace(segment) - if trimmed == "" { - continue - } - segments = append(segments, trimmed) - } - if len(segments) > 0 && qobuzLocaleSegmentRegex.MatchString(strings.ToLower(segments[0])) { - return segments[1:] - } - return segments -} - -func qobuzResourceTypeFromSegment(segment string) string { - switch strings.ToLower(strings.TrimSpace(segment)) { - case "album": - return "album" - case "interpreter", "artist": - return "artist" - case "playlist", "playlists": - return "playlist" - case "track": - return "track" - default: - return "" - } -} - -func parseQobuzURL(input string) (string, string, error) { - raw := strings.TrimSpace(input) - if raw == "" { - return "", "", fmt.Errorf("empty Qobuz URL") - } - - if strings.HasPrefix(strings.ToLower(raw), "qobuzapp://") { - parsed, err := url.Parse(raw) - if err != nil { - return "", "", err - } - resourceType := qobuzResourceTypeFromSegment(parsed.Host) - resourceID := strings.Trim(strings.TrimSpace(parsed.Path), "/") - if resourceType == "" || resourceID == "" { - return "", "", fmt.Errorf("invalid or unsupported Qobuz URL") - } - return resourceType, resourceID, nil - } - - parsed, err := url.Parse(raw) - if err != nil || parsed.Host == "" { - if !strings.Contains(raw, "://") { - parsed, err = url.Parse("https://" + raw) - } - } - if err != nil || parsed == nil || parsed.Host == "" { - return "", "", fmt.Errorf("invalid or unsupported Qobuz URL") - } - - host := strings.ToLower(parsed.Host) - if host != "qobuz.com" && host != "www.qobuz.com" && host != "play.qobuz.com" { - return "", "", fmt.Errorf("invalid or unsupported Qobuz URL") - } - - segments := qobuzSplitPathSegments(parsed.Path) - if len(segments) < 2 { - return "", "", fmt.Errorf("invalid or unsupported Qobuz URL") - } - - resourceType := qobuzResourceTypeFromSegment(segments[0]) - resourceID := strings.TrimSpace(segments[len(segments)-1]) - if resourceType == "" || resourceID == "" { - return "", "", fmt.Errorf("invalid or unsupported Qobuz URL") - } - - return resourceType, resourceID, nil -} - -func qobuzArtistsMatch(expectedArtist, foundArtist string) bool { - normExpected := normalizeLooseArtistName(expectedArtist) - normFound := normalizeLooseArtistName(foundArtist) - - if normExpected == normFound { - return true - } - - if strings.Contains(normExpected, normFound) || strings.Contains(normFound, normExpected) { - return true - } - - expectedArtists := qobuzSplitArtists(normExpected) - foundArtists := qobuzSplitArtists(normFound) - - for _, exp := range expectedArtists { - for _, fnd := range foundArtists { - if exp == fnd { - return true - } - if strings.Contains(exp, fnd) || strings.Contains(fnd, exp) { - return true - } - if qobuzSameWordsUnordered(exp, fnd) { - GoLog("[Qobuz] Artist names have same words in different order: '%s' vs '%s'\n", exp, fnd) - return true - } - } - } - - expectedLatin := qobuzIsLatinScript(expectedArtist) - foundLatin := qobuzIsLatinScript(foundArtist) - if expectedLatin != foundLatin { - GoLog("[Qobuz] Artist names in different scripts, assuming match: '%s' vs '%s'\n", expectedArtist, foundArtist) - return true - } - - return false -} - -func qobuzSplitArtists(artists string) []string { - normalized := artists - normalized = strings.ReplaceAll(normalized, " feat. ", "|") - normalized = strings.ReplaceAll(normalized, " feat ", "|") - normalized = strings.ReplaceAll(normalized, " ft. ", "|") - normalized = strings.ReplaceAll(normalized, " ft ", "|") - normalized = strings.ReplaceAll(normalized, " & ", "|") - normalized = strings.ReplaceAll(normalized, " and ", "|") - normalized = strings.ReplaceAll(normalized, ", ", "|") - normalized = strings.ReplaceAll(normalized, " x ", "|") - - parts := strings.Split(normalized, "|") - result := make([]string, 0, len(parts)) - for _, p := range parts { - trimmed := strings.TrimSpace(p) - if trimmed != "" { - result = append(result, trimmed) - } - } - return result -} - -func qobuzSameWordsUnordered(a, b string) bool { - wordsA := strings.Fields(a) - wordsB := strings.Fields(b) - - if len(wordsA) != len(wordsB) || len(wordsA) == 0 { - return false - } - - sortedA := make([]string, len(wordsA)) - sortedB := make([]string, len(wordsB)) - copy(sortedA, wordsA) - copy(sortedB, wordsB) - - for i := 0; i < len(sortedA)-1; i++ { - for j := i + 1; j < len(sortedA); j++ { - if sortedA[i] > sortedA[j] { - sortedA[i], sortedA[j] = sortedA[j], sortedA[i] - } - if sortedB[i] > sortedB[j] { - sortedB[i], sortedB[j] = sortedB[j], sortedB[i] - } - } - } - - for i := range sortedA { - if sortedA[i] != sortedB[i] { - return false - } - } - return true -} - -func qobuzTitlesMatch(expectedTitle, foundTitle string) bool { - normExpected := strings.ToLower(strings.TrimSpace(expectedTitle)) - normFound := strings.ToLower(strings.TrimSpace(foundTitle)) - - if normExpected == normFound { - return true - } - - if strings.Contains(normExpected, normFound) || strings.Contains(normFound, normExpected) { - return true - } - - cleanExpected := qobuzCleanTitle(normExpected) - cleanFound := qobuzCleanTitle(normFound) - - if cleanExpected == cleanFound { - return true - } - - if cleanExpected != "" && cleanFound != "" { - if strings.Contains(cleanExpected, cleanFound) || strings.Contains(cleanFound, cleanExpected) { - return true - } - } - - coreExpected := qobuzExtractCoreTitle(normExpected) - coreFound := qobuzExtractCoreTitle(normFound) - - if coreExpected != "" && coreFound != "" && coreExpected == coreFound { - return true - } - - looseExpected := normalizeLooseTitle(normExpected) - looseFound := normalizeLooseTitle(normFound) - if looseExpected != "" && looseFound != "" { - if looseExpected == looseFound { - return true - } - if strings.Contains(looseExpected, looseFound) || strings.Contains(looseFound, looseExpected) { - return true - } - } - - // Emoji/symbol-only titles must be matched strictly to avoid false positives - // like mapping "🪐" to unrelated textual tracks. - if (!hasAlphaNumericRunes(expectedTitle) || !hasAlphaNumericRunes(foundTitle)) && - strings.TrimSpace(expectedTitle) != "" && - strings.TrimSpace(foundTitle) != "" { - expectedSymbols := normalizeSymbolOnlyTitle(expectedTitle) - foundSymbols := normalizeSymbolOnlyTitle(foundTitle) - if expectedSymbols != "" && foundSymbols != "" && expectedSymbols == foundSymbols { - GoLog("[Qobuz] Symbol-heavy title matched strictly: '%s' vs '%s'\n", expectedTitle, foundTitle) - return true - } - GoLog("[Qobuz] Symbol-heavy title mismatch: '%s' vs '%s'\n", expectedTitle, foundTitle) - return false - } - - expectedLatin := qobuzIsLatinScript(expectedTitle) - foundLatin := qobuzIsLatinScript(foundTitle) - if expectedLatin != foundLatin { - GoLog("[Qobuz] Titles in different scripts, assuming match: '%s' vs '%s'\n", expectedTitle, foundTitle) - return true - } - - return false -} - -func qobuzExtractCoreTitle(title string) string { - parenIdx := strings.Index(title, "(") - bracketIdx := strings.Index(title, "[") - dashIdx := strings.Index(title, " - ") - - cutIdx := len(title) - if parenIdx > 0 && parenIdx < cutIdx { - cutIdx = parenIdx - } - if bracketIdx > 0 && bracketIdx < cutIdx { - cutIdx = bracketIdx - } - if dashIdx > 0 && dashIdx < cutIdx { - cutIdx = dashIdx - } - - return strings.TrimSpace(title[:cutIdx]) -} - -func qobuzCleanTitle(title string) string { - cleaned := title - - versionPatterns := []string{ - "remaster", "remastered", "deluxe", "bonus", "single", - "album version", "radio edit", "original mix", "extended", - "club mix", "remix", "live", "acoustic", "demo", - } - - for { - startParen := strings.LastIndex(cleaned, "(") - endParen := strings.LastIndex(cleaned, ")") - if startParen >= 0 && endParen > startParen { - content := strings.ToLower(cleaned[startParen+1 : endParen]) - isVersionIndicator := false - for _, pattern := range versionPatterns { - if strings.Contains(content, pattern) { - isVersionIndicator = true - break - } - } - if isVersionIndicator { - cleaned = strings.TrimSpace(cleaned[:startParen]) + cleaned[endParen+1:] - continue - } - } - break - } - - for { - startBracket := strings.LastIndex(cleaned, "[") - endBracket := strings.LastIndex(cleaned, "]") - if startBracket >= 0 && endBracket > startBracket { - content := strings.ToLower(cleaned[startBracket+1 : endBracket]) - isVersionIndicator := false - for _, pattern := range versionPatterns { - if strings.Contains(content, pattern) { - isVersionIndicator = true - break - } - } - if isVersionIndicator { - cleaned = strings.TrimSpace(cleaned[:startBracket]) + cleaned[endBracket+1:] - continue - } - } - break - } - - dashPatterns := []string{ - " - remaster", " - remastered", " - single version", " - radio edit", - " - live", " - acoustic", " - demo", " - remix", - } - for _, pattern := range dashPatterns { - if strings.HasSuffix(strings.ToLower(cleaned), pattern) { - cleaned = cleaned[:len(cleaned)-len(pattern)] - } - } - - for strings.Contains(cleaned, " ") { - cleaned = strings.ReplaceAll(cleaned, " ", " ") - } - - return strings.TrimSpace(cleaned) -} - -func qobuzIsLatinScript(s string) bool { - for _, r := range s { - if r < 128 { - continue - } - if (r >= 0x0100 && r <= 0x024F) || - (r >= 0x1E00 && r <= 0x1EFF) || - (r >= 0x00C0 && r <= 0x00FF) { - continue - } - if (r >= 0x4E00 && r <= 0x9FFF) || - (r >= 0x3040 && r <= 0x309F) || - (r >= 0x30A0 && r <= 0x30FF) || - (r >= 0xAC00 && r <= 0xD7AF) || - (r >= 0x0600 && r <= 0x06FF) || - (r >= 0x0400 && r <= 0x04FF) { - return false - } - } - return true -} - -func containsQueryQobuz(queries []string, query string) bool { - for _, q := range queries { - if q == query { - return true - } - } - return false -} - -func NewQobuzDownloader() *QobuzDownloader { - qobuzDownloaderOnce.Do(func() { - globalQobuzDownloader = &QobuzDownloader{ - client: NewHTTPClientWithTimeout(DefaultTimeout), - appID: "798273057", - } - }) - return globalQobuzDownloader -} - -func (q *QobuzDownloader) GetTrackByID(trackID int64) (*QobuzTrack, error) { - trackURL := fmt.Sprintf("%s%d&app_id=%s", qobuzTrackGetBaseURL, trackID, q.appID) - - req, err := http.NewRequest("GET", trackURL, nil) - if err != nil { - return nil, err - } - - resp, err := DoRequestWithUserAgent(q.client, req) - if err != nil { - if isQobuzPrimaryUnavailable(err) { - GoLog("[Qobuz] Primary API unavailable for track %d, trying qbz2 fallback: %v\n", trackID, err) - return q.getTrackByIDViaMusicDL(trackID) - } - return nil, err - } - defer resp.Body.Close() - - if resp.StatusCode != 200 { - primaryErr := fmt.Errorf("get track failed: HTTP %d", resp.StatusCode) - if isQobuzPrimaryUnavailable(primaryErr) { - GoLog("[Qobuz] Primary API unavailable for track %d, trying qbz2 fallback: %v\n", trackID, primaryErr) - return q.getTrackByIDViaMusicDL(trackID) - } - return nil, primaryErr - } - - var track QobuzTrack - if err := json.NewDecoder(resp.Body).Decode(&track); err != nil { - return nil, err - } - - return &track, nil -} - -func (q *QobuzDownloader) getTrackByIDViaMusicDL(trackID int64) (*QobuzTrack, error) { - requestURL := fmt.Sprintf("%s%d", qobuzFallbackTrackGetBaseURL, trackID) - var track QobuzTrack - if err := q.getQobuzJSON(requestURL, &track); err != nil { - return nil, fmt.Errorf("qbz2 fallback also failed for track %d: %w", trackID, err) - } - GoLog("[Qobuz] qbz2 fallback succeeded for track %d\n", trackID) - return &track, nil -} - -func (q *QobuzDownloader) getQobuzJSON(requestURL string, target interface{}) error { - req, err := http.NewRequest("GET", requestURL, nil) - if err != nil { - return err - } - - resp, err := DoRequestWithUserAgent(q.client, req) - if err != nil { - return err - } - defer resp.Body.Close() - - if resp.StatusCode != 200 { - body, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) - return fmt.Errorf("qobuz request failed: HTTP %d (%s)", resp.StatusCode, strings.TrimSpace(string(body))) - } - - return json.NewDecoder(resp.Body).Decode(target) -} - -func (q *QobuzDownloader) getQobuzBody(requestURL string) ([]byte, error) { - req, err := http.NewRequest("GET", requestURL, nil) - if err != nil { - return nil, err - } - - resp, err := DoRequestWithUserAgent(q.client, req) - if err != nil { - return nil, err - } - defer resp.Body.Close() - - if resp.StatusCode != 200 { - body, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) - return nil, fmt.Errorf("qobuz request failed: HTTP %d (%s)", resp.StatusCode, strings.TrimSpace(string(body))) - } - - return io.ReadAll(resp.Body) -} - -func isQobuzPrimaryUnavailable(err error) bool { - if err == nil { - return false - } - errStr := err.Error() - return strings.Contains(errStr, "HTTP 429") || - strings.Contains(errStr, "HTTP 5") || - strings.Contains(errStr, "rate limit") || - strings.Contains(errStr, "connection refused") || - strings.Contains(errStr, "no such host") || - strings.Contains(errStr, "i/o timeout") || - strings.Contains(errStr, "deadline exceeded") || - strings.Contains(errStr, "EOF") || - strings.Contains(errStr, "connection reset") || - strings.Contains(errStr, "TLS handshake") || - strings.Contains(errStr, "server misbehaving") || - strings.Contains(errStr, "network is unreachable") -} - -func extractQobuzAlbumIDsFromArtistHTML(body []byte) []string { - matches := qobuzArtistAlbumIDRegex.FindAllSubmatch(body, -1) - if len(matches) == 0 { - return nil - } - - albumIDs := make([]string, 0, len(matches)) - seen := make(map[string]struct{}, len(matches)) - for _, match := range matches { - if len(match) < 2 { - continue - } - albumID := strings.TrimSpace(string(match[1])) - if albumID == "" { - continue - } - if _, ok := seen[albumID]; ok { - continue - } - seen[albumID] = struct{}{} - albumIDs = append(albumIDs, albumID) - } - return albumIDs -} - -func (q *QobuzDownloader) getAlbumDetails(albumID string) (*qobuzAlbumDetails, error) { - requestURL := fmt.Sprintf("%s%s&app_id=%s", qobuzAlbumGetBaseURL, url.QueryEscape(strings.TrimSpace(albumID)), q.appID) - var album qobuzAlbumDetails - if err := q.getQobuzJSON(requestURL, &album); err != nil { - if isQobuzPrimaryUnavailable(err) { - GoLog("[Qobuz] Primary API unavailable for album %s, trying qbz2 fallback: %v\n", albumID, err) - return q.getAlbumDetailsViaMusicDL(albumID) - } - return nil, err - } - return &album, nil -} - -func (q *QobuzDownloader) getAlbumDetailsViaMusicDL(albumID string) (*qobuzAlbumDetails, error) { - requestURL := fmt.Sprintf("%s%s", qobuzFallbackAlbumGetBaseURL, url.QueryEscape(strings.TrimSpace(albumID))) - var album qobuzAlbumDetails - if err := q.getQobuzJSON(requestURL, &album); err != nil { - return nil, fmt.Errorf("qbz2 fallback also failed for album %s: %w", albumID, err) - } - GoLog("[Qobuz] qbz2 fallback succeeded for album %s\n", albumID) - return &album, nil -} - -func (q *QobuzDownloader) getArtistDetails(artistID string) (*qobuzArtistDetails, error) { - requestURL := fmt.Sprintf("%s%s&app_id=%s", qobuzArtistGetBaseURL, url.QueryEscape(strings.TrimSpace(artistID)), q.appID) - var artist qobuzArtistDetails - if err := q.getQobuzJSON(requestURL, &artist); err != nil { - if isQobuzPrimaryUnavailable(err) { - GoLog("[Qobuz] Primary API unavailable for artist %s, trying qbz2 fallback: %v\n", artistID, err) - return q.getArtistDetailsViaMusicDL(artistID) - } - return nil, err - } - return &artist, nil -} - -func (q *QobuzDownloader) getArtistDetailsViaMusicDL(artistID string) (*qobuzArtistDetails, error) { - requestURL := fmt.Sprintf("%s%s", qobuzFallbackArtistGetBaseURL, url.QueryEscape(strings.TrimSpace(artistID))) - var artist qobuzArtistDetails - if err := q.getQobuzJSON(requestURL, &artist); err != nil { - return nil, fmt.Errorf("qbz2 fallback also failed for artist %s: %w", artistID, err) - } - GoLog("[Qobuz] qbz2 fallback succeeded for artist %s\n", artistID) - return &artist, nil -} - -func (q *QobuzDownloader) getPlaylistDetailsPage(playlistID string, limit, offset int) (*qobuzPlaylistDetails, error) { - requestURL := fmt.Sprintf( - "%s%s&extra=tracks&limit=%d&offset=%d&app_id=%s", - qobuzPlaylistGetBaseURL, - url.QueryEscape(strings.TrimSpace(playlistID)), - limit, - offset, - q.appID, - ) - var playlist qobuzPlaylistDetails - if err := q.getQobuzJSON(requestURL, &playlist); err != nil { - if isQobuzPrimaryUnavailable(err) { - GoLog("[Qobuz] Primary API unavailable for playlist %s, trying qbz2 fallback: %v\n", playlistID, err) - return q.getPlaylistDetailsPageViaMusicDL(playlistID, limit, offset) - } - return nil, err - } - return &playlist, nil -} - -func (q *QobuzDownloader) getPlaylistDetailsPageViaMusicDL(playlistID string, limit, offset int) (*qobuzPlaylistDetails, error) { - requestURL := fmt.Sprintf( - "%s%s&limit=%d&offset=%d", - qobuzFallbackPlaylistGetBaseURL, - url.QueryEscape(strings.TrimSpace(playlistID)), - limit, - offset, - ) - var playlist qobuzPlaylistDetails - if err := q.getQobuzJSON(requestURL, &playlist); err != nil { - return nil, fmt.Errorf("qbz2 fallback also failed for playlist %s: %w", playlistID, err) - } - GoLog("[Qobuz] qbz2 fallback succeeded for playlist %s (offset=%d)\n", playlistID, offset) - return &playlist, nil -} - -func (q *QobuzDownloader) getArtistAlbumIDs(artistID string) ([]string, error) { - artist, err := q.getArtistDetails(artistID) - if err != nil { - return nil, err - } - - slug := strings.TrimSpace(artist.Slug) - if slug == "" { - slug = "artist" - } - requestURL := fmt.Sprintf("%s/interpreter/%s/%d", qobuzStoreBaseURL, url.PathEscape(slug), artist.ID) - body, err := q.getQobuzBody(requestURL) - if err != nil { - return nil, err - } - - albumIDs := extractQobuzAlbumIDsFromArtistHTML(body) - if len(albumIDs) == 0 { - return nil, fmt.Errorf("artist page did not contain album IDs") - } - return albumIDs, nil -} - -func (q *QobuzDownloader) GetTrackMetadata(resourceID string) (*TrackResponse, error) { - trackID, err := strconv.ParseInt(strings.TrimSpace(resourceID), 10, 64) - if err != nil || trackID <= 0 { - return nil, fmt.Errorf("invalid Qobuz track ID: %s", resourceID) - } - - track, err := q.GetTrackByID(trackID) - if err != nil { - return nil, err - } - - return &TrackResponse{Track: qobuzTrackToTrackMetadata(track)}, nil -} - -func (q *QobuzDownloader) GetAlbumMetadata(resourceID string) (*AlbumResponsePayload, error) { - album, err := q.getAlbumDetails(resourceID) - if err != nil { - return nil, err - } - - tracks := make([]AlbumTrackMetadata, 0, len(album.Tracks.Items)) - totalDiscs := 0 - for i := range album.Tracks.Items { - track := &album.Tracks.Items[i] - track.Album.ID = album.ID - track.Album.Title = album.Title - track.Album.ReleaseDate = album.ReleaseDateOriginal - track.Album.Image = qobuzImageSet{ - Thumbnail: album.Image.Thumbnail, - Small: album.Image.Small, - Large: album.Image.Large, - } - track.Album.TracksCount = album.TracksCount - if track.MediaNumber > totalDiscs { - totalDiscs = track.MediaNumber - } - tracks = append(tracks, qobuzTrackToAlbumTrackMetadata(track)) - } - for i := range tracks { - tracks[i].TotalDiscs = totalDiscs - } - - return &AlbumResponsePayload{ - AlbumInfo: qobuzAlbumToAlbumInfo(album), - TrackList: tracks, - }, nil -} - -func (q *QobuzDownloader) GetPlaylistMetadata(resourceID string) (*PlaylistResponsePayload, error) { - const pageSize = 50 - - offset := 0 - var playlistInfo PlaylistInfoMetadata - tracks := make([]AlbumTrackMetadata, 0, pageSize) - - for { - page, err := q.getPlaylistDetailsPage(resourceID, pageSize, offset) - if err != nil { - return nil, err - } - - if offset == 0 { - total := page.Tracks.Total - if total == 0 { - total = page.TracksCount - } - playlistInfo.Tracks.Total = total - playlistInfo.Owner.DisplayName = strings.TrimSpace(page.Owner.Name) - playlistInfo.Owner.Name = strings.TrimSpace(page.Name) - playlistInfo.Owner.Images = qobuzFirstNonEmpty(page.ImageRectangle...) - } - - for i := range page.Tracks.Items { - tracks = append(tracks, qobuzTrackToAlbumTrackMetadata(&page.Tracks.Items[i])) - } - - if len(page.Tracks.Items) == 0 || - offset+len(page.Tracks.Items) >= playlistInfo.Tracks.Total || - len(page.Tracks.Items) < pageSize { - break - } - offset += len(page.Tracks.Items) - } - - return &PlaylistResponsePayload{ - PlaylistInfo: playlistInfo, - TrackList: tracks, - }, nil -} - -func (q *QobuzDownloader) GetArtistMetadata(resourceID string) (*ArtistResponsePayload, error) { - artist, err := q.getArtistDetails(resourceID) - if err != nil { - return nil, err - } - - albumIDs, err := q.getArtistAlbumIDs(resourceID) - if err != nil { - return nil, err - } - - albums := make([]ArtistAlbumMetadata, 0, len(albumIDs)) - for _, albumID := range albumIDs { - album, albumErr := q.getAlbumDetails(albumID) - if albumErr != nil { - GoLog("[Qobuz] Skipping artist album %s: %v\n", albumID, albumErr) - continue - } - albums = append(albums, qobuzAlbumToArtistAlbum(album)) - } - - return &ArtistResponsePayload{ - ArtistInfo: ArtistInfoMetadata{ - ID: qobuzPrefixedNumericID(artist.ID), - Name: strings.TrimSpace(artist.Name), - Images: qobuzFirstNonEmpty(artist.Image.Large, artist.Image.Small, artist.Image.Thumbnail), - }, - Albums: albums, - }, nil -} - -func (q *QobuzDownloader) GetAvailableAPIs() []string { - return []string{ - qobuzDownloadAPIURL, - qobuzZarzDownloadAPIURL, - qobuzDabMusicAPIURL, - qobuzDeebAPIURL, - qobuzAfkarAPIURL, - qobuzSquidAPIURL, - } -} - -type qobuzAPIProvider struct { - Name string - URL string - Kind string -} - -const ( - qobuzAPIKindMusicDL = "musicdl" - qobuzAPIKindStandard = "standard" -) - -func (q *QobuzDownloader) GetAvailableProviders() []qobuzAPIProvider { - return []qobuzAPIProvider{ - {Name: "musicdl", URL: qobuzDownloadAPIURL, Kind: qobuzAPIKindMusicDL}, - {Name: "zarz", URL: qobuzZarzDownloadAPIURL, Kind: qobuzAPIKindMusicDL}, - {Name: "dabmusic", URL: qobuzDabMusicAPIURL, Kind: qobuzAPIKindStandard}, - {Name: "deeb", URL: qobuzDeebAPIURL, Kind: qobuzAPIKindStandard}, - {Name: "qbz", URL: qobuzAfkarAPIURL, Kind: qobuzAPIKindStandard}, - {Name: "squid", URL: qobuzSquidAPIURL, Kind: qobuzAPIKindStandard}, - } -} - -type qobuzDownloadInfo struct { - DownloadURL string - BitDepth int - SampleRate int -} - -func extractQobuzDownloadInfoFromBody(body []byte) (qobuzDownloadInfo, error) { - var raw map[string]any - if err := json.Unmarshal(body, &raw); err != nil { - return qobuzDownloadInfo{}, fmt.Errorf("invalid JSON: %v", err) - } - - if errMsg, ok := raw["error"].(string); ok && strings.TrimSpace(errMsg) != "" { - return qobuzDownloadInfo{}, fmt.Errorf("%s", errMsg) - } - if detail, ok := raw["detail"].(string); ok && strings.TrimSpace(detail) != "" { - return qobuzDownloadInfo{}, fmt.Errorf("%s", detail) - } - - if success, ok := raw["success"].(bool); ok && !success { - if msg, ok := raw["message"].(string); ok && strings.TrimSpace(msg) != "" { - return qobuzDownloadInfo{}, fmt.Errorf("%s", msg) - } - return qobuzDownloadInfo{}, fmt.Errorf("api returned success=false") - } - - info := qobuzDownloadInfo{ - BitDepth: qobuzParseBitDepth(raw["bit_depth"]), - SampleRate: qobuzParseSampleRate(raw["sampling_rate"]), - } - if urlVal, ok := raw["download_url"].(string); ok && strings.TrimSpace(urlVal) != "" { - info.DownloadURL = strings.TrimSpace(urlVal) - return info, nil - } - if urlVal, ok := raw["url"].(string); ok && strings.TrimSpace(urlVal) != "" { - info.DownloadURL = strings.TrimSpace(urlVal) - return info, nil - } - if linkVal, ok := raw["link"].(string); ok && strings.TrimSpace(linkVal) != "" { - info.DownloadURL = strings.TrimSpace(linkVal) - return info, nil - } - - if data, ok := raw["data"].(map[string]any); ok { - if info.BitDepth == 0 { - info.BitDepth = qobuzParseBitDepth(data["bit_depth"]) - } - if info.SampleRate == 0 { - info.SampleRate = qobuzParseSampleRate(data["sampling_rate"]) - } - if urlVal, ok := data["download_url"].(string); ok && strings.TrimSpace(urlVal) != "" { - info.DownloadURL = strings.TrimSpace(urlVal) - return info, nil - } - if urlVal, ok := data["url"].(string); ok && strings.TrimSpace(urlVal) != "" { - info.DownloadURL = strings.TrimSpace(urlVal) - return info, nil - } - if linkVal, ok := data["link"].(string); ok && strings.TrimSpace(linkVal) != "" { - info.DownloadURL = strings.TrimSpace(linkVal) - return info, nil - } - } - - return qobuzDownloadInfo{}, fmt.Errorf("no download URL in response") -} - -func extractQobuzDownloadURLFromBody(body []byte) (string, error) { - info, err := extractQobuzDownloadInfoFromBody(body) - if err != nil { - return "", err - } - return info.DownloadURL, nil -} - -func qobuzParseBitDepth(value any) int { - switch v := value.(type) { - case float64: - return int(v) - case int: - return v - case int64: - return int(v) - case json.Number: - n, _ := v.Int64() - return int(n) - default: - return 0 - } -} - -func qobuzParseSampleRate(value any) int { - switch v := value.(type) { - case float64: - if v > 0 && v < 1000 { - return int(v * 1000) - } - return int(v) - case int: - if v > 0 && v < 1000 { - return v * 1000 - } - return v - case int64: - if v > 0 && v < 1000 { - return int(v * 1000) - } - return int(v) - case json.Number: - if n, err := v.Float64(); err == nil { - if n > 0 && n < 1000 { - return int(n * 1000) - } - return int(n) - } - return 0 - default: - return 0 - } -} - -func normalizeQobuzQualityCode(quality string) string { - switch strings.ToLower(strings.TrimSpace(quality)) { - case "", "5", "6", "cd", "lossless": - return "6" - case "7", "hi-res": - return "7" - case "27", "hi-res-max": - return "27" - default: - return "6" - } -} - -func mapQobuzQualityCodeToAPI(qualityCode string) string { - switch normalizeQobuzQualityCode(qualityCode) { - case "27": - return "hi-res-max" - case "7": - return "hi-res" - default: - return "cd" - } -} - -func (q *QobuzDownloader) SearchTrackByISRC(isrc string) (*QobuzTrack, error) { - candidates, err := q.searchQobuzTracksWithFallback(isrc, 50) - if err != nil { - return nil, err - } - - for i := range candidates { - if candidates[i].ISRC == isrc { - return &candidates[i], nil - } - } - - if len(candidates) == 0 { - return nil, fmt.Errorf("no tracks found for ISRC: %s", isrc) - } - - return nil, fmt.Errorf("no exact ISRC match found for: %s", isrc) -} - -func (q *QobuzDownloader) SearchTrackByISRCWithDuration(isrc string, expectedDurationSec int) (*QobuzTrack, error) { - GoLog("[Qobuz] Searching by ISRC: %s\n", isrc) - - candidates, err := q.searchQobuzTracksWithFallback(isrc, 50) - if err != nil { - return nil, err - } - - GoLog("[Qobuz] ISRC search returned %d results\n", len(candidates)) - - var isrcMatches []*QobuzTrack - for i := range candidates { - if candidates[i].ISRC == isrc { - isrcMatches = append(isrcMatches, &candidates[i]) - } - } - - GoLog("[Qobuz] Found %d exact ISRC matches\n", len(isrcMatches)) - - if len(isrcMatches) > 0 { - if expectedDurationSec > 0 { - var durationVerifiedMatches []*QobuzTrack - for _, track := range isrcMatches { - durationDiff := track.Duration - expectedDurationSec - if durationDiff < 0 { - durationDiff = -durationDiff - } - if durationDiff <= 10 { - durationVerifiedMatches = append(durationVerifiedMatches, track) - } - } - - if len(durationVerifiedMatches) > 0 { - GoLog("[Qobuz] ISRC match with duration verification: '%s' (expected %ds, found %ds)\n", - durationVerifiedMatches[0].Title, expectedDurationSec, durationVerifiedMatches[0].Duration) - return durationVerifiedMatches[0], nil - } - - GoLog("[Qobuz] WARNING: ISRC %s found but duration mismatch. Expected=%ds, Found=%ds. Rejecting.\n", - isrc, expectedDurationSec, isrcMatches[0].Duration) - return nil, fmt.Errorf("ISRC found but duration mismatch: expected %ds, found %ds (likely different version)", - expectedDurationSec, isrcMatches[0].Duration) - } - - GoLog("[Qobuz] ISRC match (no duration verification): '%s'\n", isrcMatches[0].Title) - return isrcMatches[0], nil - } - - if len(candidates) == 0 { - return nil, fmt.Errorf("no tracks found for ISRC: %s", isrc) - } - - return nil, fmt.Errorf("no exact ISRC match found for: %s", isrc) -} - -func (q *QobuzDownloader) SearchTrackByISRCWithTitle(isrc, expectedTitle string) (*QobuzTrack, error) { - return q.SearchTrackByISRCWithDuration(isrc, 0) -} - -func (q *QobuzDownloader) SearchTrackByMetadata(trackName, artistName string) (*QobuzTrack, error) { - return q.SearchTrackByMetadataWithDuration(trackName, artistName, 0) -} - -func (q *QobuzDownloader) SearchTracks(query string, limit int) ([]ExtTrackMetadata, error) { - cleanQuery := strings.TrimSpace(query) - if cleanQuery == "" { - return nil, fmt.Errorf("empty qobuz search query") - } - if limit <= 0 { - limit = 20 - } - - tracks, err := q.searchQobuzTracksWithFallback(cleanQuery, limit) - if err != nil { - return nil, err - } - - results := make([]ExtTrackMetadata, 0, len(tracks)) - for i := range tracks { - results = append(results, normalizeBuiltInMetadataTrack(qobuzTrackToTrackMetadata(&tracks[i]), "qobuz")) - } - return results, nil -} - -// SearchAll searches Qobuz for tracks, artists, and albums matching the query. -// Returns results in the same SearchAllResult format as Deezer's SearchAll. -func (q *QobuzDownloader) SearchAll(query string, trackLimit, artistLimit int, filter string) (*SearchAllResult, error) { - GoLog("[Qobuz] SearchAll: query=%q, trackLimit=%d, artistLimit=%d, filter=%q\n", query, trackLimit, artistLimit, filter) - - cleanQuery := strings.TrimSpace(query) - if cleanQuery == "" { - return nil, fmt.Errorf("empty qobuz search query") - } - - albumLimit := 5 - - if filter != "" { - switch filter { - case "track": - trackLimit = 50 - artistLimit = 0 - albumLimit = 0 - case "artist": - trackLimit = 0 - artistLimit = 20 - albumLimit = 0 - case "album": - trackLimit = 0 - artistLimit = 0 - albumLimit = 20 - } - } - - result := &SearchAllResult{ - Tracks: make([]TrackMetadata, 0, trackLimit), - Artists: make([]SearchArtistResult, 0, artistLimit), - Albums: make([]SearchAlbumResult, 0, albumLimit), - Playlists: make([]SearchPlaylistResult, 0), - } - - if trackLimit > 0 { - tracks, err := q.searchQobuzTracksWithFallback(cleanQuery, trackLimit) - if err != nil { - GoLog("[Qobuz] Track search failed: %v\n", err) - return nil, fmt.Errorf("qobuz track search failed: %w", err) - } - GoLog("[Qobuz] Got %d tracks from API\n", len(tracks)) - for i := range tracks { - result.Tracks = append(result.Tracks, qobuzTrackToTrackMetadata(&tracks[i])) - } - } - - if artistLimit > 0 { - searchURL := fmt.Sprintf("%sartist/search?query=%s&limit=%d&app_id=%s", - qobuzAPIBaseURL, url.QueryEscape(cleanQuery), artistLimit, q.appID) - req, err := http.NewRequest("GET", searchURL, nil) - artistSearchDone := false - if err == nil { - resp, reqErr := DoRequestWithUserAgent(q.client, req) - if reqErr == nil { - defer resp.Body.Close() - if resp.StatusCode == 200 { - var artistResp struct { - Artists struct { - Items []struct { - ID int64 `json:"id"` - Name string `json:"name"` - Image qobuzImageSet `json:"image"` - } `json:"items"` - } `json:"artists"` - } - if decErr := json.NewDecoder(resp.Body).Decode(&artistResp); decErr == nil { - GoLog("[Qobuz] Got %d artists from API\n", len(artistResp.Artists.Items)) - for _, artist := range artistResp.Artists.Items { - imageURL := qobuzFirstNonEmpty(artist.Image.Large, artist.Image.Small, artist.Image.Thumbnail) - result.Artists = append(result.Artists, SearchArtistResult{ - ID: qobuzPrefixedNumericID(artist.ID), - Name: strings.TrimSpace(artist.Name), - Images: imageURL, - }) - } - artistSearchDone = true - } else { - GoLog("[Qobuz] Artist search decode failed: %v\n", decErr) - } - } else if isQobuzPrimaryUnavailable(fmt.Errorf("HTTP %d", resp.StatusCode)) { - GoLog("[Qobuz] Artist search primary API returned HTTP %d, will try qbz2 fallback\n", resp.StatusCode) - } - } else { - GoLog("[Qobuz] Artist search request failed: %v\n", reqErr) - if isQobuzPrimaryUnavailable(reqErr) { - GoLog("[Qobuz] Primary API unavailable for artist search, will try qbz2 fallback\n") - } - } - } - if !artistSearchDone { - q.searchAllArtistsViaMusicDL(cleanQuery, artistLimit, result) - } - } - - if albumLimit > 0 { - searchURL := fmt.Sprintf("%salbum/search?query=%s&limit=%d&app_id=%s", - qobuzAPIBaseURL, url.QueryEscape(cleanQuery), albumLimit, q.appID) - req, err := http.NewRequest("GET", searchURL, nil) - albumSearchDone := false - if err == nil { - resp, reqErr := DoRequestWithUserAgent(q.client, req) - if reqErr == nil { - defer resp.Body.Close() - if resp.StatusCode == 200 { - var albumResp struct { - Albums struct { - Items []qobuzAlbumDetails `json:"items"` - } `json:"albums"` - } - if decErr := json.NewDecoder(resp.Body).Decode(&albumResp); decErr == nil { - GoLog("[Qobuz] Got %d albums from API\n", len(albumResp.Albums.Items)) - for i := range albumResp.Albums.Items { - album := &albumResp.Albums.Items[i] - result.Albums = append(result.Albums, SearchAlbumResult{ - ID: qobuzPrefixedID(album.ID), - Name: strings.TrimSpace(album.Title), - Artists: qobuzArtistsDisplayName(album.Artists, album.Artist.Name), - Images: qobuzAlbumImage(album), - ReleaseDate: qobuzNormalizeReleaseDate(album.ReleaseDateOriginal), - TotalTracks: album.TracksCount, - AlbumType: qobuzNormalizeAlbumType(album.ReleaseType, album.ProductType, album.TracksCount), - }) - } - albumSearchDone = true - } else { - GoLog("[Qobuz] Album search decode failed: %v\n", decErr) - } - } else if isQobuzPrimaryUnavailable(fmt.Errorf("HTTP %d", resp.StatusCode)) { - GoLog("[Qobuz] Album search primary API returned HTTP %d, will try qbz2 fallback\n", resp.StatusCode) - } - } else { - GoLog("[Qobuz] Album search request failed: %v\n", reqErr) - if isQobuzPrimaryUnavailable(reqErr) { - GoLog("[Qobuz] Primary API unavailable for album search, will try qbz2 fallback\n") - } - } - } - if !albumSearchDone { - q.searchAllAlbumsViaMusicDL(cleanQuery, albumLimit, result) - } - } - - GoLog("[Qobuz] SearchAll complete: %d tracks, %d artists, %d albums\n", len(result.Tracks), len(result.Artists), len(result.Albums)) - return result, nil -} - -func (q *QobuzDownloader) searchAllArtistsViaMusicDL(query string, limit int, result *SearchAllResult) { - requestURL := fmt.Sprintf("%sartist/search?query=%s&limit=%d", qobuzFallbackAPIBaseURL, url.QueryEscape(query), limit) - var searchResp struct { - Artists struct { - Items []struct { - ID int64 `json:"id"` - Name string `json:"name"` - Image qobuzImageSet `json:"image"` - } `json:"items"` - } `json:"artists"` - } - if err := q.getQobuzJSON(requestURL, &searchResp); err != nil { - GoLog("[Qobuz] qbz2 fallback artist search also failed: %v\n", err) - return - } - GoLog("[Qobuz] qbz2 fallback artist search succeeded: %d artists\n", len(searchResp.Artists.Items)) - for _, artist := range searchResp.Artists.Items { - imageURL := qobuzFirstNonEmpty(artist.Image.Large, artist.Image.Small, artist.Image.Thumbnail) - result.Artists = append(result.Artists, SearchArtistResult{ - ID: qobuzPrefixedNumericID(artist.ID), - Name: strings.TrimSpace(artist.Name), - Images: imageURL, - }) - } -} - -func (q *QobuzDownloader) searchAllAlbumsViaMusicDL(query string, limit int, result *SearchAllResult) { - requestURL := fmt.Sprintf("%salbum/search?query=%s&limit=%d", qobuzFallbackAPIBaseURL, url.QueryEscape(query), limit) - var searchResp struct { - Albums struct { - Items []qobuzAlbumDetails `json:"items"` - } `json:"albums"` - } - if err := q.getQobuzJSON(requestURL, &searchResp); err != nil { - GoLog("[Qobuz] qbz2 fallback album search also failed: %v\n", err) - return - } - GoLog("[Qobuz] qbz2 fallback album search succeeded: %d albums\n", len(searchResp.Albums.Items)) - for i := range searchResp.Albums.Items { - album := &searchResp.Albums.Items[i] - result.Albums = append(result.Albums, SearchAlbumResult{ - ID: qobuzPrefixedID(album.ID), - Name: strings.TrimSpace(album.Title), - Artists: qobuzArtistsDisplayName(album.Artists, album.Artist.Name), - Images: qobuzAlbumImage(album), - ReleaseDate: qobuzNormalizeReleaseDate(album.ReleaseDateOriginal), - TotalTracks: album.TracksCount, - AlbumType: qobuzNormalizeAlbumType(album.ReleaseType, album.ProductType, album.TracksCount), - }) - } -} - -func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistName string, expectedDurationSec int) (*QobuzTrack, error) { - queries := []string{} - - if artistName != "" && trackName != "" { - queries = append(queries, artistName+" "+trackName) - } - - if trackName != "" { - queries = append(queries, trackName) - } - - if ContainsJapanese(trackName) || ContainsJapanese(artistName) { - romajiTrack := JapaneseToRomaji(trackName) - romajiArtist := JapaneseToRomaji(artistName) - - cleanRomajiTrack := CleanToASCII(romajiTrack) - cleanRomajiArtist := CleanToASCII(romajiArtist) - - if cleanRomajiArtist != "" && cleanRomajiTrack != "" { - romajiQuery := cleanRomajiArtist + " " + cleanRomajiTrack - if !containsQueryQobuz(queries, romajiQuery) { - queries = append(queries, romajiQuery) - GoLog("[Qobuz] Japanese detected, adding romaji query: %s\n", romajiQuery) - } - } - - if cleanRomajiTrack != "" && cleanRomajiTrack != trackName { - if !containsQueryQobuz(queries, cleanRomajiTrack) { - queries = append(queries, cleanRomajiTrack) - } - } - } - - if artistName != "" { - artistOnly := CleanToASCII(JapaneseToRomaji(artistName)) - if artistOnly != "" && !containsQueryQobuz(queries, artistOnly) { - queries = append(queries, artistOnly) - } - } - - var allTracks []QobuzTrack - searchedQueries := make(map[string]bool) - seenTrackIDs := make(map[int64]struct{}) - - for _, query := range queries { - cleanQuery := strings.TrimSpace(query) - if cleanQuery == "" || searchedQueries[cleanQuery] { - continue - } - searchedQueries[cleanQuery] = true - - GoLog("[Qobuz] Searching for: %s\n", cleanQuery) - - result, err := q.searchQobuzTracksWithFallback(cleanQuery, 50) - if err != nil { - GoLog("[Qobuz] Search error for '%s': %v\n", cleanQuery, err) - continue - } - - if len(result) > 0 { - GoLog("[Qobuz] Found %d results for '%s'\n", len(result), cleanQuery) - for i := range result { - trackID := result[i].ID - if trackID <= 0 { - allTracks = append(allTracks, result[i]) - continue - } - if _, ok := seenTrackIDs[trackID]; ok { - continue - } - seenTrackIDs[trackID] = struct{}{} - allTracks = append(allTracks, result[i]) - } - } - } - - if len(allTracks) == 0 { - return nil, fmt.Errorf("no tracks found for: %s - %s", artistName, trackName) - } - - var titleMatches []*QobuzTrack - for i := range allTracks { - track := &allTracks[i] - if qobuzTitlesMatch(trackName, track.Title) { - titleMatches = append(titleMatches, track) - } - } - - GoLog("[Qobuz] Title matches: %d out of %d results\n", len(titleMatches), len(allTracks)) - - tracksToCheck := titleMatches - if len(titleMatches) == 0 { - GoLog("[Qobuz] WARNING: No title matches for '%s', checking all %d results\n", trackName, len(allTracks)) - for i := range allTracks { - tracksToCheck = append(tracksToCheck, &allTracks[i]) - } - } - - if expectedDurationSec > 0 { - var durationMatches []*QobuzTrack - for _, track := range tracksToCheck { - durationDiff := track.Duration - expectedDurationSec - if durationDiff < 0 { - durationDiff = -durationDiff - } - if durationDiff <= 10 { - durationMatches = append(durationMatches, track) - } - } - - if len(durationMatches) > 0 { - for _, track := range durationMatches { - if track.MaximumBitDepth >= 24 { - GoLog("[Qobuz] Match found: '%s' by '%s' (title+duration verified, hi-res)\n", - track.Title, track.Performer.Name) - return track, nil - } - } - GoLog("[Qobuz] Match found: '%s' by '%s' (title+duration verified)\n", - durationMatches[0].Title, durationMatches[0].Performer.Name) - return durationMatches[0], nil - } - - return nil, fmt.Errorf("no tracks found with matching title and duration (expected '%s', %ds)", trackName, expectedDurationSec) - } - - for _, track := range tracksToCheck { - if track.MaximumBitDepth >= 24 { - GoLog("[Qobuz] Match found: '%s' by '%s' (title verified, hi-res)\n", - track.Title, track.Performer.Name) - return track, nil - } - } - - if len(tracksToCheck) > 0 { - GoLog("[Qobuz] Match found: '%s' by '%s' (title verified)\n", - tracksToCheck[0].Title, tracksToCheck[0].Performer.Name) - return tracksToCheck[0], nil - } - - return nil, fmt.Errorf("no matching track found for: %s - %s", artistName, trackName) -} - -func qobuzTrackMatchesRequest(req DownloadRequest, track *QobuzTrack, logPrefix, source string, skipNameVerification bool) bool { - if track == nil { - return false - } - - exactISRCMatch := req.ISRC != "" && - track.ISRC != "" && - strings.EqualFold(strings.TrimSpace(req.ISRC), strings.TrimSpace(track.ISRC)) - - if !exactISRCMatch && !skipNameVerification { - if req.ArtistName != "" && !qobuzArtistsMatch(req.ArtistName, track.Performer.Name) { - GoLog("[%s] Artist mismatch from %s: expected '%s', got '%s'. Rejecting.\n", - logPrefix, source, req.ArtistName, track.Performer.Name) - return false - } - - if req.TrackName != "" && !qobuzTitlesMatch(req.TrackName, track.Title) { - GoLog("[%s] Title mismatch from %s: expected '%s', got '%s'. Rejecting.\n", - logPrefix, source, req.TrackName, track.Title) - return false - } - } - - expectedDurationSec := req.DurationMS / 1000 - if expectedDurationSec > 0 && track.Duration > 0 { - durationDiff := track.Duration - expectedDurationSec - if durationDiff < 0 { - durationDiff = -durationDiff - } - if durationDiff > 10 { - GoLog("[%s] Duration mismatch from %s: expected %ds, got %ds. Rejecting.\n", - logPrefix, source, expectedDurationSec, track.Duration) - return false - } - } - - return true -} - -func (q *QobuzDownloader) searchQobuzTracksViaAPI(query string, limit int) ([]QobuzTrack, error) { - searchURL := fmt.Sprintf("%s%s&limit=%d&app_id=%s", qobuzTrackSearchBaseURL, url.QueryEscape(query), limit, q.appID) - req, err := http.NewRequest("GET", searchURL, nil) - if err != nil { - return nil, err - } - - resp, err := DoRequestWithUserAgent(q.client, req) - if err != nil { - if isQobuzPrimaryUnavailable(err) { - GoLog("[Qobuz] Primary API unavailable for track search, trying qbz2 fallback: %v\n", err) - return q.searchQobuzTracksViaMusicDL(query, limit) - } - return nil, err - } - defer resp.Body.Close() - - if resp.StatusCode != 200 { - body, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) - primaryErr := fmt.Errorf("search failed: HTTP %d (%s)", resp.StatusCode, strings.TrimSpace(string(body))) - if isQobuzPrimaryUnavailable(primaryErr) { - GoLog("[Qobuz] Primary API unavailable for track search, trying qbz2 fallback: %v\n", primaryErr) - return q.searchQobuzTracksViaMusicDL(query, limit) - } - return nil, primaryErr - } - - var result struct { - Tracks struct { - Items []QobuzTrack `json:"items"` - } `json:"tracks"` - } - if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { - return nil, err - } - return result.Tracks.Items, nil -} - -func (q *QobuzDownloader) searchQobuzTracksViaMusicDL(query string, limit int) ([]QobuzTrack, error) { - requestURL := fmt.Sprintf("%s%s&limit=%d", qobuzFallbackTrackSearchBaseURL, url.QueryEscape(query), limit) - var result struct { - Tracks struct { - Items []QobuzTrack `json:"items"` - } `json:"tracks"` - } - if err := q.getQobuzJSON(requestURL, &result); err != nil { - return nil, fmt.Errorf("qbz2 fallback search also failed: %w", err) - } - GoLog("[Qobuz] qbz2 fallback search succeeded: %d tracks for '%s'\n", len(result.Tracks.Items), query) - return result.Tracks.Items, nil -} - -type qobuzTrackSearchCandidate struct { - score int - track QobuzTrack -} - -func qobuzNormalizedSearchText(value string) string { - return normalizeLooseArtistName(value) -} - -func qobuzSearchTokens(value string) []string { - normalized := qobuzNormalizedSearchText(value) - if normalized == "" { - return nil - } - - parts := strings.Fields(normalized) - tokens := make([]string, 0, len(parts)) - seen := make(map[string]struct{}, len(parts)) - for _, part := range parts { - if len(part) < 2 { - continue - } - if _, ok := seen[part]; ok { - continue - } - seen[part] = struct{}{} - tokens = append(tokens, part) - } - return tokens -} - -func qobuzScoreTrackSearchCandidate(query string, track *QobuzTrack) int { - if track == nil { - return 0 - } - - queryNorm := qobuzNormalizedSearchText(query) - if queryNorm == "" { - return 0 - } - - titleNorm := qobuzNormalizedSearchText(track.Title) - displayNorm := qobuzNormalizedSearchText(qobuzTrackDisplayTitle(track)) - artistNorm := qobuzNormalizedSearchText(qobuzTrackArtistName(track)) - albumNorm := qobuzNormalizedSearchText(strings.TrimSpace(track.Album.Title)) - - score := 0 - - if qobuzTitlesMatch(query, track.Title) || qobuzTitlesMatch(query, qobuzTrackDisplayTitle(track)) { - score += 900 - } - - switch { - case queryNorm == titleNorm, queryNorm == displayNorm: - score += 1200 - case (titleNorm != "" && strings.Contains(titleNorm, queryNorm)) || - (displayNorm != "" && strings.Contains(displayNorm, queryNorm)): - score += 420 - case (titleNorm != "" && strings.Contains(queryNorm, titleNorm)) || - (displayNorm != "" && strings.Contains(queryNorm, displayNorm)): - score += 260 - } - - if artistNorm != "" && strings.Contains(queryNorm, artistNorm) { - score += 180 - } - if albumNorm != "" && strings.Contains(queryNorm, albumNorm) { - score += 100 - } - - for _, token := range qobuzSearchTokens(query) { - switch { - case strings.Contains(titleNorm, token), strings.Contains(displayNorm, token): - score += 180 - case strings.Contains(artistNorm, token): - score += 70 - case strings.Contains(albumNorm, token): - score += 35 - } - } - - if track.ISRC != "" { - score += 15 - } - if track.MaximumBitDepth >= 24 { - score += 10 - } - if track.MaximumSamplingRate >= 88.2 { - score += 10 - } - - return score -} - -func selectQobuzTracksFromAlbumSearchResults( - query string, - limit int, - albumSummaries []qobuzAlbumDetails, - loadAlbum func(string) (*qobuzAlbumDetails, error), -) ([]QobuzTrack, error) { - if strings.TrimSpace(query) == "" { - return nil, fmt.Errorf("empty qobuz album-search fallback query") - } - if len(albumSummaries) == 0 { - return nil, fmt.Errorf("album search returned no albums") - } - - candidates := make([]qobuzTrackSearchCandidate, 0, limit) - seenTrackIDs := make(map[int64]struct{}) - - for _, summary := range albumSummaries { - albumID := strings.TrimSpace(summary.ID) - if albumID == "" { - continue - } - - album, err := loadAlbum(albumID) - if err != nil || album == nil { - continue - } - - for i := range album.Tracks.Items { - track := album.Tracks.Items[i] - track.Album.ID = album.ID - track.Album.QobuzID = album.QobuzID - track.Album.Title = album.Title - track.Album.ReleaseDate = album.ReleaseDateOriginal - track.Album.TracksCount = album.TracksCount - track.Album.ProductType = album.ProductType - track.Album.ReleaseType = album.ReleaseType - track.Album.Artist.ID = album.Artist.ID - track.Album.Artist.Name = album.Artist.Name - track.Album.Artists = album.Artists - track.Album.Image = album.Image - - if track.ID > 0 { - if _, ok := seenTrackIDs[track.ID]; ok { - continue - } - seenTrackIDs[track.ID] = struct{}{} - } - - score := qobuzScoreTrackSearchCandidate(query, &track) - if score <= 0 { - continue - } - - candidates = append(candidates, qobuzTrackSearchCandidate{ - score: score, - track: track, - }) - } - } - - if len(candidates) == 0 { - return nil, fmt.Errorf("album-search fallback returned no scored track candidates") - } - - sort.SliceStable(candidates, func(i, j int) bool { - if candidates[i].score != candidates[j].score { - return candidates[i].score > candidates[j].score - } - if candidates[i].track.MaximumBitDepth != candidates[j].track.MaximumBitDepth { - return candidates[i].track.MaximumBitDepth > candidates[j].track.MaximumBitDepth - } - return candidates[i].track.ID < candidates[j].track.ID - }) - - if limit > 0 && len(candidates) > limit { - candidates = candidates[:limit] - } - - tracks := make([]QobuzTrack, 0, len(candidates)) - for _, candidate := range candidates { - tracks = append(tracks, candidate.track) - } - return tracks, nil -} - -func (q *QobuzDownloader) searchQobuzTracksViaAlbumSearch(query string, limit int) ([]QobuzTrack, error) { - albumLimit := limit - if albumLimit < 3 { - albumLimit = 3 - } - if albumLimit > 8 { - albumLimit = 8 - } - - searchURL := fmt.Sprintf( - "%salbum/search?query=%s&limit=%d&app_id=%s", - qobuzAPIBaseURL, - url.QueryEscape(strings.TrimSpace(query)), - albumLimit, - q.appID, - ) - - req, err := http.NewRequest("GET", searchURL, nil) - if err != nil { - return nil, err - } - - resp, err := DoRequestWithUserAgent(q.client, req) - if err != nil { - if isQobuzPrimaryUnavailable(err) { - GoLog("[Qobuz] Primary API unavailable for album search fallback, trying qbz2: %v\n", err) - return q.searchQobuzTracksViaAlbumSearchMusicDL(query, limit, albumLimit) - } - return nil, err - } - defer resp.Body.Close() - - if resp.StatusCode != 200 { - body, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) - primaryErr := fmt.Errorf("album search failed: HTTP %d (%s)", resp.StatusCode, strings.TrimSpace(string(body))) - if isQobuzPrimaryUnavailable(primaryErr) { - GoLog("[Qobuz] Primary API unavailable for album search fallback, trying qbz2: %v\n", primaryErr) - return q.searchQobuzTracksViaAlbumSearchMusicDL(query, limit, albumLimit) - } - return nil, primaryErr - } - - var albumResp struct { - Albums struct { - Items []qobuzAlbumDetails `json:"items"` - } `json:"albums"` - } - if err := json.NewDecoder(resp.Body).Decode(&albumResp); err != nil { - return nil, err - } - - return selectQobuzTracksFromAlbumSearchResults( - query, - limit, - albumResp.Albums.Items, - q.getAlbumDetails, - ) -} - -func (q *QobuzDownloader) searchQobuzTracksViaAlbumSearchMusicDL(query string, limit, albumLimit int) ([]QobuzTrack, error) { - requestURL := fmt.Sprintf("%salbum/search?query=%s&limit=%d", qobuzFallbackAPIBaseURL, url.QueryEscape(strings.TrimSpace(query)), albumLimit) - var searchResp struct { - Albums struct { - Items []qobuzAlbumDetails `json:"items"` - } `json:"albums"` - } - if err := q.getQobuzJSON(requestURL, &searchResp); err != nil { - return nil, fmt.Errorf("qbz2 fallback album search also failed: %w", err) - } - GoLog("[Qobuz] qbz2 fallback album search returned %d albums\n", len(searchResp.Albums.Items)) - return selectQobuzTracksFromAlbumSearchResults( - query, - limit, - searchResp.Albums.Items, - q.getAlbumDetails, - ) -} - -func extractQobuzTrackIDsFromStoreSearchHTML(body []byte) []int64 { - matches := qobuzStoreTrackIDRegex.FindAllSubmatch(body, -1) - if len(matches) == 0 { - return nil - } - - trackIDs := make([]int64, 0, len(matches)) - seen := make(map[int64]struct{}, len(matches)) - for _, match := range matches { - if len(match) < 2 { - continue - } - id, err := strconv.ParseInt(string(match[1]), 10, 64) - if err != nil || id <= 0 { - continue - } - if _, ok := seen[id]; ok { - continue - } - seen[id] = struct{}{} - trackIDs = append(trackIDs, id) - } - return trackIDs -} - -func (q *QobuzDownloader) searchQobuzTracksViaStore(query string, limit int) ([]QobuzTrack, error) { - searchURL := qobuzStoreSearchBaseURL + url.PathEscape(query) - req, err := http.NewRequest("GET", searchURL, nil) - if err != nil { - return nil, err - } - - resp, err := DoRequestWithUserAgent(q.client, req) - if err != nil { - return nil, err - } - defer resp.Body.Close() - - if resp.StatusCode != 200 { - return nil, fmt.Errorf("store search failed: HTTP %d", resp.StatusCode) - } - - body, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err - } - - trackIDs := extractQobuzTrackIDsFromStoreSearchHTML(body) - if len(trackIDs) == 0 { - return nil, fmt.Errorf("store search did not contain track IDs") - } - - if limit > 0 && len(trackIDs) > limit { - trackIDs = trackIDs[:limit] - } - - tracks := make([]QobuzTrack, 0, len(trackIDs)) - for _, id := range trackIDs { - track, trackErr := q.GetTrackByID(id) - if trackErr != nil || track == nil { - continue - } - tracks = append(tracks, *track) - } - - if len(tracks) == 0 { - return nil, fmt.Errorf("store fallback returned IDs but no track metadata could be loaded") - } - return tracks, nil -} - -func (q *QobuzDownloader) searchQobuzTracksWithFallback(query string, limit int) ([]QobuzTrack, error) { - apiTracks, apiErr := q.searchQobuzTracksViaAPI(query, limit) - if apiErr == nil { - if len(apiTracks) > 0 { - return apiTracks, nil - } - GoLog("[Qobuz] API search returned 0 results for '%s', trying album-search fallback\n", query) - } else { - GoLog("[Qobuz] API search failed for '%s': %v. Trying album-search fallback.\n", query, apiErr) - } - - albumTracks, albumErr := q.searchQobuzTracksViaAlbumSearch(query, limit) - if albumErr == nil && len(albumTracks) > 0 { - GoLog("[Qobuz] Album-search fallback returned %d candidate tracks for '%s'\n", len(albumTracks), query) - return albumTracks, nil - } - if albumErr != nil { - GoLog("[Qobuz] Album-search fallback failed for '%s': %v. Trying store fallback.\n", query, albumErr) - } - - storeTracks, storeErr := q.searchQobuzTracksViaStore(query, limit) - if storeErr == nil && len(storeTracks) > 0 { - GoLog("[Qobuz] Store fallback returned %d candidate tracks for '%s'\n", len(storeTracks), query) - return storeTracks, nil - } - - if apiErr != nil && albumErr != nil && storeErr != nil { - return nil, fmt.Errorf( - "api search failed (%v); album-search fallback failed (%v); store fallback failed (%v)", - apiErr, - albumErr, - storeErr, - ) - } - if albumErr == nil && len(albumTracks) == 0 && storeErr != nil { - return nil, storeErr - } - if storeErr != nil { - if albumErr != nil { - return nil, albumErr - } - return nil, storeErr - } - return nil, fmt.Errorf("no tracks found for query: %s", query) -} - -type qobuzAPIResult struct { - provider qobuzAPIProvider - info qobuzDownloadInfo - err error - duration time.Duration -} - -// Mobile networks are more unstable, so we use longer timeouts -const ( - qobuzAPITimeoutMobile = 25 * time.Second - qobuzMaxRetries = 2 - qobuzRetryDelay = 500 * time.Millisecond -) - -func getQobuzAPITimeout() time.Duration { - // The Go backend is only used on mobile (Android/iOS) - return qobuzAPITimeoutMobile -} - -// fetchQobuzURLWithRetry fetches download URL from a single Qobuz API with retry logic -func fetchQobuzURLWithRetry(provider qobuzAPIProvider, trackID int64, quality string, timeout time.Duration) (qobuzDownloadInfo, error) { - return fetchQobuzURLSingleAttempt(provider, trackID, quality, timeout, "") -} - -func buildQobuzMusicDLPayload(trackID int64, quality string) ([]byte, error) { - requestQuality := mapQobuzQualityCodeToAPI(quality) - payload := map[string]any{ - "quality": requestQuality, - "upload_to_r2": false, - "url": fmt.Sprintf("%s%d", qobuzTrackOpenBaseURL, trackID), - } - return json.Marshal(payload) -} - -func fetchQobuzURLSingleAttempt(provider qobuzAPIProvider, trackID int64, quality string, timeout time.Duration, country string) (qobuzDownloadInfo, error) { - var lastErr error - retryDelay := qobuzRetryDelay - var payloadBytes []byte - if provider.Kind == qobuzAPIKindMusicDL { - var err error - payloadBytes, err = buildQobuzMusicDLPayload(trackID, quality) - if err != nil { - return qobuzDownloadInfo{}, fmt.Errorf("failed to encode qobuz request: %w", err) - } - } - - for attempt := 0; attempt <= qobuzMaxRetries; attempt++ { - if attempt > 0 { - GoLog("[Qobuz] Retry %d/%d for %s after %v\n", attempt, qobuzMaxRetries, provider.Name, retryDelay) - time.Sleep(retryDelay) - retryDelay *= 2 - } - - client := NewHTTPClientWithTimeout(timeout) - reqURL := provider.URL - if country != "" { - reqURL += "?country=" + url.QueryEscape(country) - } - - var ( - req *http.Request - err error - ) - if provider.Kind == qobuzAPIKindStandard { - separator := "&" - if !strings.Contains(reqURL, "?") { - separator = "?" - } - reqURL = fmt.Sprintf( - "%s%d%squality=%s", - reqURL, - trackID, - separator, - url.QueryEscape(normalizeQobuzQualityCode(quality)), - ) - req, err = http.NewRequest("GET", reqURL, nil) - } else { - req, err = http.NewRequest("POST", reqURL, bytes.NewReader(payloadBytes)) - } - if err != nil { - lastErr = err - continue - } - if provider.Kind == qobuzAPIKindMusicDL { - req.Header.Set("Content-Type", "application/json") - } - - resp, err := DoRequestWithUserAgent(client, req) - if err != nil { - lastErr = err - // Check for retryable errors (timeout, connection reset) - errStr := strings.ToLower(err.Error()) - if strings.Contains(errStr, "timeout") || - strings.Contains(errStr, "reset") || - strings.Contains(errStr, "connection refused") || - strings.Contains(errStr, "eof") { - continue - } - break - } - if resp.StatusCode >= 500 { - io.Copy(io.Discard, resp.Body) - resp.Body.Close() - lastErr = fmt.Errorf("HTTP %d", resp.StatusCode) - continue - } - - // 429 rate limit - wait and retry - if resp.StatusCode == 429 { - io.Copy(io.Discard, resp.Body) - resp.Body.Close() - lastErr = fmt.Errorf("rate limited") - retryDelay = 2 * time.Second - continue - } - - if resp.StatusCode != 200 { - io.Copy(io.Discard, resp.Body) - resp.Body.Close() - return qobuzDownloadInfo{}, fmt.Errorf("HTTP %d", resp.StatusCode) - } - - body, err := io.ReadAll(resp.Body) - resp.Body.Close() - if err != nil { - lastErr = err - continue - } - - if len(body) > 0 && body[0] == '<' { - return qobuzDownloadInfo{}, fmt.Errorf("received HTML instead of JSON") - } - - info, parseErr := extractQobuzDownloadInfoFromBody(body) - if parseErr == nil { - return info, nil - } - lastErr = parseErr - continue - } - - if lastErr != nil { - return qobuzDownloadInfo{}, lastErr - } - return qobuzDownloadInfo{}, fmt.Errorf("all retries failed") -} - -func getQobuzDownloadURLParallel(providers []qobuzAPIProvider, trackID int64, quality string) (qobuzAPIProvider, qobuzDownloadInfo, error) { - if len(providers) == 0 { - return qobuzAPIProvider{}, qobuzDownloadInfo{}, fmt.Errorf("no APIs available") - } - - GoLog("[Qobuz] Requesting download URL from %d APIs in parallel (with retry)...\n", len(providers)) - - resultChan := make(chan qobuzAPIResult, len(providers)) - startTime := time.Now() - timeout := getQobuzAPITimeout() - - for _, provider := range providers { - go func(provider qobuzAPIProvider) { - reqStart := time.Now() - info, err := fetchQobuzURLWithRetry(provider, trackID, quality, timeout) - resultChan <- qobuzAPIResult{ - provider: provider, - info: info, - err: err, - duration: time.Since(reqStart), - } - }(provider) - } - - var errors []string - - for i := 0; i < len(providers); i++ { - result := <-resultChan - if result.err == nil { - GoLog("[Qobuz] [Parallel] Got response from %s in %v\n", result.provider.Name, result.duration) - - go func(remaining int) { - for j := 0; j < remaining; j++ { - <-resultChan - } - }(len(providers) - i - 1) - - GoLog("[Qobuz] [Parallel] Total time: %v (first success)\n", time.Since(startTime)) - return result.provider, result.info, nil - } - errMsg := result.err.Error() - if len(errMsg) > 50 { - errMsg = errMsg[:50] + "..." - } - errors = append(errors, fmt.Sprintf("%s: %s", result.provider.Name, errMsg)) - } - - GoLog("[Qobuz] [Parallel] All %d APIs failed in %v\n", len(providers), time.Since(startTime)) - return qobuzAPIProvider{}, qobuzDownloadInfo{}, fmt.Errorf("all %d Qobuz APIs failed. Errors: %v", len(providers), errors) -} - -func (q *QobuzDownloader) GetDownloadURL(trackID int64, quality string) (qobuzDownloadInfo, error) { - providers := q.GetAvailableProviders() - if len(providers) == 0 { - return qobuzDownloadInfo{}, fmt.Errorf("no Qobuz API available") - } - - qualityCode := normalizeQobuzQualityCode(quality) - - downloadFunc := func(qual string) (qobuzDownloadInfo, error) { - provider, info, err := getQobuzDownloadURLParallel(providers, trackID, qual) - if err != nil { - return qobuzDownloadInfo{}, err - } - GoLog("[Qobuz] Download URL resolved via %s\n", provider.Name) - return info, nil - } - - downloadInfo, err := downloadFunc(qualityCode) - if err == nil { - return downloadInfo, nil - } - - currentQuality := qualityCode - if currentQuality == "27" { - GoLog("[Qobuz] Hi-res (27) failed, trying 24-bit (7)...\n") - downloadInfo, err = downloadFunc("7") - if err == nil { - return downloadInfo, nil - } - currentQuality = "7" - } - - if currentQuality == "7" { - GoLog("[Qobuz] 24-bit failed, trying 16-bit (6)...\n") - downloadInfo, err = downloadFunc("6") - if err == nil { - return downloadInfo, nil - } - } - - return qobuzDownloadInfo{}, fmt.Errorf("all Qobuz APIs failed: %w", err) -} - -func (q *QobuzDownloader) DownloadFile(downloadURL, outputPath string, outputFD int, itemID string) error { - ctx := context.Background() - - if itemID != "" { - StartItemProgress(itemID) - defer CompleteItemProgress(itemID) - ctx = initDownloadCancel(itemID) - defer clearDownloadCancel(itemID) - } - - if isDownloadCancelled(itemID) { - return ErrDownloadCancelled - } - - req, err := http.NewRequestWithContext(ctx, "GET", downloadURL, nil) - if err != nil { - return fmt.Errorf("failed to create request: %w", err) - } - - resp, err := DoRequestWithUserAgent(q.client, req) - if err != nil { - if isDownloadCancelled(itemID) { - return ErrDownloadCancelled - } - return err - } - defer resp.Body.Close() - - if resp.StatusCode != 200 { - return fmt.Errorf("download failed: HTTP %d", resp.StatusCode) - } - - expectedSize := resp.ContentLength - if expectedSize > 0 && itemID != "" { - SetItemBytesTotal(itemID, expectedSize) - } - - out, err := openOutputForWrite(outputPath, outputFD) - if err != nil { - return err - } - - bufWriter := bufio.NewWriterSize(out, 256*1024) - - var written int64 - if itemID != "" { - progressWriter := NewItemProgressWriter(bufWriter, itemID) - written, err = io.Copy(progressWriter, resp.Body) - } else { - written, err = io.Copy(bufWriter, resp.Body) - } - - flushErr := bufWriter.Flush() - closeErr := out.Close() - - if err != nil { - cleanupOutputOnError(outputPath, outputFD) - if isDownloadCancelled(itemID) { - return ErrDownloadCancelled - } - return fmt.Errorf("download interrupted: %w", err) - } - if flushErr != nil { - cleanupOutputOnError(outputPath, outputFD) - return fmt.Errorf("failed to flush buffer: %w", flushErr) - } - if closeErr != nil { - cleanupOutputOnError(outputPath, outputFD) - return fmt.Errorf("failed to close file: %w", closeErr) - } - - if expectedSize > 0 && written != expectedSize { - cleanupOutputOnError(outputPath, outputFD) - return fmt.Errorf("incomplete download: expected %d bytes, got %d bytes", expectedSize, written) - } - - return nil -} - -type QobuzDownloadResult struct { - FilePath string - BitDepth int - SampleRate int - Title string - Artist string - Album string - ReleaseDate string - TrackNumber int - DiscNumber int - ISRC string - CoverURL string - LyricsLRC string -} - -func parseQobuzRequestTrackID(raw string) int64 { - trimmed := strings.TrimSpace(raw) - trimmed = strings.TrimPrefix(trimmed, "qobuz:") - if trimmed == "" { - return 0 - } - var trackID int64 - if _, err := fmt.Sscanf(trimmed, "%d", &trackID); err != nil || trackID <= 0 { - return 0 - } - return trackID -} - -func resolveQobuzTrackForRequest(req DownloadRequest, downloader *QobuzDownloader, logPrefix string) (*QobuzTrack, error) { - if downloader == nil { - downloader = NewQobuzDownloader() - } - if strings.TrimSpace(logPrefix) == "" { - logPrefix = "Qobuz" - } - - expectedDurationSec := req.DurationMS / 1000 - - var track *QobuzTrack - var err error - - // Strategy 1: Use Qobuz ID from request payload (fastest, most accurate) - if req.QobuzID != "" { - GoLog("[%s] Using Qobuz ID from request payload: %s\n", logPrefix, req.QobuzID) - if trackID := parseQobuzRequestTrackID(req.QobuzID); trackID > 0 { - track, err = qobuzGetTrackByIDFunc(downloader, trackID) - if err != nil { - GoLog("[%s] Failed to get track by request Qobuz ID %d: %v\n", logPrefix, trackID, err) - track = nil - } else if track != nil { - if qobuzTrackMatchesRequest(req, track, logPrefix, "request Qobuz ID", false) { - GoLog("[%s] Successfully found track via request Qobuz ID: '%s' by '%s'\n", logPrefix, track.Title, track.Performer.Name) - } else { - track = nil - } - } - } - } - - // Strategy 2: Use cached Qobuz Track ID (fast, no search needed) - if track == nil && req.ISRC != "" { - if cached := GetTrackIDCache().Get(req.ISRC); cached != nil && cached.QobuzTrackID > 0 { - GoLog("[%s] Cache hit! Using cached track ID: %d\n", logPrefix, cached.QobuzTrackID) - track, err = qobuzGetTrackByIDFunc(downloader, cached.QobuzTrackID) - if err != nil { - GoLog("[%s] Cache hit but GetTrackByID failed: %v\n", logPrefix, err) - track = nil - } else if track != nil && !qobuzTrackMatchesRequest(req, track, logPrefix, "cached Qobuz ID", false) { - track = nil - } - } - } - - // Strategy 3: Try to get QobuzID from SongLink if we have SpotifyID but no ISRC - if track == nil && req.SpotifyID != "" && req.QobuzID == "" && req.ISRC == "" { - GoLog("[%s] Trying to get Qobuz ID from SongLink for Spotify ID: %s\n", logPrefix, req.SpotifyID) - songLinkClient := NewSongLinkClient() - availability, slErr := songLinkCheckTrackAvailabilityFunc(songLinkClient, req.SpotifyID, req.ISRC) - if slErr == nil && availability != nil && availability.QobuzID != "" { - var trackID int64 - if _, parseErr := fmt.Sscanf(availability.QobuzID, "%d", &trackID); parseErr == nil && trackID > 0 { - GoLog("[%s] Got Qobuz ID %d from SongLink\n", logPrefix, trackID) - track, err = qobuzGetTrackByIDFunc(downloader, trackID) - if err != nil { - GoLog("[%s] Failed to get track by SongLink ID %d: %v\n", logPrefix, trackID, err) - track = nil - } else if track != nil { - if qobuzTrackMatchesRequest(req, track, logPrefix, "SongLink Qobuz ID", true) { - GoLog("[%s] Successfully found track via SongLink ID: '%s' by '%s'\n", logPrefix, track.Title, track.Performer.Name) - if req.ISRC != "" { - GetTrackIDCache().SetQobuz(req.ISRC, track.ID) - } - } else { - track = nil - } - } - } - } - } - - // Strategy 4: ISRC search with duration verification - if track == nil && req.ISRC != "" { - GoLog("[%s] Trying ISRC search: %s\n", logPrefix, req.ISRC) - track, err = qobuzSearchTrackByISRCWithDurationFunc(downloader, req.ISRC, expectedDurationSec) - if track != nil && !qobuzTrackMatchesRequest(req, track, logPrefix, "ISRC search", false) { - track = nil - } - } - - if track == nil { - errMsg := "could not find matching track on Qobuz without identifier match" - if err != nil { - errMsg = err.Error() - } - return nil, fmt.Errorf("qobuz search failed: %s", errMsg) - } - - GoLog("[%s] Match found: '%s' by '%s' (duration: %ds)\n", logPrefix, track.Title, track.Performer.Name, track.Duration) - if req.ISRC != "" { - GetTrackIDCache().SetQobuz(req.ISRC, track.ID) - } - - return track, nil -} - -func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) { - downloader := NewQobuzDownloader() - - isSafOutput := isFDOutput(req.OutputFD) || strings.TrimSpace(req.OutputPath) != "" - if !isSafOutput { - if existingFile, exists := checkISRCExistsInternal(req.OutputDir, req.ISRC); exists { - return QobuzDownloadResult{FilePath: "EXISTS:" + existingFile}, nil - } - } - - track, err := resolveQobuzTrackForRequest(req, downloader, "Qobuz") - if err != nil { - return QobuzDownloadResult{}, err - } - - filename := buildFilenameFromTemplate(req.FilenameFormat, map[string]interface{}{ - "title": req.TrackName, - "artist": req.ArtistName, - "album": req.AlbumName, - "track": req.TrackNumber, - "year": extractYear(req.ReleaseDate), - "date": req.ReleaseDate, - "disc": req.DiscNumber, - }) - var outputPath string - if isSafOutput { - outputPath = strings.TrimSpace(req.OutputPath) - if outputPath == "" && isFDOutput(req.OutputFD) { - outputPath = fmt.Sprintf("/proc/self/fd/%d", req.OutputFD) - } - } else { - filename = sanitizeFilename(filename) + ".flac" - outputPath = filepath.Join(req.OutputDir, filename) - if fileInfo, statErr := os.Stat(outputPath); statErr == nil && fileInfo.Size() > 0 { - return QobuzDownloadResult{FilePath: "EXISTS:" + outputPath}, nil - } - } - - qobuzQuality := "27" - switch req.Quality { - case "LOSSLESS": - qobuzQuality = "6" - case "HI_RES": - qobuzQuality = "7" - case "HI_RES_LOSSLESS", "", "DEFAULT": - qobuzQuality = "27" - } - GoLog("[Qobuz] Using quality: %s (mapped from %s)\n", qobuzQuality, req.Quality) - - actualBitDepth := track.MaximumBitDepth - actualSampleRate := int(track.MaximumSamplingRate * 1000) - GoLog("[Qobuz] Actual quality: %d-bit/%.1fkHz\n", actualBitDepth, track.MaximumSamplingRate) - - downloadInfo, err := downloader.GetDownloadURL(track.ID, qobuzQuality) - if err != nil { - return QobuzDownloadResult{}, fmt.Errorf("failed to get download URL: %w", err) - } - if downloadInfo.BitDepth > 0 { - actualBitDepth = downloadInfo.BitDepth - } - if downloadInfo.SampleRate > 0 { - actualSampleRate = downloadInfo.SampleRate - } - if actualBitDepth > 0 || actualSampleRate > 0 { - GoLog("[Qobuz] API returned quality: %d-bit/%dHz\n", actualBitDepth, actualSampleRate) - } - - var parallelResult *ParallelDownloadResult - parallelDone := make(chan struct{}) - go func() { - defer close(parallelDone) - coverURL := strings.TrimSpace(req.CoverURL) - if coverURL == "" { - coverURL = strings.TrimSpace(qobuzTrackAlbumImage(track)) - } - embedLyrics := req.EmbedLyrics - if !req.EmbedMetadata { - coverURL = "" - embedLyrics = false - } - parallelResult = FetchCoverAndLyricsParallel( - coverURL, - req.EmbedMaxQualityCover, - req.SpotifyID, - req.TrackName, - req.ArtistName, - embedLyrics, - int64(req.DurationMS), - ) - }() - - if err := downloader.DownloadFile(downloadInfo.DownloadURL, outputPath, req.OutputFD, req.ItemID); err != nil { - if errors.Is(err, ErrDownloadCancelled) { - return QobuzDownloadResult{}, ErrDownloadCancelled - } - return QobuzDownloadResult{}, fmt.Errorf("download failed: %w", err) - } - - <-parallelDone - - if req.ItemID != "" { - SetItemProgress(req.ItemID, 1.0, 0, 0) - SetItemFinalizing(req.ItemID) - } - - albumName := track.Album.Title - if req.AlbumName != "" { - albumName = req.AlbumName - } - releaseDate := track.Album.ReleaseDate - if req.ReleaseDate != "" { - releaseDate = req.ReleaseDate - } - - actualTrackNumber := req.TrackNumber - if actualTrackNumber == 0 { - actualTrackNumber = track.TrackNumber - } - - metadata := Metadata{ - Title: track.Title, - Artist: req.ArtistName, - Album: albumName, - AlbumArtist: req.AlbumArtist, - ArtistTagMode: req.ArtistTagMode, - Date: releaseDate, - TrackNumber: actualTrackNumber, - TotalTracks: req.TotalTracks, - DiscNumber: req.DiscNumber, - TotalDiscs: req.TotalDiscs, - ISRC: track.ISRC, - Genre: req.Genre, - Label: req.Label, - Copyright: req.Copyright, - Composer: req.Composer, - } - - var coverData []byte - if parallelResult != nil && parallelResult.CoverData != nil { - coverData = parallelResult.CoverData - GoLog("[Qobuz] Using parallel-fetched cover (%d bytes)\n", len(coverData)) - } - - if isSafOutput || !req.EmbedMetadata { - if !req.EmbedMetadata { - GoLog("[Qobuz] Metadata embedding disabled by settings, skipping in-backend metadata/lyrics embedding\n") - } else { - GoLog("[Qobuz] SAF output detected - skipping in-backend metadata/lyrics embedding (handled in Flutter)\n") - } - } else { - if err := EmbedMetadataWithCoverData(outputPath, metadata, coverData); err != nil { - fmt.Printf("Warning: failed to embed metadata: %v\n", err) - } - - if req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" { - lyricsMode := req.LyricsMode - if lyricsMode == "" { - lyricsMode = "embed" - } - - if lyricsMode == "external" || lyricsMode == "both" { - GoLog("[Qobuz] Saving external LRC file...\n") - if lrcPath, lrcErr := SaveLRCFile(outputPath, parallelResult.LyricsLRC); lrcErr != nil { - GoLog("[Qobuz] Warning: failed to save LRC file: %v\n", lrcErr) - } else { - GoLog("[Qobuz] LRC file saved: %s\n", lrcPath) - } - } - - if lyricsMode == "embed" || lyricsMode == "both" { - GoLog("[Qobuz] Embedding parallel-fetched lyrics (%d lines)...\n", len(parallelResult.LyricsData.Lines)) - if embedErr := EmbedLyrics(outputPath, parallelResult.LyricsLRC); embedErr != nil { - GoLog("[Qobuz] Warning: failed to embed lyrics: %v\n", embedErr) - } else { - fmt.Println("[Qobuz] Lyrics embedded successfully") - } - } - } else if req.EmbedLyrics { - fmt.Println("[Qobuz] No lyrics available from parallel fetch") - } - } - - if !isSafOutput { - AddToISRCIndex(req.OutputDir, req.ISRC, outputPath) - } - - lyricsLRC := "" - if req.EmbedMetadata && req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" { - lyricsLRC = parallelResult.LyricsLRC - } - - resultAlbum, resultReleaseDate, resultTrackNumber, resultDiscNumber := preferredReleaseMetadata( - req, - track.Album.Title, - track.Album.ReleaseDate, - actualTrackNumber, - req.DiscNumber, - ) - - // Prefer the cover URL the frontend sent (user-selected album) over the - // track's default album cover returned by the Qobuz track/get API, which - // may belong to a different album when the same track appears on multiple - // releases. - resultCoverURL := strings.TrimSpace(req.CoverURL) - if resultCoverURL == "" { - resultCoverURL = strings.TrimSpace(qobuzTrackAlbumImage(track)) - } - - return QobuzDownloadResult{ - FilePath: outputPath, - BitDepth: actualBitDepth, - SampleRate: actualSampleRate, - Title: track.Title, - Artist: track.Performer.Name, - Album: resultAlbum, - ReleaseDate: resultReleaseDate, - TrackNumber: resultTrackNumber, - DiscNumber: resultDiscNumber, - ISRC: track.ISRC, - CoverURL: resultCoverURL, - LyricsLRC: lyricsLRC, - }, nil -} diff --git a/go_backend/qobuz_test.go b/go_backend/qobuz_test.go deleted file mode 100644 index 78d0b121..00000000 --- a/go_backend/qobuz_test.go +++ /dev/null @@ -1,553 +0,0 @@ -package gobackend - -import ( - "encoding/json" - "testing" -) - -func buildTestQobuzAlbum(id, title, artist string, tracks ...QobuzTrack) *qobuzAlbumDetails { - album := &qobuzAlbumDetails{ - ID: id, - Title: title, - ReleaseDateOriginal: "2013-05-20", - TracksCount: len(tracks), - ProductType: "album", - ReleaseType: "album", - } - album.Artist = qobuzArtistRef{ID: 1, Name: artist} - album.Artists = []qobuzArtistRef{{ID: 1, Name: artist}} - album.Tracks.Items = tracks - return album -} - -func TestParseQobuzURL(t *testing.T) { - tests := []struct { - name string - input string - wantType string - wantID string - expectErr bool - }{ - { - name: "store album url", - input: "https://www.qobuz.com/us-en/album/harry-styles-harry-styles/0886446451985", - wantType: "album", - wantID: "0886446451985", - }, - { - name: "store playlist url", - input: "https://www.qobuz.com/us-en/playlists/new-releases/2049430", - wantType: "playlist", - wantID: "2049430", - }, - { - name: "store artist url", - input: "https://www.qobuz.com/us-en/interpreter/harry-styles/729886", - wantType: "artist", - wantID: "729886", - }, - { - name: "play track url", - input: "https://play.qobuz.com/track/40681594", - wantType: "track", - wantID: "40681594", - }, - { - name: "custom scheme playlist url", - input: "qobuzapp://playlist/2049430", - wantType: "playlist", - wantID: "2049430", - }, - { - name: "unsupported url", - input: "https://example.com/not-qobuz", - expectErr: true, - }, - } - - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - gotType, gotID, err := parseQobuzURL(test.input) - if test.expectErr { - if err == nil { - t.Fatalf("expected error, got none") - } - return - } - if err != nil { - t.Fatalf("expected no error, got %v", err) - } - if gotType != test.wantType || gotID != test.wantID { - t.Fatalf("parseQobuzURL(%q) = (%q, %q), want (%q, %q)", test.input, gotType, gotID, test.wantType, test.wantID) - } - }) - } -} - -func TestExtractQobuzArtistAlbumIDs(t *testing.T) { - body := []byte(` -
- -
-
- -
-
- -
-`) - - matches := qobuzArtistAlbumIDRegex.FindAllSubmatch(body, -1) - if len(matches) != 3 { - t.Fatalf("expected 3 regex matches, got %d", len(matches)) - } - if string(matches[0][1]) != "yrpbt0lwm3g0y" { - t.Fatalf("unexpected first album id: %q", matches[0][1]) - } - if string(matches[2][1]) != "0886446451985" { - t.Fatalf("unexpected last album id: %q", matches[2][1]) - } -} - -func TestExtractQobuzDownloadURLFromBody(t *testing.T) { - t.Run("reads top-level download_url and quality metadata", func(t *testing.T) { - body := []byte(`{"success":true,"download_url":"https://example.test/new.flac","bit_depth":24,"sampling_rate":96}`) - - info, err := extractQobuzDownloadInfoFromBody(body) - if err != nil { - t.Fatalf("expected no error, got %v", err) - } - if info.DownloadURL != "https://example.test/new.flac" { - t.Fatalf("unexpected URL: %q", info.DownloadURL) - } - if info.BitDepth != 24 { - t.Fatalf("unexpected bit depth: %d", info.BitDepth) - } - if info.SampleRate != 96000 { - t.Fatalf("unexpected sample rate: %d", info.SampleRate) - } - }) - - t.Run("reads nested data.url", func(t *testing.T) { - body := []byte(`{"success":true,"data":{"url":"https://example.test/audio.flac"}}`) - - got, err := extractQobuzDownloadURLFromBody(body) - if err != nil { - t.Fatalf("expected no error, got %v", err) - } - if got != "https://example.test/audio.flac" { - t.Fatalf("unexpected URL: %q", got) - } - }) - - t.Run("reads top-level url", func(t *testing.T) { - body := []byte(`{"url":"https://example.test/top.flac"}`) - - got, err := extractQobuzDownloadURLFromBody(body) - if err != nil { - t.Fatalf("expected no error, got %v", err) - } - if got != "https://example.test/top.flac" { - t.Fatalf("unexpected URL: %q", got) - } - }) - - t.Run("returns API error", func(t *testing.T) { - body := []byte(`{"error":"track not found"}`) - - _, err := extractQobuzDownloadURLFromBody(body) - if err == nil || err.Error() != "track not found" { - t.Fatalf("expected track-not-found error, got %v", err) - } - }) - - t.Run("returns message when success false", func(t *testing.T) { - body := []byte(`{"success":false,"message":"blocked"}`) - - _, err := extractQobuzDownloadURLFromBody(body) - if err == nil || err.Error() != "blocked" { - t.Fatalf("expected blocked error, got %v", err) - } - }) - - t.Run("returns detail error", func(t *testing.T) { - body := []byte(`{"detail":"Invalid quality 'lossless'. Choose from: ['mp3', 'cd', 'hi-res', 'hi-res-max']"}`) - - _, err := extractQobuzDownloadURLFromBody(body) - if err == nil || err.Error() != "Invalid quality 'lossless'. Choose from: ['mp3', 'cd', 'hi-res', 'hi-res-max']" { - t.Fatalf("expected detail error, got %v", err) - } - }) -} - -func TestNormalizeQobuzQualityCode(t *testing.T) { - tests := map[string]string{ - "": "6", - "5": "6", - "6": "6", - "cd": "6", - "lossless": "6", - "7": "7", - "hi-res": "7", - "27": "27", - "hi-res-max": "27", - "unexpected": "6", - } - - for input, want := range tests { - if got := normalizeQobuzQualityCode(input); got != want { - t.Fatalf("normalizeQobuzQualityCode(%q) = %q, want %q", input, got, want) - } - } -} - -func TestBuildQobuzMusicDLPayloadUsesOpenTrackURL(t *testing.T) { - payloadBytes, err := buildQobuzMusicDLPayload(374610875, "7") - if err != nil { - t.Fatalf("buildQobuzMusicDLPayload returned error: %v", err) - } - - var payload map[string]any - if err := json.Unmarshal(payloadBytes, &payload); err != nil { - t.Fatalf("payload is not valid JSON: %v", err) - } - - if got := payload["url"]; got != "https://open.qobuz.com/track/374610875" { - t.Fatalf("payload url = %v, want open.qobuz.com track URL", got) - } - if got := payload["quality"]; got != "hi-res" { - t.Fatalf("payload quality = %v, want hi-res", got) - } - if got := payload["upload_to_r2"]; got != false { - t.Fatalf("payload upload_to_r2 = %v, want false", got) - } -} - -func TestExtractQobuzAlbumIDsFromArtistHTML(t *testing.T) { - body := []byte(` - - - - `) - - got := extractQobuzAlbumIDsFromArtistHTML(body) - if len(got) != 2 { - t.Fatalf("expected 2 unique album IDs, got %d (%v)", len(got), got) - } - if got[0] != "0886446451985" || got[1] != "pvv406bth40ya" { - t.Fatalf("unexpected album IDs: %v", got) - } -} - -func TestQobuzAvailableProviders(t *testing.T) { - providers := NewQobuzDownloader().GetAvailableProviders() - if len(providers) != 6 { - t.Fatalf("expected 6 Qobuz providers, got %d", len(providers)) - } - - want := map[string]string{ - "musicdl": qobuzAPIKindMusicDL, - "zarz": qobuzAPIKindMusicDL, - "dabmusic": qobuzAPIKindStandard, - "deeb": qobuzAPIKindStandard, - "qbz": qobuzAPIKindStandard, - "squid": qobuzAPIKindStandard, - } - - for _, provider := range providers { - wantKind, ok := want[provider.Name] - if !ok { - t.Fatalf("unexpected provider %q", provider.Name) - } - if provider.Kind != wantKind { - t.Fatalf("provider %q has kind %q, want %q", provider.Name, provider.Kind, wantKind) - } - delete(want, provider.Name) - } - - if len(want) != 0 { - t.Fatalf("missing providers: %v", want) - } -} - -func testQobuzTrack(id int64, title, artist string, duration int) *QobuzTrack { - track := &QobuzTrack{ - ID: id, - Title: title, - Duration: duration, - } - track.Performer.Name = artist - return track -} - -func TestSelectQobuzTracksFromAlbumSearchResultsPrefersMatchingTrack(t *testing.T) { - summaries := []qobuzAlbumDetails{ - {ID: "album-a"}, - {ID: "album-b"}, - } - - match := *testQobuzTrack(1, "Get Lucky", "Daft Punk", 369) - other := *testQobuzTrack(2, "Fragments of Time", "Daft Punk", 280) - fallback := *testQobuzTrack(3, "Da Funk", "Daft Punk", 330) - - albums := map[string]*qobuzAlbumDetails{ - "album-a": buildTestQobuzAlbum("album-a", "Random Access Memories", "Daft Punk", match, other), - "album-b": buildTestQobuzAlbum("album-b", "Homework", "Daft Punk", fallback), - } - - tracks, err := selectQobuzTracksFromAlbumSearchResults( - "daft punk get lucky", - 3, - summaries, - func(id string) (*qobuzAlbumDetails, error) { return albums[id], nil }, - ) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if len(tracks) == 0 { - t.Fatal("expected tracks, got none") - } - if tracks[0].ID != 1 { - t.Fatalf("expected Get Lucky to rank first, got track id %d", tracks[0].ID) - } -} - -func TestSelectQobuzTracksFromAlbumSearchResultsDedupesTracks(t *testing.T) { - summaries := []qobuzAlbumDetails{ - {ID: "album-a"}, - {ID: "album-b"}, - } - - shared := *testQobuzTrack(42, "Get Lucky", "Daft Punk", 369) - - albums := map[string]*qobuzAlbumDetails{ - "album-a": buildTestQobuzAlbum("album-a", "Random Access Memories", "Daft Punk", shared), - "album-b": buildTestQobuzAlbum("album-b", "Random Access Memories Deluxe", "Daft Punk", shared), - } - - tracks, err := selectQobuzTracksFromAlbumSearchResults( - "daft punk get lucky", - 5, - summaries, - func(id string) (*qobuzAlbumDetails, error) { return albums[id], nil }, - ) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if len(tracks) != 1 { - t.Fatalf("expected 1 deduped track, got %d", len(tracks)) - } - if tracks[0].ID != 42 { - t.Fatalf("unexpected deduped track id: %d", tracks[0].ID) - } -} - -func TestResolveQobuzTrackForRequestRejectsSongLinkMismatch(t *testing.T) { - origGetTrackByID := qobuzGetTrackByIDFunc - origSearchISRC := qobuzSearchTrackByISRCWithDurationFunc - origSearchMetadata := qobuzSearchTrackByMetadataWithDurationFunc - origSongLinkCheck := songLinkCheckTrackAvailabilityFunc - t.Cleanup(func() { - qobuzGetTrackByIDFunc = origGetTrackByID - qobuzSearchTrackByISRCWithDurationFunc = origSearchISRC - qobuzSearchTrackByMetadataWithDurationFunc = origSearchMetadata - songLinkCheckTrackAvailabilityFunc = origSongLinkCheck - GetTrackIDCache().Clear() - }) - GetTrackIDCache().Clear() - - qobuzGetTrackByIDFunc = func(_ *QobuzDownloader, trackID int64) (*QobuzTrack, error) { - if trackID != 111 { - t.Fatalf("unexpected track ID lookup: %d", trackID) - } - return testQobuzTrack(111, "Aperture", "Harry Styles", 180), nil - } - qobuzSearchTrackByISRCWithDurationFunc = func(_ *QobuzDownloader, isrc string, expectedDurationSec int) (*QobuzTrack, error) { - if isrc != "TESTISRC1" { - t.Fatalf("unexpected ISRC lookup: %q", isrc) - } - if expectedDurationSec != 180 { - t.Fatalf("unexpected duration: %d", expectedDurationSec) - } - return testQobuzTrack(222, "Taste Back", "Harry Styles", 180), nil - } - qobuzSearchTrackByMetadataWithDurationFunc = func(_ *QobuzDownloader, _, _ string, _ int) (*QobuzTrack, error) { - t.Fatal("metadata fallback should not run when ISRC fallback succeeds") - return nil, nil - } - songLinkCheckTrackAvailabilityFunc = func(_ *SongLinkClient, spotifyTrackID string, isrc string) (*TrackAvailability, error) { - if spotifyTrackID != "spotify-track-id" { - t.Fatalf("unexpected spotify ID: %q", spotifyTrackID) - } - if isrc != "TESTISRC1" { - t.Fatalf("unexpected SongLink ISRC: %q", isrc) - } - return &TrackAvailability{QobuzID: "111"}, nil - } - - req := DownloadRequest{ - ISRC: "TESTISRC1", - SpotifyID: "spotify-track-id", - TrackName: "Taste Back", - ArtistName: "Harry Styles", - DurationMS: 180000, - } - - track, err := resolveQobuzTrackForRequest(req, &QobuzDownloader{}, "Test") - if err != nil { - t.Fatalf("expected no error, got %v", err) - } - if track == nil || track.ID != 222 || track.Title != "Taste Back" { - t.Fatalf("unexpected resolved track: %+v", track) - } - - cached := GetTrackIDCache().Get(req.ISRC) - if cached == nil || cached.QobuzTrackID != 222 { - t.Fatalf("expected validated fallback track to be cached, got %+v", cached) - } -} - -func TestResolveQobuzTrackForRequestRejectsOdesliMismatch(t *testing.T) { - origGetTrackByID := qobuzGetTrackByIDFunc - origSearchISRC := qobuzSearchTrackByISRCWithDurationFunc - origSearchMetadata := qobuzSearchTrackByMetadataWithDurationFunc - origSongLinkCheck := songLinkCheckTrackAvailabilityFunc - t.Cleanup(func() { - qobuzGetTrackByIDFunc = origGetTrackByID - qobuzSearchTrackByISRCWithDurationFunc = origSearchISRC - qobuzSearchTrackByMetadataWithDurationFunc = origSearchMetadata - songLinkCheckTrackAvailabilityFunc = origSongLinkCheck - }) - - qobuzGetTrackByIDFunc = func(_ *QobuzDownloader, trackID int64) (*QobuzTrack, error) { - if trackID != 333 { - t.Fatalf("unexpected track ID lookup: %d", trackID) - } - return testQobuzTrack(333, "American Girls", "Harry Styles", 181), nil - } - qobuzSearchTrackByISRCWithDurationFunc = func(_ *QobuzDownloader, _ string, _ int) (*QobuzTrack, error) { - t.Fatal("ISRC fallback should not run without an ISRC") - return nil, nil - } - qobuzSearchTrackByMetadataWithDurationFunc = func(_ *QobuzDownloader, _, _ string, _ int) (*QobuzTrack, error) { - t.Fatal("metadata fallback should not run") - return nil, nil - } - songLinkCheckTrackAvailabilityFunc = func(_ *SongLinkClient, _, _ string) (*TrackAvailability, error) { - t.Fatal("SongLink should not run when Odesli QobuzID is provided") - return nil, nil - } - - req := DownloadRequest{ - QobuzID: "333", - TrackName: "Taste Back", - ArtistName: "Harry Styles", - DurationMS: 181000, - } - - track, err := resolveQobuzTrackForRequest(req, &QobuzDownloader{}, "Test") - if err == nil { - t.Fatalf("expected error, got track %+v", track) - } - if track != nil { - t.Fatalf("expected nil track, got %+v", track) - } -} - -func TestResolveQobuzTrackForRequestUsesPrefixedQobuzIDWithoutSongLink(t *testing.T) { - origGetTrackByID := qobuzGetTrackByIDFunc - origSearchISRC := qobuzSearchTrackByISRCWithDurationFunc - origSearchMetadata := qobuzSearchTrackByMetadataWithDurationFunc - origSongLinkCheck := songLinkCheckTrackAvailabilityFunc - t.Cleanup(func() { - qobuzGetTrackByIDFunc = origGetTrackByID - qobuzSearchTrackByISRCWithDurationFunc = origSearchISRC - qobuzSearchTrackByMetadataWithDurationFunc = origSearchMetadata - songLinkCheckTrackAvailabilityFunc = origSongLinkCheck - }) - - qobuzGetTrackByIDFunc = func(_ *QobuzDownloader, trackID int64) (*QobuzTrack, error) { - if trackID != 40681594 { - t.Fatalf("unexpected track ID lookup: %d", trackID) - } - return testQobuzTrack(40681594, "Sign of the Times", "Harry Styles", 341), nil - } - qobuzSearchTrackByISRCWithDurationFunc = func(_ *QobuzDownloader, _ string, _ int) (*QobuzTrack, error) { - t.Fatal("ISRC fallback should not run when request qobuz id succeeds") - return nil, nil - } - qobuzSearchTrackByMetadataWithDurationFunc = func(_ *QobuzDownloader, _, _ string, _ int) (*QobuzTrack, error) { - t.Fatal("metadata fallback should not run when request qobuz id succeeds") - return nil, nil - } - songLinkCheckTrackAvailabilityFunc = func(_ *SongLinkClient, _, _ string) (*TrackAvailability, error) { - t.Fatal("SongLink should not run when request qobuz id is provided") - return nil, nil - } - - req := DownloadRequest{ - QobuzID: "qobuz:40681594", - TrackName: "Sign of the Times", - ArtistName: "Harry Styles", - DurationMS: 341000, - } - - track, err := resolveQobuzTrackForRequest(req, &QobuzDownloader{}, "Test") - if err != nil { - t.Fatalf("expected no error, got %v", err) - } - if track == nil || track.ID != 40681594 { - t.Fatalf("unexpected resolved track: %+v", track) - } -} - -func TestQobuzTrackMatchesRequest_SongLinkBypassesArtistAndTitle(t *testing.T) { - req := DownloadRequest{ - TrackName: "RingiÅ”pil", - ArtistName: "Djordje Balasevic", - } - - track := &QobuzTrack{ - Title: "Different Title", - Duration: 0, - } - track.Performer.Name = "Different Artist" - - if !qobuzTrackMatchesRequest(req, track, "Qobuz", "SongLink Qobuz ID", true) { - t.Fatal("expected SongLink Qobuz source to bypass artist/title verification") - } -} - -func TestQobuzTrackMetadataIncludesComposer(t *testing.T) { - track := &QobuzTrack{ - ID: 40681594, - Title: "Sign of the Times", - ISRC: "USSM11703595", - Duration: 340, - TrackNumber: 1, - MediaNumber: 1, - } - track.Performer.ID = 729886 - track.Performer.Name = "Harry Styles" - track.Composer.ID = 729886 - track.Composer.Name = "Harry Styles" - track.Album.ID = "0886446451985" - track.Album.Title = "Harry Styles" - track.Album.ReleaseDate = "2017-05-12" - track.Album.TracksCount = 10 - track.Album.ReleaseType = "album" - track.Album.ProductType = "album" - track.Album.Artist.ID = 729886 - track.Album.Artist.Name = "Harry Styles" - track.Album.Artists = []qobuzArtistRef{{ID: 729886, Name: "Harry Styles"}} - - trackMeta := qobuzTrackToTrackMetadata(track) - if trackMeta.Composer != "Harry Styles" { - t.Fatalf("track composer = %q", trackMeta.Composer) - } - - albumTrackMeta := qobuzTrackToAlbumTrackMetadata(track) - if albumTrackMeta.Composer != "Harry Styles" { - t.Fatalf("album track composer = %q", albumTrackMeta.Composer) - } -} diff --git a/go_backend/title_match_utils_test.go b/go_backend/title_match_utils_test.go index cfbf59e9..74c46f90 100644 --- a/go_backend/title_match_utils_test.go +++ b/go_backend/title_match_utils_test.go @@ -69,18 +69,3 @@ func TestTitlesMatch_EmojiStrict(t *testing.T) { t.Fatal("expected identical emoji titles to match") } } - -func TestQobuzTitlesMatch_SeparatorVariants(t *testing.T) { - if !qobuzTitlesMatch("Doctor / Cops", "Doctor _ Cops") { - t.Fatal("expected qobuzTitlesMatch to accept / vs _ variant") - } -} - -func TestQobuzTitlesMatch_EmojiStrict(t *testing.T) { - if qobuzTitlesMatch("🪐", "Higher Power") { - t.Fatal("expected emoji title not to match unrelated textual title") - } - if !qobuzTitlesMatch("🪐", "🪐") { - t.Fatal("expected identical emoji titles to match") - } -} diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index d7b0fde6..117a56a2 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -376,10 +376,10 @@ abstract class AppLocalizations { /// **'Choose which tab opens first for new search results.'** String get optionsDefaultSearchTabSubtitle; - /// Hint to switch back to built-in providers + /// Hint to switch away from the current provider selection /// /// In en, this message translates to: - /// **'Tap Deezer or Spotify to switch back from extension'** + /// **'Choose another provider below to stop using the current extension'** String get optionsSwitchBack; /// Auto-retry with other services @@ -406,10 +406,10 @@ abstract class AppLocalizations { /// **'Extensions will be tried first'** String get optionsUseExtensionProvidersOn; - /// Status when extension providers disabled + /// Status when extension providers are disabled /// /// In en, this message translates to: - /// **'Using built-in providers only'** + /// **'Extensions are turned off'** String get optionsUseExtensionProvidersOff; /// Embed lyrics in audio files @@ -5073,16 +5073,16 @@ abstract class AppLocalizations { /// **'Off: strict HTTPS certificate validation (recommended)'** String get downloadNetworkCompatibilityModeDisabled; - /// Hint shown instead of Ask-quality subtitle when no built-in service selected + /// Hint shown instead of Ask-quality subtitle when the selected provider does not expose built-in quality controls /// /// In en, this message translates to: - /// **'Select a built-in service to enable'** + /// **'Select a compatible download provider to enable quality options'** String get downloadSelectServiceToEnable; - /// Info hint when non-Tidal/Qobuz service is selected + /// Info hint when the selected provider does not expose built-in quality controls /// /// In en, this message translates to: - /// **'Select Tidal or Qobuz above to configure quality'** + /// **'Select a compatible provider above to configure quality'** String get downloadSelectTidalQobuz; /// Subtitle for Embed Lyrics when Embed Metadata is disabled diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index fe074715..e84b17f2 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -2952,11 +2952,11 @@ class AppLocalizationsDe extends AppLocalizations { @override String get downloadSelectServiceToEnable => - 'Select a built-in service to enable'; + 'Select a compatible download provider to enable quality options'; @override String get downloadSelectTidalQobuz => - 'Select Tidal or Qobuz above to configure quality'; + 'Select a compatible provider above to configure quality'; @override String get downloadEmbedLyricsDisabled => diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 6beff188..4969a1e8 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -143,7 +143,7 @@ class AppLocalizationsEn extends AppLocalizations { @override String get optionsSwitchBack => - 'Tap Deezer or Spotify to switch back from extension'; + 'Choose another provider below to stop using the current extension'; @override String get optionsAutoFallback => 'Auto Fallback'; @@ -159,7 +159,7 @@ class AppLocalizationsEn extends AppLocalizations { String get optionsUseExtensionProvidersOn => 'Extensions will be tried first'; @override - String get optionsUseExtensionProvidersOff => 'Using built-in providers only'; + String get optionsUseExtensionProvidersOff => 'Extensions are turned off'; @override String get optionsEmbedLyrics => 'Embed Lyrics'; @@ -2920,11 +2920,11 @@ class AppLocalizationsEn extends AppLocalizations { @override String get downloadSelectServiceToEnable => - 'Select a built-in service to enable'; + 'Select a compatible download provider to enable quality options'; @override String get downloadSelectTidalQobuz => - 'Select Tidal or Qobuz above to configure quality'; + 'Select a compatible provider above to configure quality'; @override String get downloadEmbedLyricsDisabled => diff --git a/lib/l10n/app_localizations_es.dart b/lib/l10n/app_localizations_es.dart index ffbe0335..39c6229d 100644 --- a/lib/l10n/app_localizations_es.dart +++ b/lib/l10n/app_localizations_es.dart @@ -2920,11 +2920,11 @@ class AppLocalizationsEs extends AppLocalizations { @override String get downloadSelectServiceToEnable => - 'Select a built-in service to enable'; + 'Select a compatible download provider to enable quality options'; @override String get downloadSelectTidalQobuz => - 'Select Tidal or Qobuz above to configure quality'; + 'Select a compatible provider above to configure quality'; @override String get downloadEmbedLyricsDisabled => diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart index f8ebcf9d..8f51a8c5 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -2921,11 +2921,11 @@ class AppLocalizationsFr extends AppLocalizations { @override String get downloadSelectServiceToEnable => - 'Select a built-in service to enable'; + 'Select a compatible download provider to enable quality options'; @override String get downloadSelectTidalQobuz => - 'Select Tidal or Qobuz above to configure quality'; + 'Select a compatible provider above to configure quality'; @override String get downloadEmbedLyricsDisabled => diff --git a/lib/l10n/app_localizations_hi.dart b/lib/l10n/app_localizations_hi.dart index 0d22f888..041b487a 100644 --- a/lib/l10n/app_localizations_hi.dart +++ b/lib/l10n/app_localizations_hi.dart @@ -2919,11 +2919,11 @@ class AppLocalizationsHi extends AppLocalizations { @override String get downloadSelectServiceToEnable => - 'Select a built-in service to enable'; + 'Select a compatible download provider to enable quality options'; @override String get downloadSelectTidalQobuz => - 'Select Tidal or Qobuz above to configure quality'; + 'Select a compatible provider above to configure quality'; @override String get downloadEmbedLyricsDisabled => diff --git a/lib/l10n/app_localizations_id.dart b/lib/l10n/app_localizations_id.dart index 903b6169..a1bdb8e5 100644 --- a/lib/l10n/app_localizations_id.dart +++ b/lib/l10n/app_localizations_id.dart @@ -145,7 +145,7 @@ class AppLocalizationsId extends AppLocalizations { @override String get optionsSwitchBack => - 'Ketuk Deezer atau Spotify untuk beralih dari ekstensi'; + 'Pilih provider lain di bawah untuk berhenti memakai ekstensi saat ini'; @override String get optionsAutoFallback => 'Cadangan Otomatis'; @@ -162,8 +162,7 @@ class AppLocalizationsId extends AppLocalizations { 'Ekstensi akan dicoba terlebih dahulu'; @override - String get optionsUseExtensionProvidersOff => - 'Hanya menggunakan provider bawaan'; + String get optionsUseExtensionProvidersOff => 'Ekstensi dimatikan'; @override String get optionsEmbedLyrics => 'Sematkan Lirik'; @@ -2930,11 +2929,11 @@ class AppLocalizationsId extends AppLocalizations { @override String get downloadSelectServiceToEnable => - 'Select a built-in service to enable'; + 'Select a compatible download provider to enable quality options'; @override String get downloadSelectTidalQobuz => - 'Select Tidal or Qobuz above to configure quality'; + 'Select a compatible provider above to configure quality'; @override String get downloadEmbedLyricsDisabled => diff --git a/lib/l10n/app_localizations_ja.dart b/lib/l10n/app_localizations_ja.dart index 388d62a1..4a4968fd 100644 --- a/lib/l10n/app_localizations_ja.dart +++ b/lib/l10n/app_localizations_ja.dart @@ -142,8 +142,7 @@ class AppLocalizationsJa extends AppLocalizations { 'Choose which tab opens first for new search results.'; @override - String get optionsSwitchBack => - 'Tap Deezer or Spotify to switch back from extension'; + String get optionsSwitchBack => 'ē¾åœØć®ę‹”å¼µć®ä½æē”Øć‚’ć‚„ć‚ć‚‹ć«ćÆć€äø‹ć‹ć‚‰åˆ„ć®ćƒ—ćƒ­ćƒć‚¤ćƒ€ćƒ¼ć‚’éøęŠžć—ć¦ćć ć•ć„'; @override String get optionsAutoFallback => 'Auto Fallback'; @@ -159,7 +158,7 @@ class AppLocalizationsJa extends AppLocalizations { String get optionsUseExtensionProvidersOn => 'ęœ€åˆć«ę‹”å¼µć§č©¦ćæć¾ć™'; @override - String get optionsUseExtensionProvidersOff => 'å†…č”µć®ćƒ—ćƒ­ćƒć‚¤ćƒ€ćƒ¼ć®ćæć‚’ä½æē”Øć™ć‚‹'; + String get optionsUseExtensionProvidersOff => 'ę‹”å¼µćÆć‚Ŗćƒ•ć§ć™'; @override String get optionsEmbedLyrics => 'ę­Œč©žć‚’åŸ‹ć‚č¾¼ć‚€'; @@ -2906,11 +2905,11 @@ class AppLocalizationsJa extends AppLocalizations { @override String get downloadSelectServiceToEnable => - 'Select a built-in service to enable'; + 'Select a compatible download provider to enable quality options'; @override String get downloadSelectTidalQobuz => - 'Select Tidal or Qobuz above to configure quality'; + 'Select a compatible provider above to configure quality'; @override String get downloadEmbedLyricsDisabled => diff --git a/lib/l10n/app_localizations_ko.dart b/lib/l10n/app_localizations_ko.dart index f334880e..bf02d357 100644 --- a/lib/l10n/app_localizations_ko.dart +++ b/lib/l10n/app_localizations_ko.dart @@ -2899,11 +2899,11 @@ class AppLocalizationsKo extends AppLocalizations { @override String get downloadSelectServiceToEnable => - 'Select a built-in service to enable'; + 'Select a compatible download provider to enable quality options'; @override String get downloadSelectTidalQobuz => - 'Select Tidal or Qobuz above to configure quality'; + 'Select a compatible provider above to configure quality'; @override String get downloadEmbedLyricsDisabled => diff --git a/lib/l10n/app_localizations_nl.dart b/lib/l10n/app_localizations_nl.dart index 4e933bc2..f46029a7 100644 --- a/lib/l10n/app_localizations_nl.dart +++ b/lib/l10n/app_localizations_nl.dart @@ -2919,11 +2919,11 @@ class AppLocalizationsNl extends AppLocalizations { @override String get downloadSelectServiceToEnable => - 'Select a built-in service to enable'; + 'Select a compatible download provider to enable quality options'; @override String get downloadSelectTidalQobuz => - 'Select Tidal or Qobuz above to configure quality'; + 'Select a compatible provider above to configure quality'; @override String get downloadEmbedLyricsDisabled => diff --git a/lib/l10n/app_localizations_pt.dart b/lib/l10n/app_localizations_pt.dart index fa1cefb1..8843e67e 100644 --- a/lib/l10n/app_localizations_pt.dart +++ b/lib/l10n/app_localizations_pt.dart @@ -2920,11 +2920,11 @@ class AppLocalizationsPt extends AppLocalizations { @override String get downloadSelectServiceToEnable => - 'Select a built-in service to enable'; + 'Select a compatible download provider to enable quality options'; @override String get downloadSelectTidalQobuz => - 'Select Tidal or Qobuz above to configure quality'; + 'Select a compatible provider above to configure quality'; @override String get downloadEmbedLyricsDisabled => diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index c6165f0b..da480b1f 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -2979,11 +2979,11 @@ class AppLocalizationsRu extends AppLocalizations { @override String get downloadSelectServiceToEnable => - 'Select a built-in service to enable'; + 'Select a compatible download provider to enable quality options'; @override String get downloadSelectTidalQobuz => - 'Select Tidal or Qobuz above to configure quality'; + 'Select a compatible provider above to configure quality'; @override String get downloadEmbedLyricsDisabled => diff --git a/lib/l10n/app_localizations_tr.dart b/lib/l10n/app_localizations_tr.dart index f6437c4f..d4b035f4 100644 --- a/lib/l10n/app_localizations_tr.dart +++ b/lib/l10n/app_localizations_tr.dart @@ -146,7 +146,7 @@ class AppLocalizationsTr extends AppLocalizations { @override String get optionsSwitchBack => - 'Eklentiden Ƨıkıp varsayılana dƶnmek iƧin Deezer veya Spotify\'a dokunun'; + 'GeƧerli eklentiyi bırakmak iƧin aşağıdan başka bir sağlayıcı seƧin'; @override String get optionsAutoFallback => 'Otomatik GeƧiş'; @@ -163,8 +163,7 @@ class AppLocalizationsTr extends AppLocalizations { 'İndirme iƧin ƶnce eklentiler denenecek'; @override - String get optionsUseExtensionProvidersOff => - 'Sadece yerleşik sağlayıcılar kullanılıyor'; + String get optionsUseExtensionProvidersOff => 'Eklentiler kapalı'; @override String get optionsEmbedLyrics => 'Şarkı Sƶzlerini Gƶmer'; @@ -2979,11 +2978,11 @@ class AppLocalizationsTr extends AppLocalizations { @override String get downloadSelectServiceToEnable => - 'SeƧenekleri aƧmak iƧin yerleşik bir sağlayıcı seƧin'; + 'Kalite seƧeneklerini aƧmak iƧin uyumlu bir indirme sağlayıcısı seƧin'; @override String get downloadSelectTidalQobuz => - 'Kaliteyi ayarlamak iƧin lütfen yukarıdan Tidal veya Qobuz seƧin'; + 'Kaliteyi ayarlamak iƧin yukarıdan uyumlu bir sağlayıcı seƧin'; @override String get downloadEmbedLyricsDisabled => diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index 77f65213..283a58c5 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -2920,11 +2920,11 @@ class AppLocalizationsZh extends AppLocalizations { @override String get downloadSelectServiceToEnable => - 'Select a built-in service to enable'; + 'Select a compatible download provider to enable quality options'; @override String get downloadSelectTidalQobuz => - 'Select Tidal or Qobuz above to configure quality'; + 'Select a compatible provider above to configure quality'; @override String get downloadEmbedLyricsDisabled => diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index 817d73cd..7497bd67 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -174,9 +174,9 @@ "@optionsDefaultSearchTabSubtitle": { "description": "Subtitle for the preferred default search tab setting" }, - "optionsSwitchBack": "Tap Deezer or Spotify to switch back from extension", + "optionsSwitchBack": "Choose another provider below to stop using the current extension", "@optionsSwitchBack": { - "description": "Hint to switch back to built-in providers" + "description": "Hint to switch away from the current provider selection" }, "optionsAutoFallback": "Auto Fallback", "@optionsAutoFallback": { @@ -194,9 +194,9 @@ "@optionsUseExtensionProvidersOn": { "description": "Status when extension providers enabled" }, - "optionsUseExtensionProvidersOff": "Using built-in providers only", + "optionsUseExtensionProvidersOff": "Extensions are turned off", "@optionsUseExtensionProvidersOff": { - "description": "Status when extension providers disabled" + "description": "Status when extension providers are disabled" }, "optionsEmbedLyrics": "Embed Lyrics", "@optionsEmbedLyrics": { @@ -3867,14 +3867,14 @@ "@downloadNetworkCompatibilityModeDisabled": { "description": "Subtitle when network compatibility mode is disabled" }, - "downloadSelectServiceToEnable": "Select a built-in service to enable", + "downloadSelectServiceToEnable": "Select a compatible download provider to enable quality options", "@downloadSelectServiceToEnable": { - "description": "Hint shown instead of Ask-quality subtitle when no built-in service selected" + "description": "Hint shown instead of Ask-quality subtitle when the selected provider does not expose built-in quality controls" }, - "downloadSelectTidalQobuz": "Select Tidal or Qobuz above to configure quality", + "downloadSelectTidalQobuz": "Select a compatible provider above to configure quality", "@downloadSelectTidalQobuz": { - "description": "Info hint when non-Tidal/Qobuz service is selected" + "description": "Info hint when the selected provider does not expose built-in quality controls" }, "downloadEmbedLyricsDisabled": "Disabled while Embed Metadata is turned off", "@downloadEmbedLyricsDisabled": { diff --git a/lib/l10n/arb/app_id.arb b/lib/l10n/arb/app_id.arb index e54cd15f..5f677922 100644 --- a/lib/l10n/arb/app_id.arb +++ b/lib/l10n/arb/app_id.arb @@ -158,9 +158,9 @@ "@optionsDefaultSearchTabSubtitle": { "description": "Subtitle for the preferred default search tab setting" }, - "optionsSwitchBack": "Ketuk Deezer atau Spotify untuk beralih dari ekstensi", + "optionsSwitchBack": "Pilih provider lain di bawah untuk berhenti memakai ekstensi saat ini", "@optionsSwitchBack": { - "description": "Hint to switch back to built-in providers" + "description": "Hint to switch away from the current provider selection" }, "optionsAutoFallback": "Cadangan Otomatis", "@optionsAutoFallback": { @@ -178,9 +178,9 @@ "@optionsUseExtensionProvidersOn": { "description": "Status when extension providers enabled" }, - "optionsUseExtensionProvidersOff": "Hanya menggunakan provider bawaan", + "optionsUseExtensionProvidersOff": "Ekstensi dimatikan", "@optionsUseExtensionProvidersOff": { - "description": "Status when extension providers disabled" + "description": "Status when extension providers are disabled" }, "optionsEmbedLyrics": "Sematkan Lirik", "@optionsEmbedLyrics": { diff --git a/lib/l10n/arb/app_ja.arb b/lib/l10n/arb/app_ja.arb index 7dd086a6..579313a1 100644 --- a/lib/l10n/arb/app_ja.arb +++ b/lib/l10n/arb/app_ja.arb @@ -150,9 +150,9 @@ } } }, - "optionsSwitchBack": "Tap Deezer or Spotify to switch back from extension", + "optionsSwitchBack": "ē¾åœØć®ę‹”å¼µć®ä½æē”Øć‚’ć‚„ć‚ć‚‹ć«ćÆć€äø‹ć‹ć‚‰åˆ„ć®ćƒ—ćƒ­ćƒć‚¤ćƒ€ćƒ¼ć‚’éøęŠžć—ć¦ćć ć•ć„", "@optionsSwitchBack": { - "description": "Hint to switch back to built-in providers" + "description": "Hint to switch away from the current provider selection" }, "optionsAutoFallback": "Auto Fallback", "@optionsAutoFallback": { @@ -170,9 +170,9 @@ "@optionsUseExtensionProvidersOn": { "description": "Status when extension providers enabled" }, - "optionsUseExtensionProvidersOff": "å†…č”µć®ćƒ—ćƒ­ćƒć‚¤ćƒ€ćƒ¼ć®ćæć‚’ä½æē”Øć™ć‚‹", + "optionsUseExtensionProvidersOff": "ę‹”å¼µćÆć‚Ŗćƒ•ć§ć™", "@optionsUseExtensionProvidersOff": { - "description": "Status when extension providers disabled" + "description": "Status when extension providers are disabled" }, "optionsEmbedLyrics": "ę­Œč©žć‚’åŸ‹ć‚č¾¼ć‚€", "@optionsEmbedLyrics": { @@ -3198,4 +3198,4 @@ "@downloadUseAlbumArtistForFoldersTrackSubtitle": { "description": "Subtitle when Track Artist is used for folder naming" } -} \ No newline at end of file +} diff --git a/lib/l10n/arb/app_tr.arb b/lib/l10n/arb/app_tr.arb index b00e4fa2..3c360eca 100644 --- a/lib/l10n/arb/app_tr.arb +++ b/lib/l10n/arb/app_tr.arb @@ -150,9 +150,9 @@ } } }, - "optionsSwitchBack": "Eklentiden Ƨıkıp varsayılana dƶnmek iƧin Deezer veya Spotify'a dokunun", + "optionsSwitchBack": "GeƧerli eklentiyi bırakmak iƧin aşağıdan başka bir sağlayıcı seƧin", "@optionsSwitchBack": { - "description": "Hint to switch back to built-in providers" + "description": "Hint to switch away from the current provider selection" }, "optionsAutoFallback": "Otomatik GeƧiş", "@optionsAutoFallback": { @@ -170,9 +170,9 @@ "@optionsUseExtensionProvidersOn": { "description": "Status when extension providers enabled" }, - "optionsUseExtensionProvidersOff": "Sadece yerleşik sağlayıcılar kullanılıyor", + "optionsUseExtensionProvidersOff": "Eklentiler kapalı", "@optionsUseExtensionProvidersOff": { - "description": "Status when extension providers disabled" + "description": "Status when extension providers are disabled" }, "optionsEmbedLyrics": "Şarkı Sƶzlerini Gƶmer", "@optionsEmbedLyrics": { @@ -3689,13 +3689,13 @@ "@downloadNetworkCompatibilityModeDisabled": { "description": "Subtitle when network compatibility mode is disabled" }, - "downloadSelectServiceToEnable": "SeƧenekleri aƧmak iƧin yerleşik bir sağlayıcı seƧin", + "downloadSelectServiceToEnable": "Kalite seƧeneklerini aƧmak iƧin uyumlu bir indirme sağlayıcısı seƧin", "@downloadSelectServiceToEnable": { - "description": "Hint shown instead of Ask-quality subtitle when no built-in service selected" + "description": "Hint shown instead of Ask-quality subtitle when the selected provider does not expose built-in quality controls" }, - "downloadSelectTidalQobuz": "Kaliteyi ayarlamak iƧin lütfen yukarıdan Tidal veya Qobuz seƧin", + "downloadSelectTidalQobuz": "Kaliteyi ayarlamak iƧin yukarıdan uyumlu bir sağlayıcı seƧin", "@downloadSelectTidalQobuz": { - "description": "Info hint when non-Tidal/Qobuz service is selected" + "description": "Info hint when the selected provider does not expose built-in quality controls" }, "downloadEmbedLyricsDisabled": "Şarkı Verilerini Dosyaya Gƶmme ayarı kapalıyken kullanılamaz", "@downloadEmbedLyricsDisabled": { diff --git a/lib/providers/download_queue_provider.dart b/lib/providers/download_queue_provider.dart index d795fa9b..dde1706b 100644 --- a/lib/providers/download_queue_provider.dart +++ b/lib/providers/download_queue_provider.dart @@ -4815,7 +4815,7 @@ class DownloadQueueNotifier extends Notifier { String payloadTidalId = ''; if (trackToDownload.id.startsWith('qobuz:')) { payloadQobuzId = trackToDownload.id.substring(6); - if (item.service == 'qobuz') { + if (_usesBuiltInCompatibleDownloadProvider(item.service, 'qobuz')) { payloadSpotifyId = ''; } } diff --git a/lib/providers/extension_provider.dart b/lib/providers/extension_provider.dart index a38126a5..b55a062b 100644 --- a/lib/providers/extension_provider.dart +++ b/lib/providers/extension_provider.dart @@ -288,6 +288,85 @@ List get builtInDownloadProviderIds => List.unmodifiable( builtInDownloadProviderSpecs.map((provider) => provider.id), ); +String resolveEffectiveDownloadService( + String requestedService, + ExtensionState extensionState, +) { + final normalizedRequested = requestedService.trim().toLowerCase(); + final builtInDownloadIds = extensionState.builtInProviders + .where((provider) => provider.supportsDownload) + .map((provider) => provider.id.trim().toLowerCase()) + .where((providerId) => providerId.isNotEmpty) + .toSet(); + final enabledDownloadExtensions = extensionState.extensions + .where((ext) => ext.enabled && ext.hasDownloadProvider) + .toList(growable: false); + + if (normalizedRequested.isNotEmpty) { + if (builtInDownloadIds.contains(normalizedRequested)) { + return normalizedRequested; + } + + final matchingExtension = enabledDownloadExtensions + .where((ext) => ext.id.trim().toLowerCase() == normalizedRequested) + .firstOrNull; + if (matchingExtension != null) { + return matchingExtension.id; + } + + final replacementExtension = enabledDownloadExtensions + .where( + (ext) => ext.replacesBuiltInProviders.contains(normalizedRequested), + ) + .firstOrNull; + if (replacementExtension != null) { + return replacementExtension.id; + } + } + + const preferredBuiltInOrder = ['tidal', 'qobuz', 'deezer']; + for (final builtInId in preferredBuiltInOrder) { + final replacement = enabledDownloadExtensions + .where((ext) => ext.replacesBuiltInProviders.contains(builtInId)) + .firstOrNull; + if (replacement != null) { + return replacement.id; + } + if (builtInDownloadIds.contains(builtInId)) { + return builtInId; + } + } + + return enabledDownloadExtensions.firstOrNull?.id ?? + extensionState.builtInProviders + .where((provider) => provider.supportsDownload) + .map((provider) => provider.id) + .firstOrNull ?? + ''; +} + +bool isDeezerCompatibleDownloadService( + String service, + ExtensionState extensionState, +) { + final normalizedService = service.trim().toLowerCase(); + if (normalizedService.isEmpty) { + return false; + } + + if (normalizedService == 'deezer') { + return true; + } + + return extensionState.extensions.any( + (ext) => + ext.enabled && + ext.hasDownloadProvider && + ext.id.trim().toLowerCase() == normalizedService && + ext.replacesBuiltInProviders.contains('deezer'), + ); +} + bool isBuiltInSearchProvider(String? providerId) => builtInProviderSpecForId(providerId)?.supportsSearch ?? false; diff --git a/lib/providers/track_provider.dart b/lib/providers/track_provider.dart index 2f961a13..33aba515 100644 --- a/lib/providers/track_provider.dart +++ b/lib/providers/track_provider.dart @@ -599,7 +599,6 @@ class TrackNotifier extends Notifier { if (effectiveBuiltInProvider == null || effectiveBuiltInProvider.isEmpty) { state = TrackState( isLoading: false, - error: 'No active search provider available', hasSearchText: state.hasSearchText, isShowingRecentAccess: state.isShowingRecentAccess, selectedSearchFilter: currentFilter, diff --git a/lib/screens/album_screen.dart b/lib/screens/album_screen.dart index dca498ab..f7c81ff5 100644 --- a/lib/screens/album_screen.dart +++ b/lib/screens/album_screen.dart @@ -5,6 +5,7 @@ import 'package:spotiflac_android/services/cover_cache_manager.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/models/track.dart'; import 'package:spotiflac_android/providers/download_queue_provider.dart'; +import 'package:spotiflac_android/providers/extension_provider.dart'; import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/providers/recent_access_provider.dart'; import 'package:spotiflac_android/providers/local_library_provider.dart'; @@ -644,9 +645,20 @@ class _AlbumScreenState extends ConsumerState { }, ); } else { + final extensionState = ref.read(extensionProvider); + final service = resolveEffectiveDownloadService( + settings.defaultService, + extensionState, + ); + if (service.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.extensionsNoDownloadProvider)), + ); + return; + } ref .read(downloadQueueProvider.notifier) - .addToQueue(track, settings.defaultService); + .addToQueue(track, service); ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(context.l10n.snackbarAddedToQueue(track.name))), ); @@ -716,9 +728,20 @@ class _AlbumScreenState extends ConsumerState { }, ); } else { + final extensionState = ref.read(extensionProvider); + final service = resolveEffectiveDownloadService( + settings.defaultService, + extensionState, + ); + if (service.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.extensionsNoDownloadProvider)), + ); + return; + } ref .read(downloadQueueProvider.notifier) - .addMultipleToQueue(tracksToQueue, settings.defaultService); + .addMultipleToQueue(tracksToQueue, service); _showQueuedSnackbar(context, tracksToQueue.length, skippedCount); } } diff --git a/lib/screens/artist_screen.dart b/lib/screens/artist_screen.dart index 9db86fb6..11312961 100644 --- a/lib/screens/artist_screen.dart +++ b/lib/screens/artist_screen.dart @@ -6,6 +6,7 @@ import 'package:intl/intl.dart'; import 'package:spotiflac_android/services/cover_cache_manager.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/models/track.dart'; +import 'package:spotiflac_android/providers/extension_provider.dart'; import 'package:spotiflac_android/providers/track_provider.dart'; import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/providers/download_queue_provider.dart'; @@ -1627,7 +1628,18 @@ class _ArtistScreenState extends ConsumerState { return; } - enqueue(settings.defaultService); + final extensionState = ref.read(extensionProvider); + final service = resolveEffectiveDownloadService( + settings.defaultService, + extensionState, + ); + if (service.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.extensionsNoDownloadProvider)), + ); + return; + } + enqueue(service); } Widget _buildAlbumSection( diff --git a/lib/screens/home_tab.dart b/lib/screens/home_tab.dart index f5c6f69b..97505f6f 100644 --- a/lib/screens/home_tab.dart +++ b/lib/screens/home_tab.dart @@ -1004,9 +1004,20 @@ class _HomeTabState extends ConsumerState }, ); } else { + final extensionState = ref.read(extensionProvider); + final service = resolveEffectiveDownloadService( + settings.defaultService, + extensionState, + ); + if (service.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.extensionsNoDownloadProvider)), + ); + return; + } ref .read(downloadQueueProvider.notifier) - .addToQueue(track, settings.defaultService); + .addToQueue(track, service); ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text(context.l10n.snackbarAddedToQueue(track.name)), @@ -1227,9 +1238,24 @@ class _HomeTabState extends ConsumerState }, ); } else { + final extensionState = ref.read(extensionProvider); + final service = resolveEffectiveDownloadService( + settings.defaultService, + extensionState, + ); + if (service.isEmpty) { + if (mounted) { + ScaffoldMessenger.of(this.context).showSnackBar( + SnackBar( + content: Text(this.context.l10n.extensionsNoDownloadProvider), + ), + ); + } + return; + } ref .read(downloadQueueProvider.notifier) - .addMultipleToQueue(tracksToQueue, settings.defaultService); + .addMultipleToQueue(tracksToQueue, service); if (mounted) { ScaffoldMessenger.of(this.context).showSnackBar( SnackBar( @@ -2139,9 +2165,20 @@ class _HomeTabState extends ConsumerState }, ); } else { + final extensionState = ref.read(extensionProvider); + final service = resolveEffectiveDownloadService( + settings.defaultService, + extensionState, + ); + if (service.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.extensionsNoDownloadProvider)), + ); + return; + } ref .read(downloadQueueProvider.notifier) - .addToQueue(track, settings.defaultService); + .addToQueue(track, service); ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(context.l10n.snackbarAddedToQueue(track.name))), ); diff --git a/lib/screens/library_tracks_folder_screen.dart b/lib/screens/library_tracks_folder_screen.dart index 549b77f9..743cde96 100644 --- a/lib/screens/library_tracks_folder_screen.dart +++ b/lib/screens/library_tracks_folder_screen.dart @@ -8,6 +8,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/models/track.dart'; import 'package:spotiflac_android/providers/download_queue_provider.dart'; +import 'package:spotiflac_android/providers/extension_provider.dart'; import 'package:spotiflac_android/providers/library_collections_provider.dart'; import 'package:spotiflac_android/providers/playback_provider.dart'; import 'package:spotiflac_android/providers/local_library_provider.dart'; @@ -214,12 +215,23 @@ class _LibraryTracksFolderScreenState void _downloadSelected(List entries) { final settings = ref.read(settingsProvider); + final extensionState = ref.read(extensionProvider); + final service = resolveEffectiveDownloadService( + settings.defaultService, + extensionState, + ); + if (service.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.extensionsNoDownloadProvider)), + ); + return; + } final queueNotifier = ref.read(downloadQueueProvider.notifier); var count = 0; for (final entry in entries) { if (!_selectedKeys.contains(entry.key)) continue; - queueNotifier.addToQueue(entry.track, settings.defaultService); + queueNotifier.addToQueue(entry.track, service); count++; } @@ -1349,9 +1361,20 @@ class _CollectionTrackTile extends ConsumerWidget { }, ); } else { + final extensionState = ref.read(extensionProvider); + final service = resolveEffectiveDownloadService( + settings.defaultService, + extensionState, + ); + if (service.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.extensionsNoDownloadProvider)), + ); + return; + } ref .read(downloadQueueProvider.notifier) - .addToQueue(track, settings.defaultService); + .addToQueue(track, service); ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(context.l10n.snackbarAddedToQueue(track.name))), ); diff --git a/lib/screens/local_album_screen.dart b/lib/screens/local_album_screen.dart index 7af6654a..d1de3b89 100644 --- a/lib/screens/local_album_screen.dart +++ b/lib/screens/local_album_screen.dart @@ -965,10 +965,18 @@ class _LocalAlbumScreenState extends ConsumerState { ); final targetService = LocalTrackRedownloadService.preferredFlacService( settings, + extensionState, ); + if (targetService.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.extensionsNoDownloadProvider)), + ); + return; + } final targetQuality = LocalTrackRedownloadService.preferredFlacQualityForService( targetService, + extensionState, ); final matchedTracks = []; diff --git a/lib/screens/playlist_screen.dart b/lib/screens/playlist_screen.dart index 32afe598..b4305ac1 100644 --- a/lib/screens/playlist_screen.dart +++ b/lib/screens/playlist_screen.dart @@ -6,6 +6,7 @@ import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/models/track.dart'; import 'package:spotiflac_android/providers/download_queue_provider.dart'; +import 'package:spotiflac_android/providers/extension_provider.dart'; import 'package:spotiflac_android/providers/library_collections_provider.dart'; import 'package:spotiflac_android/utils/file_access.dart'; import 'package:spotiflac_android/utils/image_cache_utils.dart'; @@ -495,11 +496,22 @@ class _PlaylistScreenState extends ConsumerState { }, ); } else { + final extensionState = ref.read(extensionProvider); + final service = resolveEffectiveDownloadService( + settings.defaultService, + extensionState, + ); + if (service.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.extensionsNoDownloadProvider)), + ); + return; + } ref .read(downloadQueueProvider.notifier) .addToQueue( track, - settings.defaultService, + service, playlistName: _playlistName, ); ScaffoldMessenger.of(context).showSnackBar( diff --git a/lib/screens/queue_tab.dart b/lib/screens/queue_tab.dart index 7286af9c..4cb4a3ef 100644 --- a/lib/screens/queue_tab.dart +++ b/lib/screens/queue_tab.dart @@ -1845,10 +1845,24 @@ class _QueueTabState extends ConsumerState { if (confirmed != true || !context.mounted) return; final settings = ref.read(settingsProvider); + final extensionState = ref.read(extensionProvider); final queueNotifier = ref.read(downloadQueueProvider.notifier); void enqueueAll({String? qualityOverride, String? service}) { - final svc = service ?? settings.defaultService; + final svc = + service ?? + resolveEffectiveDownloadService( + settings.defaultService, + extensionState, + ); + if (svc.isEmpty) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.extensionsNoDownloadProvider)), + ); + } + return; + } for (final playlist in selectedPlaylists) { final tracks = playlist.tracks.map((e) => e.track).toList(); queueNotifier.addMultipleToQueue( @@ -5218,10 +5232,18 @@ class _QueueTabState extends ConsumerState { ); final targetService = LocalTrackRedownloadService.preferredFlacService( settings, + extensionState, ); + if (targetService.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.extensionsNoDownloadProvider)), + ); + return; + } final targetQuality = LocalTrackRedownloadService.preferredFlacQualityForService( targetService, + extensionState, ); final matchedTracks = []; diff --git a/lib/screens/search_screen.dart b/lib/screens/search_screen.dart index 7ff7fd7b..3ab33731 100644 --- a/lib/screens/search_screen.dart +++ b/lib/screens/search_screen.dart @@ -4,6 +4,7 @@ import 'package:cached_network_image/cached_network_image.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/services/cover_cache_manager.dart'; import 'package:spotiflac_android/models/track.dart'; +import 'package:spotiflac_android/providers/extension_provider.dart'; import 'package:spotiflac_android/providers/track_provider.dart'; import 'package:spotiflac_android/providers/download_queue_provider.dart'; import 'package:spotiflac_android/providers/settings_provider.dart'; @@ -49,9 +50,20 @@ class _SearchScreenState extends ConsumerState { void _downloadTrack(Track track) { final settings = ref.read(settingsProvider); + final extensionState = ref.read(extensionProvider); + final service = resolveEffectiveDownloadService( + settings.defaultService, + extensionState, + ); + if (service.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.extensionsNoDownloadProvider)), + ); + return; + } ref .read(downloadQueueProvider.notifier) - .addToQueue(track, settings.defaultService); + .addToQueue(track, service); ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(context.l10n.snackbarAddedToQueue(track.name))), ); diff --git a/lib/services/local_track_redownload_service.dart b/lib/services/local_track_redownload_service.dart index 763a784f..c75a26f6 100644 --- a/lib/services/local_track_redownload_service.dart +++ b/lib/services/local_track_redownload_service.dart @@ -1,5 +1,6 @@ import 'package:spotiflac_android/models/settings.dart'; import 'package:spotiflac_android/models/track.dart'; +import 'package:spotiflac_android/providers/extension_provider.dart'; import 'package:spotiflac_android/services/library_database.dart'; import 'package:spotiflac_android/services/platform_bridge.dart'; @@ -101,19 +102,26 @@ class LocalTrackRedownloadService { ); } - static String preferredFlacService(AppSettings settings) { - switch (settings.defaultService.toLowerCase()) { - case 'tidal': - case 'qobuz': - case 'deezer': - return settings.defaultService.toLowerCase(); - default: - return ''; - } + static String preferredFlacService( + AppSettings settings, + ExtensionState extensionState, + ) { + return resolveEffectiveDownloadService( + settings.defaultService, + extensionState, + ); } - static String preferredFlacQualityForService(String service) { - return service.toLowerCase() == 'deezer' ? 'FLAC' : 'LOSSLESS'; + static String preferredFlacQualityForService( + String service, + ExtensionState extensionState, + ) { + if (service.trim().isEmpty) { + return 'LOSSLESS'; + } + return isDeezerCompatibleDownloadService(service, extensionState) + ? 'FLAC' + : 'LOSSLESS'; } static String _buildSearchQuery(LocalLibraryItem item) {