Compare commits

...

5 Commits

Author SHA1 Message Date
zarzet 36a646e5c0 feat: add Deezer download service, Qobuz squid.wtf fallback, update changelog 2026-03-06 21:18:50 +07:00
zarzet f306599ab2 v3.7.1: YT Music extension priority for YouTube downloads, Qobuz store fallback, queue fixes, server-side search filters 2026-03-06 16:44:53 +07:00
zarzet 3a7b777717 fix(queue): unique queue IDs, nullable currentDownload, local cancel tracking; refactor(l10n): consolidate and clean up localization files
download_queue_provider: generate unique queue item IDs with sequence counter to prevent collisions, fix copyWith to allow setting currentDownload to null via sentinel object pattern, add _locallyCancelledItemIds set for reliable cancel state, normalize restored queue IDs on load. l10n: remove redundant keys, consolidate ARB files, regenerate Dart localization classes.
2026-03-06 16:44:53 +07:00
Zarz Eleutherius 2334e659ad Merge pull request #206 from zarzet/renovate/major-flutter-dependencies
fix(deps): update dependency flutter_local_notifications to v21
2026-03-05 17:32:17 +07:00
renovate[bot] 2a0216c87a fix(deps): update dependency flutter_local_notifications to v21 2026-03-05 10:14:36 +00:00
44 changed files with 6011 additions and 38643 deletions
+23
View File
@@ -1,5 +1,28 @@
# Changelog # 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 ## [3.7.0] - 2026-03-04
Hey everyone, thank you so much for sticking with SpotiFLAC Mobile. Hey everyone, thank you so much for sticking with SpotiFLAC Mobile.
+213 -4
View File
@@ -15,6 +15,7 @@ import (
) )
const deezerYoinkifyURL = "https://yoinkify.lol/api/download" const deezerYoinkifyURL = "https://yoinkify.lol/api/download"
const deezerMusicDLURL = "https://www.musicdl.me/api/download"
type YoinkifyRequest struct { type YoinkifyRequest struct {
URL string `json:"url"` URL string `json:"url"`
@@ -194,6 +195,195 @@ func (c *DeezerClient) DownloadFromYoinkify(spotifyURL, outputPath string, outpu
return nil 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) { func downloadFromDeezer(req DownloadRequest) (DeezerDownloadResult, error) {
deezerClient := GetDeezerClient() deezerClient := GetDeezerClient()
isSafOutput := isFDOutput(req.OutputFD) || strings.TrimSpace(req.OutputPath) != "" 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 { // Try MusicDL first (better quality), fallback to Yoinkify
if errors.Is(err, ErrDownloadCancelled) { var downloadErr error
return DeezerDownloadResult{}, ErrDownloadCancelled 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 <-parallelDone
+159 -80
View File
@@ -12,6 +12,8 @@ import (
"net/url" "net/url"
"os" "os"
"path/filepath" "path/filepath"
"regexp"
"strconv"
"strings" "strings"
"sync" "sync"
"time" "time"
@@ -31,13 +33,17 @@ var (
const ( const (
qobuzTrackGetBaseURL = "https://www.qobuz.com/api.json/0.2/track/get?track_id=" 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=" 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/" qobuzTrackPlayBaseURL = "https://play.qobuz.com/track/"
qobuzDownloadAPIURL = "https://www.musicdl.me/api/qobuz/download" qobuzDownloadAPIURL = "https://www.musicdl.me/api/qobuz/download"
qobuzDabMusicAPIURL = "https://dabmusic.xyz/api/stream?trackId=" qobuzDabMusicAPIURL = "https://dabmusic.xyz/api/stream?trackId="
qobuzDeebAPIURL = "https://dab.yeet.su/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) qobuzDebugKeyXORMask = byte(0x5A)
) )
var qobuzStoreTrackIDRegex = regexp.MustCompile(`/v4/ajax/popin-add-cart/track/([0-9]+)`)
var qobuzDebugKeyObfuscated = []byte{ var qobuzDebugKeyObfuscated = []byte{
0x69, 0x3b, 0x38, 0x3e, 0x36, 0x37, 0x35, 0x2f, 0x36, 0x3b, 0x69, 0x3b, 0x38, 0x3e, 0x36, 0x37, 0x35, 0x2f, 0x36, 0x3b,
0x33, 0x29, 0x2e, 0x32, 0x3f, 0x3d, 0x35, 0x3b, 0x2e, 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}, {Name: "dabmusic", URL: qobuzDabMusicAPIURL, Kind: qobuzAPIKindStandard},
// "deeb" is mapped from the legacy reference fallback endpoint. // "deeb" is mapped from the legacy reference fallback endpoint.
{Name: "deeb", URL: qobuzDeebAPIURL, Kind: qobuzAPIKindStandard}, {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) { func (q *QobuzDownloader) SearchTrackByISRC(isrc string) (*QobuzTrack, error) {
searchURL := fmt.Sprintf("%s%s&limit=50&app_id=%s", qobuzTrackSearchBaseURL, url.QueryEscape(isrc), q.appID) candidates, err := q.searchQobuzTracksWithFallback(isrc, 50)
req, err := http.NewRequest("GET", searchURL, nil)
if err != nil { if err != nil {
return nil, err return nil, err
} }
resp, err := DoRequestWithUserAgent(q.client, req) for i := range candidates {
if err != nil { if candidates[i].ISRC == isrc {
return nil, err return &candidates[i], nil
}
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
} }
} }
if len(result.Tracks.Items) == 0 { if len(candidates) == 0 {
return nil, fmt.Errorf("no tracks found for ISRC: %s", isrc) 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) { func (q *QobuzDownloader) SearchTrackByISRCWithDuration(isrc string, expectedDurationSec int) (*QobuzTrack, error) {
GoLog("[Qobuz] Searching by ISRC: %s\n", isrc) GoLog("[Qobuz] Searching by ISRC: %s\n", isrc)
searchURL := fmt.Sprintf("%s%s&limit=50&app_id=%s", qobuzTrackSearchBaseURL, url.QueryEscape(isrc), q.appID) candidates, err := q.searchQobuzTracksWithFallback(isrc, 50)
req, err := http.NewRequest("GET", searchURL, nil)
if err != nil { if err != nil {
return nil, err return nil, err
} }
resp, err := DoRequestWithUserAgent(q.client, req) GoLog("[Qobuz] ISRC search returned %d results\n", len(candidates))
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))
var isrcMatches []*QobuzTrack var isrcMatches []*QobuzTrack
for i := range result.Tracks.Items { for i := range candidates {
if result.Tracks.Items[i].ISRC == isrc { if candidates[i].ISRC == isrc {
isrcMatches = append(isrcMatches, &result.Tracks.Items[i]) isrcMatches = append(isrcMatches, &candidates[i])
} }
} }
@@ -668,7 +633,7 @@ func (q *QobuzDownloader) SearchTrackByISRCWithDuration(isrc string, expectedDur
return isrcMatches[0], nil 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) return nil, fmt.Errorf("no tracks found for ISRC: %s", isrc)
} }
@@ -725,6 +690,7 @@ func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistNam
var allTracks []QobuzTrack var allTracks []QobuzTrack
searchedQueries := make(map[string]bool) searchedQueries := make(map[string]bool)
seenTrackIDs := make(map[int64]struct{})
for _, query := range queries { for _, query := range queries {
cleanQuery := strings.TrimSpace(query) cleanQuery := strings.TrimSpace(query)
@@ -735,38 +701,26 @@ func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistNam
GoLog("[Qobuz] Searching for: %s\n", cleanQuery) GoLog("[Qobuz] Searching for: %s\n", cleanQuery)
searchURL := fmt.Sprintf("%s%s&limit=50&app_id=%s", qobuzTrackSearchBaseURL, url.QueryEscape(cleanQuery), q.appID) result, err := q.searchQobuzTracksWithFallback(cleanQuery, 50)
req, err := http.NewRequest("GET", searchURL, nil)
if err != nil {
continue
}
resp, err := DoRequestWithUserAgent(q.client, req)
if err != nil { if err != nil {
GoLog("[Qobuz] Search error for '%s': %v\n", cleanQuery, err) GoLog("[Qobuz] Search error for '%s': %v\n", cleanQuery, err)
continue continue
} }
if resp.StatusCode != 200 { if len(result) > 0 {
resp.Body.Close() GoLog("[Qobuz] Found %d results for '%s'\n", len(result), cleanQuery)
continue for i := range result {
} trackID := result[i].ID
if trackID <= 0 {
var result struct { allTracks = append(allTracks, result[i])
Tracks struct { continue
Items []QobuzTrack `json:"items"` }
} `json:"tracks"` if _, ok := seenTrackIDs[trackID]; ok {
} continue
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { }
resp.Body.Close() seenTrackIDs[trackID] = struct{}{}
continue allTracks = append(allTracks, result[i])
} }
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...)
} }
} }
@@ -837,6 +791,131 @@ func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistNam
return nil, fmt.Errorf("no matching track found for: %s - %s", artistName, trackName) 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 { type qobuzAPIResult struct {
provider qobuzAPIProvider provider qobuzAPIProvider
info qobuzDownloadInfo info qobuzDownloadInfo
+62
View File
@@ -1,6 +1,7 @@
package gobackend package gobackend
import ( import (
"context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"net/http" "net/http"
@@ -36,6 +37,12 @@ var (
songLinkClientOnce sync.Once songLinkClientOnce sync.Once
songLinkRegion = "US" songLinkRegion = "US"
songLinkRegionMu sync.RWMutex 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 { func NewSongLinkClient() *SongLinkClient {
@@ -109,6 +116,20 @@ func buildSongLinkURLByPlatform(platform, entityType, entityID, userCountry stri
} }
func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc string) (*TrackAvailability, error) { 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() songLinkRateLimiter.WaitForSlot()
spotifyURL := fmt.Sprintf("https://open.spotify.com/track/%s", spotifyTrackID) 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 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) { func (s *SongLinkClient) GetStreamingURLs(spotifyTrackID string) (map[string]string, error) {
availability, err := s.CheckTrackAvailability(spotifyTrackID, "") availability, err := s.CheckTrackAvailability(spotifyTrackID, "")
if err != nil { 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") 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) { func downloadFromYouTube(req DownloadRequest) (YouTubeDownloadResult, error) {
downloader := NewYouTubeDownloader() downloader := NewYouTubeDownloader()
format, bitrate, quality := parseYouTubeQualityInput(req.Quality) 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 youtubeURL string
var lookupErr error 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) 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) { if youtubeURL == "" && req.SpotifyID != "" && !isYouTubeVideoID(req.SpotifyID) {
GoLog("[YouTube] Looking up YouTube URL via SongLink for Spotify ID: %s\n", req.SpotifyID) GoLog("[YouTube] Looking up YouTube URL via SongLink for Spotify ID: %s\n", req.SpotifyID)
songlink := NewSongLinkClient() 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 != "" { if youtubeURL == "" && req.DeezerID != "" {
GoLog("[YouTube] Looking up YouTube URL via SongLink for Deezer ID: %s\n", req.DeezerID) GoLog("[YouTube] Looking up YouTube URL via SongLink for Deezer ID: %s\n", req.DeezerID)
songlink := NewSongLinkClient() 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 != "" { if youtubeURL == "" && req.ISRC != "" {
GoLog("[YouTube] Looking up YouTube URL via SongLink for ISRC: %s\n", req.ISRC) GoLog("[YouTube] Looking up YouTube URL via SongLink for ISRC: %s\n", req.ISRC)
songlink := NewSongLinkClient() songlink := NewSongLinkClient()
+2 -2
View File
@@ -1,8 +1,8 @@
/// App version and info constants /// App version and info constants
/// Update version here only - all other files will reference this /// Update version here only - all other files will reference this
class AppInfo { class AppInfo {
static const String version = '3.7.0'; static const String version = '3.7.1';
static const String buildNumber = '103'; static const String buildNumber = '104';
static const String fullVersion = '$version+$buildNumber'; static const String fullVersion = '$version+$buildNumber';
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
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
+18 -1098
View File
File diff suppressed because it is too large Load Diff
+2147 -1541
View File
File diff suppressed because it is too large Load Diff
+10 -862
View File
File diff suppressed because it is too large Load Diff
+18 -1098
View File
File diff suppressed because it is too large Load Diff
+18 -1098
View File
File diff suppressed because it is too large Load Diff
+18 -1098
View File
File diff suppressed because it is too large Load Diff
+2800 -3945
View File
File diff suppressed because it is too large Load Diff
+18 -1098
View File
File diff suppressed because it is too large Load Diff
+18 -1098
View File
File diff suppressed because it is too large Load Diff
+18 -1098
View File
File diff suppressed because it is too large Load Diff
+10 -862
View File
File diff suppressed because it is too large Load Diff
+18 -1098
View File
File diff suppressed because it is too large Load Diff
+18 -1098
View File
File diff suppressed because it is too large Load Diff
+18 -1098
View File
File diff suppressed because it is too large Load Diff
+10 -862
View File
File diff suppressed because it is too large Load Diff
+18 -1098
View File
File diff suppressed because it is too large Load Diff
+18 -1098
View File
File diff suppressed because it is too large Load Diff
+136 -36
View File
@@ -625,6 +625,7 @@ final downloadHistoryProvider =
); );
class DownloadQueueState { class DownloadQueueState {
static const Object _noChange = Object();
final List<DownloadItem> items; final List<DownloadItem> items;
final DownloadItem? currentDownload; final DownloadItem? currentDownload;
final bool isProcessing; final bool isProcessing;
@@ -649,7 +650,7 @@ class DownloadQueueState {
DownloadQueueState copyWith({ DownloadQueueState copyWith({
List<DownloadItem>? items, List<DownloadItem>? items,
DownloadItem? currentDownload, Object? currentDownload = _noChange,
bool? isProcessing, bool? isProcessing,
bool? isPaused, bool? isPaused,
String? outputDir, String? outputDir,
@@ -660,7 +661,9 @@ class DownloadQueueState {
}) { }) {
return DownloadQueueState( return DownloadQueueState(
items: items ?? this.items, items: items ?? this.items,
currentDownload: currentDownload ?? this.currentDownload, currentDownload: identical(currentDownload, _noChange)
? this.currentDownload
: currentDownload as DownloadItem?,
isProcessing: isProcessing ?? this.isProcessing, isProcessing: isProcessing ?? this.isProcessing,
isPaused: isPaused ?? this.isPaused, isPaused: isPaused ?? this.isPaused,
outputDir: outputDir ?? this.outputDir, outputDir: outputDir ?? this.outputDir,
@@ -717,6 +720,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
int _totalQueuedAtStart = 0; int _totalQueuedAtStart = 0;
int _completedInSession = 0; int _completedInSession = 0;
int _failedInSession = 0; int _failedInSession = 0;
int _queueItemSequence = 0;
bool _isLoaded = false; bool _isLoaded = false;
final Set<String> _ensuredDirs = {}; final Set<String> _ensuredDirs = {};
int _progressPollingErrorCount = 0; int _progressPollingErrorCount = 0;
@@ -735,6 +739,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
String? _lastNotifArtistName; String? _lastNotifArtistName;
int _lastNotifPercent = -1; int _lastNotifPercent = -1;
int _lastNotifQueueCount = -1; int _lastNotifQueueCount = -1;
final Set<String> _locallyCancelledItemIds = {};
double _normalizeProgressForUi(double value) { double _normalizeProgressForUi(double value) {
final clamped = value.clamp(0.0, 1.0).toDouble(); final clamped = value.clamp(0.0, 1.0).toDouble();
@@ -854,8 +859,11 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
return; return;
} }
state = state.copyWith(items: pendingItems); final normalizedPendingItems = _normalizeRestoredQueueIds(pendingItems);
_log.i('Restored ${pendingItems.length} pending items from storage'); state = state.copyWith(items: normalizedPendingItems);
_log.i(
'Restored ${normalizedPendingItems.length} pending items from storage',
);
Future.microtask(() => _processQueue()); Future.microtask(() => _processQueue());
} catch (e) { } catch (e) {
_log.e('Failed to load queue from storage: $e'); _log.e('Failed to load queue from storage: $e');
@@ -1644,6 +1652,53 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
return _isrcRegex.hasMatch(value.toUpperCase()); 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) { void updateSettings(AppSettings settings) {
final concurrentDownloads = settings.concurrentDownloads.clamp(1, 5); final concurrentDownloads = settings.concurrentDownloads.clamp(1, 5);
state = state.copyWith( state = state.copyWith(
@@ -1661,8 +1716,8 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
final settings = ref.read(settingsProvider); final settings = ref.read(settingsProvider);
updateSettings(settings); updateSettings(settings);
final id = final takenIds = state.items.map((item) => item.id).toSet();
'${track.isrc ?? track.id}-${DateTime.now().millisecondsSinceEpoch}'; final id = _newQueueItemId(track, takenIds: takenIds);
final item = DownloadItem( final item = DownloadItem(
id: id, id: id,
track: track, track: track,
@@ -1689,9 +1744,10 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
final settings = ref.read(settingsProvider); final settings = ref.read(settingsProvider);
updateSettings(settings); updateSettings(settings);
final takenIds = state.items.map((item) => item.id).toSet();
final newItems = tracks.map((track) { final newItems = tracks.map((track) {
final id = final id = _newQueueItemId(track, takenIds: takenIds);
'${track.isrc ?? track.id}-${DateTime.now().millisecondsSinceEpoch}'; takenIds.add(id);
return DownloadItem( return DownloadItem(
id: id, id: id,
track: track, track: track,
@@ -1770,12 +1826,30 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
); );
} }
void cancelItem(String id) { DownloadItem? _findItemById(String id) {
updateItemStatus(id, DownloadStatus.skipped); 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.cancelDownload(id).catchError((_) {});
PlatformBridge.clearItemProgress(id).catchError((_) {}); PlatformBridge.clearItemProgress(id).catchError((_) {});
} }
void cancelItem(String id) {
_locallyCancelledItemIds.add(id);
updateItemStatus(id, DownloadStatus.skipped);
_requestNativeCancel(id);
}
void clearCompleted() { void clearCompleted() {
final items = state.items final items = state.items
.where( .where(
@@ -1791,8 +1865,30 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
} }
void clearAll() { 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(); _saveQueueToStorage();
if (!wasProcessing) {
_locallyCancelledItemIds.clear();
}
} }
void pauseQueue() { void pauseQueue() {
@@ -1835,6 +1931,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
} }
_log.i('Retrying item: ${item.track.name} (id: $id)'); _log.i('Retrying item: ${item.track.name} (id: $id)');
_locallyCancelledItemIds.remove(id);
final items = state.items.map((i) { final items = state.items.map((i) {
if (i.id == id) { if (i.id == id) {
@@ -1858,6 +1955,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
} }
void removeItem(String id) { void removeItem(String id) {
_locallyCancelledItemIds.remove(id);
final items = state.items.where((item) => item.id != id).toList(); final items = state.items.where((item) => item.id != id).toList();
state = state.copyWith(items: items); state = state.copyWith(items: items);
_saveQueueToStorage(); _saveQueueToStorage();
@@ -2892,17 +2990,16 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
} }
_stopProgressPolling(); _stopProgressPolling();
final remainingIds = state.items.map((item) => item.id).toSet();
_locallyCancelledItemIds.removeWhere((id) => !remainingIds.contains(id));
} }
Future<void> _downloadSingleItem(DownloadItem item) async { Future<void> _downloadSingleItem(DownloadItem item) async {
_log.d('Processing: ${item.track.name} by ${item.track.artistName}'); _log.d('Processing: ${item.track.name} by ${item.track.artistName}');
_log.d('Cover URL: ${item.track.coverUrl}'); _log.d('Cover URL: ${item.track.coverUrl}');
final currentItem = state.items.firstWhere( final currentItem = _findItemById(item.id) ?? item;
(i) => i.id == item.id, if (_isLocallyCancelled(item.id, item: currentItem)) {
orElse: () => item,
);
if (currentItem.status == DownloadStatus.skipped) {
_log.i('Download was cancelled before start, skipping'); _log.i('Download was cancelled before start, skipping');
return; 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( result = await runDownload(
useSaf: effectiveSafMode, useSaf: effectiveSafMode,
outputDir: effectiveOutputDir, outputDir: effectiveOutputDir,
@@ -3323,6 +3425,10 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
if (effectiveSafMode && if (effectiveSafMode &&
result['success'] != true && result['success'] != true &&
_isSafWriteFailure(result)) { _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'); _log.w('SAF write failed, retrying with app-private storage');
appOutputDir ??= await _buildOutputDir( appOutputDir ??= await _buildOutputDir(
trackToDownload, trackToDownload,
@@ -3348,11 +3454,11 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
_log.d('Result: $result'); _log.d('Result: $result');
final currentItem = state.items.firstWhere( final itemAfterResult = _findItemById(item.id);
(i) => i.id == item.id, final cancelledAfterResult =
orElse: () => item, itemAfterResult == null ||
); _isLocallyCancelled(item.id, item: itemAfterResult);
if (currentItem.status == DownloadStatus.skipped) { if (cancelledAfterResult) {
_log.i('Download was cancelled, skipping result processing'); _log.i('Download was cancelled, skipping result processing');
final filePath = result['file_path'] as String?; final filePath = result['file_path'] as String?;
if (filePath != null && result['success'] == true) { if (filePath != null && result['success'] == true) {
@@ -4083,11 +4189,9 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
} }
} }
final itemAfterDownload = state.items.firstWhere( final itemAfterDownload = _findItemById(item.id);
(i) => i.id == item.id, if (itemAfterDownload == null ||
orElse: () => item, _isLocallyCancelled(item.id, item: itemAfterDownload)) {
);
if (itemAfterDownload.status == DownloadStatus.skipped) {
_log.i('Download was cancelled during finalization, cleaning up'); _log.i('Download was cancelled during finalization, cleaning up');
if (filePath != null) { if (filePath != null) {
await deleteFile(filePath); await deleteFile(filePath);
@@ -4309,11 +4413,9 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
removeItem(item.id); removeItem(item.id);
} }
} else { } else {
final itemAfterFailure = state.items.firstWhere( final itemAfterFailure = _findItemById(item.id);
(i) => i.id == item.id, if (itemAfterFailure == null ||
orElse: () => item, _isLocallyCancelled(item.id, item: itemAfterFailure)) {
);
if (itemAfterFailure.status == DownloadStatus.skipped) {
_log.i('Download was cancelled, skipping error handling'); _log.i('Download was cancelled, skipping error handling');
return; return;
} }
@@ -4374,11 +4476,9 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
} }
} }
} catch (e, stackTrace) { } catch (e, stackTrace) {
final itemAfterError = state.items.firstWhere( final itemAfterError = _findItemById(item.id);
(i) => i.id == item.id, if (itemAfterError == null ||
orElse: () => item, _isLocallyCancelled(item.id, item: itemAfterError)) {
);
if (itemAfterError.status == DownloadStatus.skipped) {
_log.i('Download was cancelled, skipping error handling'); _log.i('Download was cancelled, skipping error handling');
return; return;
} }
+2 -2
View File
@@ -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)) { if (!result.contains(provider)) {
result.add(provider); result.add(provider);
} }
@@ -880,7 +880,7 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
} }
List<String> getAllDownloadProviders() { List<String> getAllDownloadProviders() {
final providers = ['tidal', 'qobuz', 'amazon']; final providers = ['tidal', 'qobuz', 'amazon', 'deezer'];
for (final ext in state.extensions) { for (final ext in state.extensions) {
if (ext.enabled && ext.hasDownloadProvider) { if (ext.enabled && ext.hasDownloadProvider) {
providers.add(ext.id); providers.add(ext.id);
@@ -23,7 +23,7 @@ class DownloadSettingsPage extends ConsumerStatefulWidget {
} }
class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> { class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
static const _builtInServices = ['tidal', 'qobuz', 'amazon']; static const _builtInServices = ['tidal', 'qobuz', 'amazon', 'deezer'];
static const _songLinkRegions = [ static const _songLinkRegions = [
'AD', 'AD',
'AE', 'AE',
@@ -2039,7 +2039,7 @@ class _ServiceSelector extends ConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final extState = ref.watch(extensionProvider); final extState = ref.watch(extensionProvider);
final builtInServiceIds = ['tidal', 'qobuz', 'amazon', 'youtube']; final builtInServiceIds = ['tidal', 'qobuz', 'amazon', 'deezer', 'youtube'];
final extensionProviders = extState.extensions final extensionProviders = extState.extensions
.where((e) => e.enabled && e.hasDownloadProvider) .where((e) => e.enabled && e.hasDownloadProvider)
-15
View File
@@ -74,13 +74,6 @@ class UpdateChecker {
return null; 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 body = releaseData['body'] as String? ?? 'No changelog available';
final htmlUrl = releaseData['html_url'] as String? ?? '${AppInfo.githubUrl}/releases'; final htmlUrl = releaseData['html_url'] as String? ?? '${AppInfo.githubUrl}/releases';
final publishedAt = DateTime.tryParse(releaseData['published_at'] as String? ?? '') ?? DateTime.now(); 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) { static bool _isNewerVersion(String latest, String current) {
try { try {
final latestBase = latest.split('-').first; final latestBase = latest.split('-').first;
+11
View File
@@ -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( BuiltInService(
id: 'youtube', id: 'youtube',
label: 'YouTube', label: 'YouTube',
+13 -13
View File
@@ -362,34 +362,34 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: flutter_local_notifications name: flutter_local_notifications
sha256: "76cd20bcfa72fabe50ea27eeaf165527f446f55d3033021462084b87805b4cac" sha256: "0d9035862236fe38250fe1644d7ed3b8254e34a21b2c837c9f539fbb3bba5ef1"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "20.0.0" version: "21.0.0"
flutter_local_notifications_linux: flutter_local_notifications_linux:
dependency: transitive dependency: transitive
description: description:
name: flutter_local_notifications_linux name: flutter_local_notifications_linux
sha256: dce0116868cedd2cdf768af0365fc37ff1cbef7c02c4f51d0587482e625868d0 sha256: e0f25e243c6c44c825bbbc6b2b2e76f7d9222362adcfe9fd780bf01923c840bd
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "7.0.0" version: "8.0.0"
flutter_local_notifications_platform_interface: flutter_local_notifications_platform_interface:
dependency: transitive dependency: transitive
description: description:
name: flutter_local_notifications_platform_interface name: flutter_local_notifications_platform_interface
sha256: "23de31678a48c084169d7ae95866df9de5c9d2a44be3e5915a2ff067aeeba899" sha256: e7db3d5b49c2b7ecc68deba4aaaa67a348f92ee0fef34c8e4b4459dbef0d7307
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "10.0.0" version: "11.0.0"
flutter_local_notifications_windows: flutter_local_notifications_windows:
dependency: transitive dependency: transitive
description: description:
name: flutter_local_notifications_windows name: flutter_local_notifications_windows
sha256: "7ddd964fa85b6a23e96956c5b63ef55cdb9e5947b71b95712204db42ad46da61" sha256: "3a2654ba104fbb52c618ebed9def24ef270228470718c43b3a6afcd5c81bef0c"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.0.0" version: "3.0.0"
flutter_localizations: flutter_localizations:
dependency: "direct main" dependency: "direct main"
description: flutter description: flutter
@@ -561,10 +561,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: js name: js
sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3 sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.6.7" version: "0.7.2"
json_annotation: json_annotation:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -1190,10 +1190,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: timezone name: timezone
sha256: dd14a3b83cfd7cb19e7888f1cbc20f258b8d71b54c06f79ac585f14093a287d1 sha256: "784a5e34d2eb62e1326f24d6f600aaaee452eb8ca8ef2f384a59244e292d158b"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.10.1" version: "0.11.0"
typed_data: typed_data:
dependency: transitive dependency: transitive
description: description:
@@ -1372,4 +1372,4 @@ packages:
version: "3.1.3" version: "3.1.3"
sdks: sdks:
dart: ">=3.10.0 <4.0.0" dart: ">=3.10.0 <4.0.0"
flutter: ">=3.35.0" flutter: ">=3.38.1"
+2 -2
View File
@@ -1,7 +1,7 @@
name: spotiflac_android name: spotiflac_android
description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music
publish_to: "none" publish_to: "none"
version: 3.7.0+103 version: 3.7.1+104
environment: environment:
sdk: ^3.10.0 sdk: ^3.10.0
@@ -60,7 +60,7 @@ dependencies:
open_filex: ^4.7.0 open_filex: ^4.7.0
# Notifications # Notifications
flutter_local_notifications: 20.0.0 flutter_local_notifications: 21.0.0
dev_dependencies: dev_dependencies:
flutter_test: flutter_test: