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.
This commit is contained in:
zarzet
2026-04-18 23:32:16 +07:00
parent 8f2ca33e87
commit 803cd2de96
37 changed files with 331 additions and 3794 deletions
+3 -1
View File
@@ -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")
}
-64
View File
@@ -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
-21
View File
@@ -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
+2 -150
View File
@@ -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
}
-2896
View File
File diff suppressed because it is too large Load Diff
-553
View File
@@ -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(`
<div class="product__item">
<button data-itemtype="album" data-itemId="yrpbt0lwm3g0y"></button>
</div>
<div class="product__item">
<button data-itemtype="album" data-itemId="yrpbt0lwm3g0y"></button>
</div>
<div class="product__item">
<button data-itemtype="album" data-itemId="0886446451985"></button>
</div>
`)
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(`
<button data-itemtype="album" data-itemId="0886446451985"></button>
<button data-itemtype="album" data-itemId="0886446451985"></button>
<button data-itemtype="album" data-itemId="pvv406bth40ya"></button>
`)
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)
}
}
-15
View File
@@ -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")
}
}
+8 -8
View File
@@ -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
+2 -2
View File
@@ -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 =>
+4 -4
View File
@@ -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 =>
+2 -2
View File
@@ -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 =>
+2 -2
View File
@@ -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 =>
+2 -2
View File
@@ -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 =>
+4 -5
View File
@@ -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 =>
+4 -5
View File
@@ -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 =>
+2 -2
View File
@@ -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 =>
+2 -2
View File
@@ -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 =>
+2 -2
View File
@@ -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 =>
+2 -2
View File
@@ -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 =>
+4 -5
View File
@@ -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 =>
+2 -2
View File
@@ -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 =>
+8 -8
View File
@@ -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": {
+4 -4
View File
@@ -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": {
+5 -5
View File
@@ -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"
}
}
}
+8 -8
View File
@@ -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": {
+1 -1
View File
@@ -4815,7 +4815,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
String payloadTidalId = '';
if (trackToDownload.id.startsWith('qobuz:')) {
payloadQobuzId = trackToDownload.id.substring(6);
if (item.service == 'qobuz') {
if (_usesBuiltInCompatibleDownloadProvider(item.service, 'qobuz')) {
payloadSpotifyId = '';
}
}
+79
View File
@@ -288,6 +288,85 @@ List<String> get builtInDownloadProviderIds => List<String>.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;
-1
View File
@@ -599,7 +599,6 @@ class TrackNotifier extends Notifier<TrackState> {
if (effectiveBuiltInProvider == null || effectiveBuiltInProvider.isEmpty) {
state = TrackState(
isLoading: false,
error: 'No active search provider available',
hasSearchText: state.hasSearchText,
isShowingRecentAccess: state.isShowingRecentAccess,
selectedSearchFilter: currentFilter,
+25 -2
View File
@@ -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<AlbumScreen> {
},
);
} 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<AlbumScreen> {
},
);
} 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);
}
}
+13 -1
View File
@@ -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<ArtistScreen> {
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(
+40 -3
View File
@@ -1004,9 +1004,20 @@ class _HomeTabState extends ConsumerState<HomeTab>
},
);
} 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<HomeTab>
},
);
} 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<HomeTab>
},
);
} 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))),
);
+25 -2
View File
@@ -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<CollectionTrackEntry> 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))),
);
+8
View File
@@ -965,10 +965,18 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
);
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 = <Track>[];
+13 -1
View File
@@ -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<PlaylistScreen> {
},
);
} 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(
+23 -1
View File
@@ -1845,10 +1845,24 @@ class _QueueTabState extends ConsumerState<QueueTab> {
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<QueueTab> {
);
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 = <Track>[];
+13 -1
View File
@@ -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<SearchScreen> {
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))),
);
@@ -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) {