mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-07-03 19:27:57 +02:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 36a646e5c0 | |||
| f306599ab2 | |||
| 3a7b777717 | |||
| 2334e659ad | |||
| 2a0216c87a |
@@ -1,5 +1,28 @@
|
||||
# Changelog
|
||||
|
||||
## [3.7.1] - 2026-03-06
|
||||
|
||||
### Added
|
||||
|
||||
- **Deezer Download Service**: Deezer is now available as a built-in download service (FLAC CD Quality).
|
||||
- **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 Squid.wtf Fallback**: Added Squid.wtf as an additional Qobuz download provider.
|
||||
- **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.
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
)
|
||||
|
||||
const deezerYoinkifyURL = "https://yoinkify.lol/api/download"
|
||||
const deezerMusicDLURL = "https://www.musicdl.me/api/download"
|
||||
|
||||
type YoinkifyRequest struct {
|
||||
URL string `json:"url"`
|
||||
@@ -194,6 +195,195 @@ func (c *DeezerClient) DownloadFromYoinkify(spotifyURL, outputPath string, outpu
|
||||
return nil
|
||||
}
|
||||
|
||||
func resolveDeezerTrackURL(req DownloadRequest) (string, error) {
|
||||
deezerID := strings.TrimSpace(req.DeezerID)
|
||||
if deezerID == "" {
|
||||
if prefixed, found := strings.CutPrefix(strings.TrimSpace(req.SpotifyID), "deezer:"); found {
|
||||
deezerID = strings.TrimSpace(prefixed)
|
||||
}
|
||||
}
|
||||
if deezerID != "" {
|
||||
return fmt.Sprintf("https://www.deezer.com/track/%s", deezerID), nil
|
||||
}
|
||||
|
||||
// Try resolving Deezer ID from Spotify ID via SongLink
|
||||
spotifyID := strings.TrimSpace(req.SpotifyID)
|
||||
if spotifyID != "" && isLikelySpotifyTrackID(spotifyID) {
|
||||
songlink := NewSongLinkClient()
|
||||
availability, err := songlink.CheckTrackAvailability(spotifyID, "")
|
||||
if err == nil && availability.Deezer && availability.DeezerURL != "" {
|
||||
return availability.DeezerURL, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Try resolving from ISRC
|
||||
isrc := strings.TrimSpace(req.ISRC)
|
||||
if isrc != "" {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), SongLinkTimeout)
|
||||
defer cancel()
|
||||
track, err := GetDeezerClient().SearchByISRC(ctx, isrc)
|
||||
if err == nil && track != nil {
|
||||
deezerID = songLinkExtractDeezerTrackID(track)
|
||||
if deezerID != "" {
|
||||
return fmt.Sprintf("https://www.deezer.com/track/%s", deezerID), nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("could not resolve Deezer track URL")
|
||||
}
|
||||
|
||||
type deezerMusicDLRequest struct {
|
||||
Platform string `json:"platform"`
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
func (c *DeezerClient) GetMusicDLDownloadURL(deezerTrackURL string) (string, error) {
|
||||
payload := deezerMusicDLRequest{
|
||||
Platform: "deezer",
|
||||
URL: deezerTrackURL,
|
||||
}
|
||||
jsonData, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to encode MusicDL request: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(http.MethodPost, deezerMusicDLURL, bytes.NewReader(jsonData))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create MusicDL request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("X-Debug-Key", getQobuzDebugKey())
|
||||
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("MusicDL request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 64*1024))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read MusicDL response: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("MusicDL returned HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
|
||||
}
|
||||
|
||||
var raw map[string]any
|
||||
if err := json.Unmarshal(body, &raw); err != nil {
|
||||
return "", fmt.Errorf("invalid MusicDL JSON: %w", err)
|
||||
}
|
||||
|
||||
if errMsg, ok := raw["error"].(string); ok && strings.TrimSpace(errMsg) != "" {
|
||||
return "", fmt.Errorf("MusicDL error: %s", errMsg)
|
||||
}
|
||||
|
||||
// Try various response fields for download URL
|
||||
for _, key := range []string{"download_url", "url", "link"} {
|
||||
if urlVal, ok := raw[key].(string); ok && strings.TrimSpace(urlVal) != "" {
|
||||
return strings.TrimSpace(urlVal), nil
|
||||
}
|
||||
}
|
||||
if data, ok := raw["data"].(map[string]any); ok {
|
||||
for _, key := range []string{"download_url", "url", "link"} {
|
||||
if urlVal, ok := data[key].(string); ok && strings.TrimSpace(urlVal) != "" {
|
||||
return strings.TrimSpace(urlVal), nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("no download URL found in MusicDL response")
|
||||
}
|
||||
|
||||
func (c *DeezerClient) DownloadFromMusicDL(deezerTrackURL, outputPath string, outputFD int, itemID string) error {
|
||||
GoLog("[Deezer] Resolving download URL via MusicDL for: %s\n", deezerTrackURL)
|
||||
|
||||
downloadURL, err := c.GetMusicDLDownloadURL(deezerTrackURL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
GoLog("[Deezer] MusicDL returned download URL, starting download...\n")
|
||||
|
||||
ctx := context.Background()
|
||||
if itemID != "" {
|
||||
StartItemProgress(itemID)
|
||||
defer CompleteItemProgress(itemID)
|
||||
ctx = initDownloadCancel(itemID)
|
||||
defer clearDownloadCancel(itemID)
|
||||
}
|
||||
|
||||
if isDownloadCancelled(itemID) {
|
||||
return ErrDownloadCancelled
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", downloadURL, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create download request: %w", err)
|
||||
}
|
||||
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
if isDownloadCancelled(itemID) {
|
||||
return ErrDownloadCancelled
|
||||
}
|
||||
return fmt.Errorf("download request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("download returned HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
expectedSize := resp.ContentLength
|
||||
if expectedSize > 0 && itemID != "" {
|
||||
SetItemBytesTotal(itemID, expectedSize)
|
||||
}
|
||||
|
||||
out, err := openOutputForWrite(outputPath, outputFD)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
bufWriter := bufio.NewWriterSize(out, 256*1024)
|
||||
var written int64
|
||||
if itemID != "" {
|
||||
pw := NewItemProgressWriter(bufWriter, itemID)
|
||||
written, err = io.Copy(pw, resp.Body)
|
||||
} else {
|
||||
written, err = io.Copy(bufWriter, resp.Body)
|
||||
}
|
||||
|
||||
flushErr := bufWriter.Flush()
|
||||
closeErr := out.Close()
|
||||
|
||||
if err != nil {
|
||||
cleanupOutputOnError(outputPath, outputFD)
|
||||
if isDownloadCancelled(itemID) {
|
||||
return ErrDownloadCancelled
|
||||
}
|
||||
return fmt.Errorf("download interrupted: %w", err)
|
||||
}
|
||||
if flushErr != nil {
|
||||
cleanupOutputOnError(outputPath, outputFD)
|
||||
return fmt.Errorf("failed to flush output: %w", flushErr)
|
||||
}
|
||||
if closeErr != nil {
|
||||
cleanupOutputOnError(outputPath, outputFD)
|
||||
return fmt.Errorf("failed to close output: %w", closeErr)
|
||||
}
|
||||
|
||||
if expectedSize > 0 && written != expectedSize {
|
||||
cleanupOutputOnError(outputPath, outputFD)
|
||||
return fmt.Errorf("incomplete download: expected %d bytes, got %d bytes", expectedSize, written)
|
||||
}
|
||||
|
||||
GoLog("[Deezer] Downloaded via MusicDL: %.2f MB\n", float64(written)/(1024*1024))
|
||||
return nil
|
||||
}
|
||||
|
||||
func downloadFromDeezer(req DownloadRequest) (DeezerDownloadResult, error) {
|
||||
deezerClient := GetDeezerClient()
|
||||
isSafOutput := isFDOutput(req.OutputFD) || strings.TrimSpace(req.OutputPath) != ""
|
||||
@@ -254,11 +444,30 @@ func downloadFromDeezer(req DownloadRequest) (DeezerDownloadResult, error) {
|
||||
)
|
||||
}()
|
||||
|
||||
if err := deezerClient.DownloadFromYoinkify(spotifyURL, outputPath, req.OutputFD, req.ItemID); err != nil {
|
||||
if errors.Is(err, ErrDownloadCancelled) {
|
||||
return DeezerDownloadResult{}, ErrDownloadCancelled
|
||||
// Try MusicDL first (better quality), fallback to Yoinkify
|
||||
var downloadErr error
|
||||
deezerTrackURL, deezerURLErr := resolveDeezerTrackURL(req)
|
||||
if deezerURLErr == nil {
|
||||
GoLog("[Deezer] Trying MusicDL for: %s\n", deezerTrackURL)
|
||||
downloadErr = deezerClient.DownloadFromMusicDL(deezerTrackURL, outputPath, req.OutputFD, req.ItemID)
|
||||
if downloadErr != nil {
|
||||
if errors.Is(downloadErr, ErrDownloadCancelled) {
|
||||
return DeezerDownloadResult{}, ErrDownloadCancelled
|
||||
}
|
||||
GoLog("[Deezer] MusicDL failed: %v, falling back to Yoinkify\n", downloadErr)
|
||||
}
|
||||
} else {
|
||||
GoLog("[Deezer] Could not resolve Deezer URL: %v, using Yoinkify directly\n", deezerURLErr)
|
||||
}
|
||||
|
||||
if downloadErr != nil || deezerURLErr != nil {
|
||||
downloadErr = deezerClient.DownloadFromYoinkify(spotifyURL, outputPath, req.OutputFD, req.ItemID)
|
||||
if downloadErr != nil {
|
||||
if errors.Is(downloadErr, ErrDownloadCancelled) {
|
||||
return DeezerDownloadResult{}, ErrDownloadCancelled
|
||||
}
|
||||
return DeezerDownloadResult{}, fmt.Errorf("deezer download failed (MusicDL + Yoinkify): %w", downloadErr)
|
||||
}
|
||||
return DeezerDownloadResult{}, fmt.Errorf("deezer yoinkify failed: %w", err)
|
||||
}
|
||||
|
||||
<-parallelDone
|
||||
|
||||
+159
-80
@@ -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
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
|
||||
+9
-1605
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+14
-1809
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+14
-1806
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+21
-2646
File diff suppressed because it is too large
Load Diff
+18
-1098
File diff suppressed because it is too large
Load Diff
+2147
-1541
File diff suppressed because it is too large
Load Diff
+10
-862
File diff suppressed because it is too large
Load Diff
+18
-1098
File diff suppressed because it is too large
Load Diff
+18
-1098
File diff suppressed because it is too large
Load Diff
+18
-1098
File diff suppressed because it is too large
Load Diff
+2800
-3945
File diff suppressed because it is too large
Load Diff
+18
-1098
File diff suppressed because it is too large
Load Diff
+18
-1098
File diff suppressed because it is too large
Load Diff
+18
-1098
File diff suppressed because it is too large
Load Diff
+10
-862
File diff suppressed because it is too large
Load Diff
+18
-1098
File diff suppressed because it is too large
Load Diff
+18
-1098
File diff suppressed because it is too large
Load Diff
+18
-1098
File diff suppressed because it is too large
Load Diff
+10
-862
File diff suppressed because it is too large
Load Diff
+18
-1098
File diff suppressed because it is too large
Load Diff
+18
-1098
File diff suppressed because it is too large
Load Diff
@@ -625,6 +625,7 @@ final downloadHistoryProvider =
|
||||
);
|
||||
|
||||
class DownloadQueueState {
|
||||
static const Object _noChange = Object();
|
||||
final List<DownloadItem> items;
|
||||
final DownloadItem? currentDownload;
|
||||
final bool isProcessing;
|
||||
@@ -649,7 +650,7 @@ class DownloadQueueState {
|
||||
|
||||
DownloadQueueState copyWith({
|
||||
List<DownloadItem>? items,
|
||||
DownloadItem? currentDownload,
|
||||
Object? currentDownload = _noChange,
|
||||
bool? isProcessing,
|
||||
bool? isPaused,
|
||||
String? outputDir,
|
||||
@@ -660,7 +661,9 @@ class DownloadQueueState {
|
||||
}) {
|
||||
return DownloadQueueState(
|
||||
items: items ?? this.items,
|
||||
currentDownload: currentDownload ?? this.currentDownload,
|
||||
currentDownload: identical(currentDownload, _noChange)
|
||||
? this.currentDownload
|
||||
: currentDownload as DownloadItem?,
|
||||
isProcessing: isProcessing ?? this.isProcessing,
|
||||
isPaused: isPaused ?? this.isPaused,
|
||||
outputDir: outputDir ?? this.outputDir,
|
||||
@@ -717,6 +720,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
int _totalQueuedAtStart = 0;
|
||||
int _completedInSession = 0;
|
||||
int _failedInSession = 0;
|
||||
int _queueItemSequence = 0;
|
||||
bool _isLoaded = false;
|
||||
final Set<String> _ensuredDirs = {};
|
||||
int _progressPollingErrorCount = 0;
|
||||
@@ -735,6 +739,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
String? _lastNotifArtistName;
|
||||
int _lastNotifPercent = -1;
|
||||
int _lastNotifQueueCount = -1;
|
||||
final Set<String> _locallyCancelledItemIds = {};
|
||||
|
||||
double _normalizeProgressForUi(double value) {
|
||||
final clamped = value.clamp(0.0, 1.0).toDouble();
|
||||
@@ -854,8 +859,11 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
return;
|
||||
}
|
||||
|
||||
state = state.copyWith(items: pendingItems);
|
||||
_log.i('Restored ${pendingItems.length} pending items from storage');
|
||||
final normalizedPendingItems = _normalizeRestoredQueueIds(pendingItems);
|
||||
state = state.copyWith(items: normalizedPendingItems);
|
||||
_log.i(
|
||||
'Restored ${normalizedPendingItems.length} pending items from storage',
|
||||
);
|
||||
Future.microtask(() => _processQueue());
|
||||
} catch (e) {
|
||||
_log.e('Failed to load queue from storage: $e');
|
||||
@@ -1644,6 +1652,53 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
return _isrcRegex.hasMatch(value.toUpperCase());
|
||||
}
|
||||
|
||||
String _newQueueItemId(Track track, {Set<String>? takenIds}) {
|
||||
final trimmedIsrc = track.isrc?.trim();
|
||||
final trimmedTrackId = track.id.trim();
|
||||
final base = (trimmedIsrc != null && trimmedIsrc.isNotEmpty)
|
||||
? trimmedIsrc
|
||||
: (trimmedTrackId.isNotEmpty ? trimmedTrackId : 'track');
|
||||
|
||||
while (true) {
|
||||
_queueItemSequence++;
|
||||
final candidate =
|
||||
'$base-${DateTime.now().microsecondsSinceEpoch}-$_queueItemSequence';
|
||||
if (takenIds == null || !takenIds.contains(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
List<DownloadItem> _normalizeRestoredQueueIds(List<DownloadItem> items) {
|
||||
if (items.isEmpty) return items;
|
||||
|
||||
final seen = <String>{};
|
||||
var regeneratedCount = 0;
|
||||
final normalized = <DownloadItem>[];
|
||||
|
||||
for (final item in items) {
|
||||
final trimmedId = item.id.trim();
|
||||
final shouldRegenerate = trimmedId.isEmpty || seen.contains(trimmedId);
|
||||
if (shouldRegenerate) {
|
||||
final newId = _newQueueItemId(item.track, takenIds: seen);
|
||||
seen.add(newId);
|
||||
normalized.add(item.copyWith(id: newId));
|
||||
regeneratedCount++;
|
||||
} else {
|
||||
seen.add(trimmedId);
|
||||
normalized.add(item);
|
||||
}
|
||||
}
|
||||
|
||||
if (regeneratedCount > 0) {
|
||||
_log.w(
|
||||
'Regenerated $regeneratedCount duplicate/empty queue item IDs during restore',
|
||||
);
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
void updateSettings(AppSettings settings) {
|
||||
final concurrentDownloads = settings.concurrentDownloads.clamp(1, 5);
|
||||
state = state.copyWith(
|
||||
@@ -1661,8 +1716,8 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
final settings = ref.read(settingsProvider);
|
||||
updateSettings(settings);
|
||||
|
||||
final id =
|
||||
'${track.isrc ?? track.id}-${DateTime.now().millisecondsSinceEpoch}';
|
||||
final takenIds = state.items.map((item) => item.id).toSet();
|
||||
final id = _newQueueItemId(track, takenIds: takenIds);
|
||||
final item = DownloadItem(
|
||||
id: id,
|
||||
track: track,
|
||||
@@ -1689,9 +1744,10 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
final settings = ref.read(settingsProvider);
|
||||
updateSettings(settings);
|
||||
|
||||
final takenIds = state.items.map((item) => item.id).toSet();
|
||||
final newItems = tracks.map((track) {
|
||||
final id =
|
||||
'${track.isrc ?? track.id}-${DateTime.now().millisecondsSinceEpoch}';
|
||||
final id = _newQueueItemId(track, takenIds: takenIds);
|
||||
takenIds.add(id);
|
||||
return DownloadItem(
|
||||
id: id,
|
||||
track: track,
|
||||
@@ -1770,12 +1826,30 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
);
|
||||
}
|
||||
|
||||
void cancelItem(String id) {
|
||||
updateItemStatus(id, DownloadStatus.skipped);
|
||||
DownloadItem? _findItemById(String id) {
|
||||
for (final item in state.items) {
|
||||
if (item.id == id) return item;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
bool _isLocallyCancelled(String id, {DownloadItem? item}) {
|
||||
if (_locallyCancelledItemIds.contains(id)) return true;
|
||||
final resolved = item ?? _findItemById(id);
|
||||
return resolved?.status == DownloadStatus.skipped;
|
||||
}
|
||||
|
||||
void _requestNativeCancel(String id) {
|
||||
PlatformBridge.cancelDownload(id).catchError((_) {});
|
||||
PlatformBridge.clearItemProgress(id).catchError((_) {});
|
||||
}
|
||||
|
||||
void cancelItem(String id) {
|
||||
_locallyCancelledItemIds.add(id);
|
||||
updateItemStatus(id, DownloadStatus.skipped);
|
||||
_requestNativeCancel(id);
|
||||
}
|
||||
|
||||
void clearCompleted() {
|
||||
final items = state.items
|
||||
.where(
|
||||
@@ -1791,8 +1865,30 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
}
|
||||
|
||||
void clearAll() {
|
||||
state = state.copyWith(items: [], isPaused: false);
|
||||
final wasProcessing = state.isProcessing;
|
||||
final activeIds = state.items
|
||||
.where(
|
||||
(item) =>
|
||||
item.status == DownloadStatus.queued ||
|
||||
item.status == DownloadStatus.downloading ||
|
||||
item.status == DownloadStatus.finalizing,
|
||||
)
|
||||
.map((item) => item.id)
|
||||
.toList(growable: false);
|
||||
|
||||
if (activeIds.isNotEmpty) {
|
||||
_locallyCancelledItemIds.addAll(activeIds);
|
||||
for (final id in activeIds) {
|
||||
_requestNativeCancel(id);
|
||||
}
|
||||
}
|
||||
|
||||
state = state.copyWith(items: [], isPaused: false, currentDownload: null);
|
||||
_notificationService.cancelDownloadNotification();
|
||||
_saveQueueToStorage();
|
||||
if (!wasProcessing) {
|
||||
_locallyCancelledItemIds.clear();
|
||||
}
|
||||
}
|
||||
|
||||
void pauseQueue() {
|
||||
@@ -1835,6 +1931,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
}
|
||||
|
||||
_log.i('Retrying item: ${item.track.name} (id: $id)');
|
||||
_locallyCancelledItemIds.remove(id);
|
||||
|
||||
final items = state.items.map((i) {
|
||||
if (i.id == id) {
|
||||
@@ -1858,6 +1955,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
}
|
||||
|
||||
void removeItem(String id) {
|
||||
_locallyCancelledItemIds.remove(id);
|
||||
final items = state.items.where((item) => item.id != id).toList();
|
||||
state = state.copyWith(items: items);
|
||||
_saveQueueToStorage();
|
||||
@@ -2892,17 +2990,16 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
}
|
||||
|
||||
_stopProgressPolling();
|
||||
final remainingIds = state.items.map((item) => item.id).toSet();
|
||||
_locallyCancelledItemIds.removeWhere((id) => !remainingIds.contains(id));
|
||||
}
|
||||
|
||||
Future<void> _downloadSingleItem(DownloadItem item) async {
|
||||
_log.d('Processing: ${item.track.name} by ${item.track.artistName}');
|
||||
_log.d('Cover URL: ${item.track.coverUrl}');
|
||||
|
||||
final currentItem = state.items.firstWhere(
|
||||
(i) => i.id == item.id,
|
||||
orElse: () => item,
|
||||
);
|
||||
if (currentItem.status == DownloadStatus.skipped) {
|
||||
final currentItem = _findItemById(item.id) ?? item;
|
||||
if (_isLocallyCancelled(item.id, item: currentItem)) {
|
||||
_log.i('Download was cancelled before start, skipping');
|
||||
return;
|
||||
}
|
||||
@@ -3315,6 +3412,11 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
);
|
||||
}
|
||||
|
||||
if (_isLocallyCancelled(item.id)) {
|
||||
_log.i('Download was cancelled before native download start, skipping');
|
||||
return;
|
||||
}
|
||||
|
||||
result = await runDownload(
|
||||
useSaf: effectiveSafMode,
|
||||
outputDir: effectiveOutputDir,
|
||||
@@ -3323,6 +3425,10 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
if (effectiveSafMode &&
|
||||
result['success'] != true &&
|
||||
_isSafWriteFailure(result)) {
|
||||
if (_isLocallyCancelled(item.id)) {
|
||||
_log.i('Download was cancelled before SAF fallback, skipping');
|
||||
return;
|
||||
}
|
||||
_log.w('SAF write failed, retrying with app-private storage');
|
||||
appOutputDir ??= await _buildOutputDir(
|
||||
trackToDownload,
|
||||
@@ -3348,11 +3454,11 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
|
||||
_log.d('Result: $result');
|
||||
|
||||
final currentItem = state.items.firstWhere(
|
||||
(i) => i.id == item.id,
|
||||
orElse: () => item,
|
||||
);
|
||||
if (currentItem.status == DownloadStatus.skipped) {
|
||||
final itemAfterResult = _findItemById(item.id);
|
||||
final cancelledAfterResult =
|
||||
itemAfterResult == null ||
|
||||
_isLocallyCancelled(item.id, item: itemAfterResult);
|
||||
if (cancelledAfterResult) {
|
||||
_log.i('Download was cancelled, skipping result processing');
|
||||
final filePath = result['file_path'] as String?;
|
||||
if (filePath != null && result['success'] == true) {
|
||||
@@ -4083,11 +4189,9 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
}
|
||||
}
|
||||
|
||||
final itemAfterDownload = state.items.firstWhere(
|
||||
(i) => i.id == item.id,
|
||||
orElse: () => item,
|
||||
);
|
||||
if (itemAfterDownload.status == DownloadStatus.skipped) {
|
||||
final itemAfterDownload = _findItemById(item.id);
|
||||
if (itemAfterDownload == null ||
|
||||
_isLocallyCancelled(item.id, item: itemAfterDownload)) {
|
||||
_log.i('Download was cancelled during finalization, cleaning up');
|
||||
if (filePath != null) {
|
||||
await deleteFile(filePath);
|
||||
@@ -4309,11 +4413,9 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
removeItem(item.id);
|
||||
}
|
||||
} else {
|
||||
final itemAfterFailure = state.items.firstWhere(
|
||||
(i) => i.id == item.id,
|
||||
orElse: () => item,
|
||||
);
|
||||
if (itemAfterFailure.status == DownloadStatus.skipped) {
|
||||
final itemAfterFailure = _findItemById(item.id);
|
||||
if (itemAfterFailure == null ||
|
||||
_isLocallyCancelled(item.id, item: itemAfterFailure)) {
|
||||
_log.i('Download was cancelled, skipping error handling');
|
||||
return;
|
||||
}
|
||||
@@ -4374,11 +4476,9 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
}
|
||||
}
|
||||
} catch (e, stackTrace) {
|
||||
final itemAfterError = state.items.firstWhere(
|
||||
(i) => i.id == item.id,
|
||||
orElse: () => item,
|
||||
);
|
||||
if (itemAfterError.status == DownloadStatus.skipped) {
|
||||
final itemAfterError = _findItemById(item.id);
|
||||
if (itemAfterError == null ||
|
||||
_isLocallyCancelled(item.id, item: itemAfterError)) {
|
||||
_log.i('Download was cancelled, skipping error handling');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -811,7 +811,7 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
|
||||
}
|
||||
}
|
||||
|
||||
for (final provider in const ['tidal', 'qobuz', 'amazon']) {
|
||||
for (final provider in const ['tidal', 'qobuz', 'amazon', 'deezer']) {
|
||||
if (!result.contains(provider)) {
|
||||
result.add(provider);
|
||||
}
|
||||
@@ -880,7 +880,7 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
|
||||
}
|
||||
|
||||
List<String> getAllDownloadProviders() {
|
||||
final providers = ['tidal', 'qobuz', 'amazon'];
|
||||
final providers = ['tidal', 'qobuz', 'amazon', 'deezer'];
|
||||
for (final ext in state.extensions) {
|
||||
if (ext.enabled && ext.hasDownloadProvider) {
|
||||
providers.add(ext.id);
|
||||
|
||||
@@ -23,7 +23,7 @@ class DownloadSettingsPage extends ConsumerStatefulWidget {
|
||||
}
|
||||
|
||||
class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
||||
static const _builtInServices = ['tidal', 'qobuz', 'amazon'];
|
||||
static const _builtInServices = ['tidal', 'qobuz', 'amazon', 'deezer'];
|
||||
static const _songLinkRegions = [
|
||||
'AD',
|
||||
'AE',
|
||||
@@ -2039,7 +2039,7 @@ class _ServiceSelector extends ConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final extState = ref.watch(extensionProvider);
|
||||
final builtInServiceIds = ['tidal', 'qobuz', 'amazon', 'youtube'];
|
||||
final builtInServiceIds = ['tidal', 'qobuz', 'amazon', 'deezer', 'youtube'];
|
||||
|
||||
final extensionProviders = extState.extensions
|
||||
.where((e) => e.enabled && e.hasDownloadProvider)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -78,6 +78,17 @@ const _builtInServices = [
|
||||
),
|
||||
],
|
||||
),
|
||||
BuiltInService(
|
||||
id: 'deezer',
|
||||
label: 'Deezer',
|
||||
qualityOptions: [
|
||||
QualityOption(
|
||||
id: 'FLAC',
|
||||
label: 'FLAC Lossless',
|
||||
description: '16-bit / 44.1kHz (CD Quality)',
|
||||
),
|
||||
],
|
||||
),
|
||||
BuiltInService(
|
||||
id: 'youtube',
|
||||
label: 'YouTube',
|
||||
|
||||
+13
-13
@@ -362,34 +362,34 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_local_notifications
|
||||
sha256: "76cd20bcfa72fabe50ea27eeaf165527f446f55d3033021462084b87805b4cac"
|
||||
sha256: "0d9035862236fe38250fe1644d7ed3b8254e34a21b2c837c9f539fbb3bba5ef1"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "20.0.0"
|
||||
version: "21.0.0"
|
||||
flutter_local_notifications_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_local_notifications_linux
|
||||
sha256: dce0116868cedd2cdf768af0365fc37ff1cbef7c02c4f51d0587482e625868d0
|
||||
sha256: e0f25e243c6c44c825bbbc6b2b2e76f7d9222362adcfe9fd780bf01923c840bd
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "7.0.0"
|
||||
version: "8.0.0"
|
||||
flutter_local_notifications_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_local_notifications_platform_interface
|
||||
sha256: "23de31678a48c084169d7ae95866df9de5c9d2a44be3e5915a2ff067aeeba899"
|
||||
sha256: e7db3d5b49c2b7ecc68deba4aaaa67a348f92ee0fef34c8e4b4459dbef0d7307
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "10.0.0"
|
||||
version: "11.0.0"
|
||||
flutter_local_notifications_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_local_notifications_windows
|
||||
sha256: "7ddd964fa85b6a23e96956c5b63ef55cdb9e5947b71b95712204db42ad46da61"
|
||||
sha256: "3a2654ba104fbb52c618ebed9def24ef270228470718c43b3a6afcd5c81bef0c"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.0"
|
||||
version: "3.0.0"
|
||||
flutter_localizations:
|
||||
dependency: "direct main"
|
||||
description: flutter
|
||||
@@ -561,10 +561,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: js
|
||||
sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3
|
||||
sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.6.7"
|
||||
version: "0.7.2"
|
||||
json_annotation:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -1190,10 +1190,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: timezone
|
||||
sha256: dd14a3b83cfd7cb19e7888f1cbc20f258b8d71b54c06f79ac585f14093a287d1
|
||||
sha256: "784a5e34d2eb62e1326f24d6f600aaaee452eb8ca8ef2f384a59244e292d158b"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.10.1"
|
||||
version: "0.11.0"
|
||||
typed_data:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1372,4 +1372,4 @@ packages:
|
||||
version: "3.1.3"
|
||||
sdks:
|
||||
dart: ">=3.10.0 <4.0.0"
|
||||
flutter: ">=3.35.0"
|
||||
flutter: ">=3.38.1"
|
||||
|
||||
+2
-2
@@ -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
|
||||
@@ -60,7 +60,7 @@ dependencies:
|
||||
open_filex: ^4.7.0
|
||||
|
||||
# Notifications
|
||||
flutter_local_notifications: 20.0.0
|
||||
flutter_local_notifications: 21.0.0
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
|
||||
Reference in New Issue
Block a user