v3.7.1: YT Music extension priority for YouTube downloads, Qobuz store fallback, queue fixes, server-side search filters

This commit is contained in:
zarzet
2026-03-06 16:37:01 +07:00
parent 3a7b777717
commit f306599ab2
7 changed files with 310 additions and 102 deletions
+21
View File
@@ -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.
+159 -80
View File
@@ -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
+62
View File
@@ -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 {
+65 -4
View File
@@ -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()
+2 -2
View File
@@ -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';
-15
View File
@@ -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;
+1 -1
View File
@@ -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