diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b7c758b..ecf8a8cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,26 @@ # Changelog +## [3.7.1] - 2026-03-06 + +### Added + +- **Smarter YouTube Downloads**: If the YouTube Music extension is installed, the app now uses it first to find the correct song — more accurate than SongLink, especially for new releases. +- **Songs-Only Search Filter**: YouTube Music extension search now filters results server-side, so you only get actual songs — no music videos or covers mixed in. +- **Qobuz Search Fallback**: If Qobuz API search returns nothing, the app now tries the Qobuz web store as a backup to find the track. +- **Better ISRC Lookup**: Tracks can now be resolved via ISRC even without a Spotify ID, using Deezer as an intermediary. + +### Fixed + +- **Download Queue Stability**: Fixed duplicate queue item IDs, cancel not working reliably, and "Clear All" not properly stopping active downloads. +- **Queue Restore on Restart**: Duplicate or broken queue item IDs are now auto-fixed when the app restarts. + +### Changed + +- **Update Checker**: The app can now detect updates across all versions, not just within the same major version. +- **Localization Cleanup**: Cleaned up and consolidated translation files across all 13 supported languages. + +--- + ## [3.7.0] - 2026-03-04 Hey everyone, thank you so much for sticking with SpotiFLAC Mobile. diff --git a/go_backend/qobuz.go b/go_backend/qobuz.go index 52653172..015dca8d 100644 --- a/go_backend/qobuz.go +++ b/go_backend/qobuz.go @@ -12,6 +12,8 @@ import ( "net/url" "os" "path/filepath" + "regexp" + "strconv" "strings" "sync" "time" @@ -31,13 +33,17 @@ var ( const ( qobuzTrackGetBaseURL = "https://www.qobuz.com/api.json/0.2/track/get?track_id=" qobuzTrackSearchBaseURL = "https://www.qobuz.com/api.json/0.2/track/search?query=" + qobuzStoreSearchBaseURL = "https://www.qobuz.com/us-en/search/tracks/" qobuzTrackPlayBaseURL = "https://play.qobuz.com/track/" qobuzDownloadAPIURL = "https://www.musicdl.me/api/qobuz/download" qobuzDabMusicAPIURL = "https://dabmusic.xyz/api/stream?trackId=" qobuzDeebAPIURL = "https://dab.yeet.su/api/stream?trackId=" + qobuzSquidAPIURL = "https://qobuz.squid.wtf/api/download-music?country=US&track_id=" qobuzDebugKeyXORMask = byte(0x5A) ) +var qobuzStoreTrackIDRegex = regexp.MustCompile(`/v4/ajax/popin-add-cart/track/([0-9]+)`) + var qobuzDebugKeyObfuscated = []byte{ 0x69, 0x3b, 0x38, 0x3e, 0x36, 0x37, 0x35, 0x2f, 0x36, 0x3b, 0x33, 0x29, 0x2e, 0x32, 0x3f, 0x3d, 0x35, 0x3b, 0x2e, 0x3b, @@ -403,6 +409,7 @@ func (q *QobuzDownloader) GetAvailableProviders() []qobuzAPIProvider { {Name: "dabmusic", URL: qobuzDabMusicAPIURL, Kind: qobuzAPIKindStandard}, // "deeb" is mapped from the legacy reference fallback endpoint. {Name: "deeb", URL: qobuzDeebAPIURL, Kind: qobuzAPIKindStandard}, + {Name: "squid", URL: qobuzSquidAPIURL, Kind: qobuzAPIKindStandard}, } } @@ -560,39 +567,18 @@ func getQobuzDebugKey() string { } func (q *QobuzDownloader) SearchTrackByISRC(isrc string) (*QobuzTrack, error) { - searchURL := fmt.Sprintf("%s%s&limit=50&app_id=%s", qobuzTrackSearchBaseURL, url.QueryEscape(isrc), q.appID) - - req, err := http.NewRequest("GET", searchURL, nil) + candidates, err := q.searchQobuzTracksWithFallback(isrc, 50) 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("search failed: HTTP %d", resp.StatusCode) - } - - var result struct { - Tracks struct { - Items []QobuzTrack `json:"items"` - } `json:"tracks"` - } - if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { - return nil, err - } - - for i := range result.Tracks.Items { - if result.Tracks.Items[i].ISRC == isrc { - return &result.Tracks.Items[i], nil + for i := range candidates { + if candidates[i].ISRC == isrc { + return &candidates[i], nil } } - if len(result.Tracks.Items) == 0 { + if len(candidates) == 0 { return nil, fmt.Errorf("no tracks found for ISRC: %s", isrc) } @@ -602,38 +588,17 @@ func (q *QobuzDownloader) SearchTrackByISRC(isrc string) (*QobuzTrack, error) { func (q *QobuzDownloader) SearchTrackByISRCWithDuration(isrc string, expectedDurationSec int) (*QobuzTrack, error) { GoLog("[Qobuz] Searching by ISRC: %s\n", isrc) - searchURL := fmt.Sprintf("%s%s&limit=50&app_id=%s", qobuzTrackSearchBaseURL, url.QueryEscape(isrc), q.appID) - - req, err := http.NewRequest("GET", searchURL, nil) + candidates, err := q.searchQobuzTracksWithFallback(isrc, 50) 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("search failed: HTTP %d", resp.StatusCode) - } - - var result struct { - Tracks struct { - Items []QobuzTrack `json:"items"` - } `json:"tracks"` - } - if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { - return nil, err - } - - GoLog("[Qobuz] ISRC search returned %d results\n", len(result.Tracks.Items)) + GoLog("[Qobuz] ISRC search returned %d results\n", len(candidates)) var isrcMatches []*QobuzTrack - for i := range result.Tracks.Items { - if result.Tracks.Items[i].ISRC == isrc { - isrcMatches = append(isrcMatches, &result.Tracks.Items[i]) + for i := range candidates { + if candidates[i].ISRC == isrc { + isrcMatches = append(isrcMatches, &candidates[i]) } } @@ -668,7 +633,7 @@ func (q *QobuzDownloader) SearchTrackByISRCWithDuration(isrc string, expectedDur return isrcMatches[0], nil } - if len(result.Tracks.Items) == 0 { + if len(candidates) == 0 { return nil, fmt.Errorf("no tracks found for ISRC: %s", isrc) } @@ -725,6 +690,7 @@ func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistNam var allTracks []QobuzTrack searchedQueries := make(map[string]bool) + seenTrackIDs := make(map[int64]struct{}) for _, query := range queries { cleanQuery := strings.TrimSpace(query) @@ -735,38 +701,26 @@ func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistNam GoLog("[Qobuz] Searching for: %s\n", cleanQuery) - searchURL := fmt.Sprintf("%s%s&limit=50&app_id=%s", qobuzTrackSearchBaseURL, url.QueryEscape(cleanQuery), q.appID) - - req, err := http.NewRequest("GET", searchURL, nil) - if err != nil { - continue - } - - resp, err := DoRequestWithUserAgent(q.client, req) + result, err := q.searchQobuzTracksWithFallback(cleanQuery, 50) if err != nil { GoLog("[Qobuz] Search error for '%s': %v\n", cleanQuery, err) continue } - if resp.StatusCode != 200 { - resp.Body.Close() - continue - } - - var result struct { - Tracks struct { - Items []QobuzTrack `json:"items"` - } `json:"tracks"` - } - if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { - resp.Body.Close() - continue - } - resp.Body.Close() - - if len(result.Tracks.Items) > 0 { - GoLog("[Qobuz] Found %d results for '%s'\n", len(result.Tracks.Items), cleanQuery) - allTracks = append(allTracks, result.Tracks.Items...) + 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]) + } } } @@ -837,6 +791,131 @@ func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistNam return nil, fmt.Errorf("no matching track found for: %s - %s", artistName, trackName) } +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 { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + return nil, fmt.Errorf("search failed: HTTP %d", resp.StatusCode) + } + + 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 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 store fallback\n", query) + } else { + GoLog("[Qobuz] API search failed for '%s': %v. Trying store fallback.\n", query, apiErr) + } + + 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 && storeErr != nil { + return nil, fmt.Errorf("api search failed (%v); store fallback failed (%v)", apiErr, storeErr) + } + if storeErr != nil { + return nil, storeErr + } + return nil, fmt.Errorf("no tracks found for query: %s", query) +} + type qobuzAPIResult struct { provider qobuzAPIProvider info qobuzDownloadInfo diff --git a/go_backend/songlink.go b/go_backend/songlink.go index 975573df..b5dfd58d 100644 --- a/go_backend/songlink.go +++ b/go_backend/songlink.go @@ -1,6 +1,7 @@ package gobackend import ( + "context" "encoding/json" "fmt" "net/http" @@ -36,6 +37,12 @@ var ( songLinkClientOnce sync.Once songLinkRegion = "US" songLinkRegionMu sync.RWMutex + songLinkSearchByISRC = func(ctx context.Context, isrc string) (*TrackMetadata, error) { + return GetDeezerClient().SearchByISRC(ctx, isrc) + } + songLinkCheckAvailabilityFromDeezer = func(s *SongLinkClient, deezerTrackID string) (*TrackAvailability, error) { + return s.CheckAvailabilityFromDeezer(deezerTrackID) + } ) func NewSongLinkClient() *SongLinkClient { @@ -109,6 +116,20 @@ func buildSongLinkURLByPlatform(platform, entityType, entityID, userCountry stri } func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc string) (*TrackAvailability, error) { + spotifyTrackID = strings.TrimSpace(spotifyTrackID) + isrc = strings.ToUpper(strings.TrimSpace(isrc)) + + switch { + case spotifyTrackID != "": + return s.checkTrackAvailabilityFromSpotify(spotifyTrackID) + case isrc != "": + return s.checkTrackAvailabilityFromISRC(isrc) + default: + return nil, fmt.Errorf("spotify track ID and ISRC are empty") + } +} + +func (s *SongLinkClient) checkTrackAvailabilityFromSpotify(spotifyTrackID string) (*TrackAvailability, error) { songLinkRateLimiter.WaitForSlot() spotifyURL := fmt.Sprintf("https://open.spotify.com/track/%s", spotifyTrackID) @@ -200,6 +221,47 @@ func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc stri return availability, nil } +func (s *SongLinkClient) checkTrackAvailabilityFromISRC(isrc string) (*TrackAvailability, error) { + ctx, cancel := context.WithTimeout(context.Background(), SongLinkTimeout) + defer cancel() + + track, err := songLinkSearchByISRC(ctx, isrc) + if err != nil { + return nil, fmt.Errorf("failed to resolve Deezer track from ISRC %s: %w", isrc, err) + } + + deezerTrackID := songLinkExtractDeezerTrackID(track) + if deezerTrackID == "" { + return nil, fmt.Errorf("failed to resolve Deezer track ID from ISRC %s", isrc) + } + + availability, err := songLinkCheckAvailabilityFromDeezer(s, deezerTrackID) + if err != nil { + return nil, fmt.Errorf("failed to resolve SongLink availability from ISRC %s via Deezer %s: %w", isrc, deezerTrackID, err) + } + + return availability, nil +} + +func songLinkExtractDeezerTrackID(track *TrackMetadata) string { + if track == nil { + return "" + } + + if deezerID, ok := strings.CutPrefix(strings.TrimSpace(track.SpotifyID), "deezer:"); ok { + deezerID = strings.TrimSpace(deezerID) + if deezerID != "" { + return deezerID + } + } + + if deezerID := extractDeezerIDFromURL(strings.TrimSpace(track.ExternalURL)); deezerID != "" { + return deezerID + } + + return "" +} + func (s *SongLinkClient) GetStreamingURLs(spotifyTrackID string) (map[string]string, error) { availability, err := s.CheckTrackAvailability(spotifyTrackID, "") if err != nil { diff --git a/go_backend/youtube.go b/go_backend/youtube.go index fdbadc63..bfbedcbb 100644 --- a/go_backend/youtube.go +++ b/go_backend/youtube.go @@ -539,12 +539,65 @@ func ExtractYouTubeVideoID(urlStr string) (string, error) { return "", fmt.Errorf("could not extract video ID from URL") } +// searchYouTubeMusicViaExtension uses the YT Music extension's customSearch +// to find a track by artist + title. It filters for tracks only (not videos, +// albums, or playlists) and returns the YouTube Music watch URL for the first +// matching track, or "" if nothing was found. +func searchYouTubeMusicViaExtension(artistName, trackName string) string { + extManager := GetExtensionManager() + searchProviders := extManager.GetSearchProviders() + + // Find the ytmusic-spotiflac extension + var ytProvider *ExtensionProviderWrapper + for _, p := range searchProviders { + if p.extension.ID == "ytmusic-spotiflac" { + ytProvider = p + break + } + } + if ytProvider == nil { + GoLog("[YouTube] YT Music extension not found or not enabled, skipping fallback\n") + return "" + } + + query := strings.TrimSpace(artistName + " " + trackName) + if query == "" { + return "" + } + + GoLog("[YouTube] Searching YT Music extension for: %s\n", query) + results, err := ytProvider.CustomSearch(query, map[string]interface{}{ + "filter": "tracks", + }) + if err != nil { + GoLog("[YouTube] YT Music extension search failed: %v\n", err) + return "" + } + + // Find the first track result (item_type == "track" with a valid video ID) + for _, track := range results { + if track.ItemType != "" && track.ItemType != "track" { + continue + } + videoID := strings.TrimSpace(track.ID) + if videoID == "" { + continue + } + if isYouTubeVideoID(videoID) { + return BuildYouTubeWatchURL(videoID) + } + } + + GoLog("[YouTube] YT Music extension returned no matching tracks for: %s\n", query) + return "" +} + func downloadFromYouTube(req DownloadRequest) (YouTubeDownloadResult, error) { downloader := NewYouTubeDownloader() format, bitrate, quality := parseYouTubeQualityInput(req.Quality) - // URL lookup priority: YouTube video ID > Spotify ID > Deezer ID > ISRC + // URL lookup priority: YouTube video ID > YT Music extension > SongLink (Spotify/Deezer/ISRC) var youtubeURL string var lookupErr error @@ -554,7 +607,15 @@ func downloadFromYouTube(req DownloadRequest) (YouTubeDownloadResult, error) { GoLog("[YouTube] SpotifyID appears to be YouTube video ID, using directly: %s\n", youtubeURL) } - // Try Spotify ID via SongLink + // Try YT Music extension search first (if installed) - more accurate, tracks only + if youtubeURL == "" && (req.TrackName != "" || req.ArtistName != "") { + youtubeURL = searchYouTubeMusicViaExtension(req.ArtistName, req.TrackName) + if youtubeURL != "" { + GoLog("[YouTube] Found YouTube URL via YT Music extension: %s\n", youtubeURL) + } + } + + // Fallback: Try Spotify ID via SongLink if youtubeURL == "" && req.SpotifyID != "" && !isYouTubeVideoID(req.SpotifyID) { GoLog("[YouTube] Looking up YouTube URL via SongLink for Spotify ID: %s\n", req.SpotifyID) songlink := NewSongLinkClient() @@ -566,7 +627,7 @@ func downloadFromYouTube(req DownloadRequest) (YouTubeDownloadResult, error) { } } - // Try Deezer ID via SongLink + // Fallback: Try Deezer ID via SongLink if youtubeURL == "" && req.DeezerID != "" { GoLog("[YouTube] Looking up YouTube URL via SongLink for Deezer ID: %s\n", req.DeezerID) songlink := NewSongLinkClient() @@ -578,7 +639,7 @@ func downloadFromYouTube(req DownloadRequest) (YouTubeDownloadResult, error) { } } - // Try ISRC via SongLink + // Fallback: Try ISRC via SongLink if youtubeURL == "" && req.ISRC != "" { GoLog("[YouTube] Looking up YouTube URL via SongLink for ISRC: %s\n", req.ISRC) songlink := NewSongLinkClient() diff --git a/lib/constants/app_info.dart b/lib/constants/app_info.dart index e786ec11..48a1c1e2 100644 --- a/lib/constants/app_info.dart +++ b/lib/constants/app_info.dart @@ -1,8 +1,8 @@ /// App version and info constants /// Update version here only - all other files will reference this class AppInfo { - static const String version = '3.7.0'; - static const String buildNumber = '103'; + static const String version = '3.7.1'; + static const String buildNumber = '104'; static const String fullVersion = '$version+$buildNumber'; diff --git a/lib/services/update_checker.dart b/lib/services/update_checker.dart index bc9335c4..3a2fb50f 100644 --- a/lib/services/update_checker.dart +++ b/lib/services/update_checker.dart @@ -74,13 +74,6 @@ class UpdateChecker { return null; } - // Ignore releases from a different major version (e.g. v4.x when we - // rolled back to v3.x). Only offer updates within the same major line. - if (_majorVersion(latestVersion) != _majorVersion(AppInfo.version)) { - _log.i('Skipping update from different major version (current: ${AppInfo.version}, latest: $latestVersion)'); - return null; - } - final body = releaseData['body'] as String? ?? 'No changelog available'; final htmlUrl = releaseData['html_url'] as String? ?? '${AppInfo.githubUrl}/releases'; final publishedAt = DateTime.tryParse(releaseData['published_at'] as String? ?? '') ?? DateTime.now(); @@ -125,14 +118,6 @@ class UpdateChecker { } } - static int _majorVersion(String version) { - try { - return int.parse(version.split('-').first.split('.').first); - } catch (_) { - return -1; - } - } - static bool _isNewerVersion(String latest, String current) { try { final latestBase = latest.split('-').first; diff --git a/pubspec.yaml b/pubspec.yaml index e109209f..ff4f6473 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: spotiflac_android description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music publish_to: "none" -version: 3.7.0+103 +version: 3.7.1+104 environment: sdk: ^3.10.0