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
## [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.
+213 -4
View File
@@ -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
View File
@@ -12,6 +12,8 @@ import (
"net/url"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"sync"
"time"
@@ -31,13 +33,17 @@ var (
const (
qobuzTrackGetBaseURL = "https://www.qobuz.com/api.json/0.2/track/get?track_id="
qobuzTrackSearchBaseURL = "https://www.qobuz.com/api.json/0.2/track/search?query="
qobuzStoreSearchBaseURL = "https://www.qobuz.com/us-en/search/tracks/"
qobuzTrackPlayBaseURL = "https://play.qobuz.com/track/"
qobuzDownloadAPIURL = "https://www.musicdl.me/api/qobuz/download"
qobuzDabMusicAPIURL = "https://dabmusic.xyz/api/stream?trackId="
qobuzDeebAPIURL = "https://dab.yeet.su/api/stream?trackId="
qobuzSquidAPIURL = "https://qobuz.squid.wtf/api/download-music?country=US&track_id="
qobuzDebugKeyXORMask = byte(0x5A)
)
var qobuzStoreTrackIDRegex = regexp.MustCompile(`/v4/ajax/popin-add-cart/track/([0-9]+)`)
var qobuzDebugKeyObfuscated = []byte{
0x69, 0x3b, 0x38, 0x3e, 0x36, 0x37, 0x35, 0x2f, 0x36, 0x3b,
0x33, 0x29, 0x2e, 0x32, 0x3f, 0x3d, 0x35, 0x3b, 0x2e, 0x3b,
@@ -403,6 +409,7 @@ func (q *QobuzDownloader) GetAvailableProviders() []qobuzAPIProvider {
{Name: "dabmusic", URL: qobuzDabMusicAPIURL, Kind: qobuzAPIKindStandard},
// "deeb" is mapped from the legacy reference fallback endpoint.
{Name: "deeb", URL: qobuzDeebAPIURL, Kind: qobuzAPIKindStandard},
{Name: "squid", URL: qobuzSquidAPIURL, Kind: qobuzAPIKindStandard},
}
}
@@ -560,39 +567,18 @@ func getQobuzDebugKey() string {
}
func (q *QobuzDownloader) SearchTrackByISRC(isrc string) (*QobuzTrack, error) {
searchURL := fmt.Sprintf("%s%s&limit=50&app_id=%s", qobuzTrackSearchBaseURL, url.QueryEscape(isrc), q.appID)
req, err := http.NewRequest("GET", searchURL, nil)
candidates, err := q.searchQobuzTracksWithFallback(isrc, 50)
if err != nil {
return nil, err
}
resp, err := DoRequestWithUserAgent(q.client, req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return nil, fmt.Errorf("search failed: HTTP %d", resp.StatusCode)
}
var result struct {
Tracks struct {
Items []QobuzTrack `json:"items"`
} `json:"tracks"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, err
}
for i := range result.Tracks.Items {
if result.Tracks.Items[i].ISRC == isrc {
return &result.Tracks.Items[i], nil
for i := range candidates {
if candidates[i].ISRC == isrc {
return &candidates[i], nil
}
}
if len(result.Tracks.Items) == 0 {
if len(candidates) == 0 {
return nil, fmt.Errorf("no tracks found for ISRC: %s", isrc)
}
@@ -602,38 +588,17 @@ func (q *QobuzDownloader) SearchTrackByISRC(isrc string) (*QobuzTrack, error) {
func (q *QobuzDownloader) SearchTrackByISRCWithDuration(isrc string, expectedDurationSec int) (*QobuzTrack, error) {
GoLog("[Qobuz] Searching by ISRC: %s\n", isrc)
searchURL := fmt.Sprintf("%s%s&limit=50&app_id=%s", qobuzTrackSearchBaseURL, url.QueryEscape(isrc), q.appID)
req, err := http.NewRequest("GET", searchURL, nil)
candidates, err := q.searchQobuzTracksWithFallback(isrc, 50)
if err != nil {
return nil, err
}
resp, err := DoRequestWithUserAgent(q.client, req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return nil, fmt.Errorf("search failed: HTTP %d", resp.StatusCode)
}
var result struct {
Tracks struct {
Items []QobuzTrack `json:"items"`
} `json:"tracks"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, err
}
GoLog("[Qobuz] ISRC search returned %d results\n", len(result.Tracks.Items))
GoLog("[Qobuz] ISRC search returned %d results\n", len(candidates))
var isrcMatches []*QobuzTrack
for i := range result.Tracks.Items {
if result.Tracks.Items[i].ISRC == isrc {
isrcMatches = append(isrcMatches, &result.Tracks.Items[i])
for i := range candidates {
if candidates[i].ISRC == isrc {
isrcMatches = append(isrcMatches, &candidates[i])
}
}
@@ -668,7 +633,7 @@ func (q *QobuzDownloader) SearchTrackByISRCWithDuration(isrc string, expectedDur
return isrcMatches[0], nil
}
if len(result.Tracks.Items) == 0 {
if len(candidates) == 0 {
return nil, fmt.Errorf("no tracks found for ISRC: %s", isrc)
}
@@ -725,6 +690,7 @@ func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistNam
var allTracks []QobuzTrack
searchedQueries := make(map[string]bool)
seenTrackIDs := make(map[int64]struct{})
for _, query := range queries {
cleanQuery := strings.TrimSpace(query)
@@ -735,38 +701,26 @@ func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistNam
GoLog("[Qobuz] Searching for: %s\n", cleanQuery)
searchURL := fmt.Sprintf("%s%s&limit=50&app_id=%s", qobuzTrackSearchBaseURL, url.QueryEscape(cleanQuery), q.appID)
req, err := http.NewRequest("GET", searchURL, nil)
if err != nil {
continue
}
resp, err := DoRequestWithUserAgent(q.client, req)
result, err := q.searchQobuzTracksWithFallback(cleanQuery, 50)
if err != nil {
GoLog("[Qobuz] Search error for '%s': %v\n", cleanQuery, err)
continue
}
if resp.StatusCode != 200 {
resp.Body.Close()
continue
}
var result struct {
Tracks struct {
Items []QobuzTrack `json:"items"`
} `json:"tracks"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
resp.Body.Close()
continue
}
resp.Body.Close()
if len(result.Tracks.Items) > 0 {
GoLog("[Qobuz] Found %d results for '%s'\n", len(result.Tracks.Items), cleanQuery)
allTracks = append(allTracks, result.Tracks.Items...)
if len(result) > 0 {
GoLog("[Qobuz] Found %d results for '%s'\n", len(result), cleanQuery)
for i := range result {
trackID := result[i].ID
if trackID <= 0 {
allTracks = append(allTracks, result[i])
continue
}
if _, ok := seenTrackIDs[trackID]; ok {
continue
}
seenTrackIDs[trackID] = struct{}{}
allTracks = append(allTracks, result[i])
}
}
}
@@ -837,6 +791,131 @@ func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistNam
return nil, fmt.Errorf("no matching track found for: %s - %s", artistName, trackName)
}
func (q *QobuzDownloader) searchQobuzTracksViaAPI(query string, limit int) ([]QobuzTrack, error) {
searchURL := fmt.Sprintf("%s%s&limit=%d&app_id=%s", qobuzTrackSearchBaseURL, url.QueryEscape(query), limit, q.appID)
req, err := http.NewRequest("GET", searchURL, nil)
if err != nil {
return nil, err
}
resp, err := DoRequestWithUserAgent(q.client, req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return nil, fmt.Errorf("search failed: HTTP %d", resp.StatusCode)
}
var result struct {
Tracks struct {
Items []QobuzTrack `json:"items"`
} `json:"tracks"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, err
}
return result.Tracks.Items, nil
}
func extractQobuzTrackIDsFromStoreSearchHTML(body []byte) []int64 {
matches := qobuzStoreTrackIDRegex.FindAllSubmatch(body, -1)
if len(matches) == 0 {
return nil
}
trackIDs := make([]int64, 0, len(matches))
seen := make(map[int64]struct{}, len(matches))
for _, match := range matches {
if len(match) < 2 {
continue
}
id, err := strconv.ParseInt(string(match[1]), 10, 64)
if err != nil || id <= 0 {
continue
}
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
trackIDs = append(trackIDs, id)
}
return trackIDs
}
func (q *QobuzDownloader) searchQobuzTracksViaStore(query string, limit int) ([]QobuzTrack, error) {
searchURL := qobuzStoreSearchBaseURL + url.PathEscape(query)
req, err := http.NewRequest("GET", searchURL, nil)
if err != nil {
return nil, err
}
resp, err := DoRequestWithUserAgent(q.client, req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return nil, fmt.Errorf("store search failed: HTTP %d", resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
trackIDs := extractQobuzTrackIDsFromStoreSearchHTML(body)
if len(trackIDs) == 0 {
return nil, fmt.Errorf("store search did not contain track IDs")
}
if limit > 0 && len(trackIDs) > limit {
trackIDs = trackIDs[:limit]
}
tracks := make([]QobuzTrack, 0, len(trackIDs))
for _, id := range trackIDs {
track, trackErr := q.GetTrackByID(id)
if trackErr != nil || track == nil {
continue
}
tracks = append(tracks, *track)
}
if len(tracks) == 0 {
return nil, fmt.Errorf("store fallback returned IDs but no track metadata could be loaded")
}
return tracks, nil
}
func (q *QobuzDownloader) searchQobuzTracksWithFallback(query string, limit int) ([]QobuzTrack, error) {
apiTracks, apiErr := q.searchQobuzTracksViaAPI(query, limit)
if apiErr == nil {
if len(apiTracks) > 0 {
return apiTracks, nil
}
GoLog("[Qobuz] API search returned 0 results for '%s', trying store fallback\n", query)
} else {
GoLog("[Qobuz] API search failed for '%s': %v. Trying store fallback.\n", query, apiErr)
}
storeTracks, storeErr := q.searchQobuzTracksViaStore(query, limit)
if storeErr == nil && len(storeTracks) > 0 {
GoLog("[Qobuz] Store fallback returned %d candidate tracks for '%s'\n", len(storeTracks), query)
return storeTracks, nil
}
if apiErr != nil && storeErr != nil {
return nil, fmt.Errorf("api search failed (%v); store fallback failed (%v)", apiErr, storeErr)
}
if storeErr != nil {
return nil, storeErr
}
return nil, fmt.Errorf("no tracks found for query: %s", query)
}
type qobuzAPIResult struct {
provider qobuzAPIProvider
info qobuzDownloadInfo
+62
View File
@@ -1,6 +1,7 @@
package gobackend
import (
"context"
"encoding/json"
"fmt"
"net/http"
@@ -36,6 +37,12 @@ var (
songLinkClientOnce sync.Once
songLinkRegion = "US"
songLinkRegionMu sync.RWMutex
songLinkSearchByISRC = func(ctx context.Context, isrc string) (*TrackMetadata, error) {
return GetDeezerClient().SearchByISRC(ctx, isrc)
}
songLinkCheckAvailabilityFromDeezer = func(s *SongLinkClient, deezerTrackID string) (*TrackAvailability, error) {
return s.CheckAvailabilityFromDeezer(deezerTrackID)
}
)
func NewSongLinkClient() *SongLinkClient {
@@ -109,6 +116,20 @@ func buildSongLinkURLByPlatform(platform, entityType, entityID, userCountry stri
}
func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc string) (*TrackAvailability, error) {
spotifyTrackID = strings.TrimSpace(spotifyTrackID)
isrc = strings.ToUpper(strings.TrimSpace(isrc))
switch {
case spotifyTrackID != "":
return s.checkTrackAvailabilityFromSpotify(spotifyTrackID)
case isrc != "":
return s.checkTrackAvailabilityFromISRC(isrc)
default:
return nil, fmt.Errorf("spotify track ID and ISRC are empty")
}
}
func (s *SongLinkClient) checkTrackAvailabilityFromSpotify(spotifyTrackID string) (*TrackAvailability, error) {
songLinkRateLimiter.WaitForSlot()
spotifyURL := fmt.Sprintf("https://open.spotify.com/track/%s", spotifyTrackID)
@@ -200,6 +221,47 @@ func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc stri
return availability, nil
}
func (s *SongLinkClient) checkTrackAvailabilityFromISRC(isrc string) (*TrackAvailability, error) {
ctx, cancel := context.WithTimeout(context.Background(), SongLinkTimeout)
defer cancel()
track, err := songLinkSearchByISRC(ctx, isrc)
if err != nil {
return nil, fmt.Errorf("failed to resolve Deezer track from ISRC %s: %w", isrc, err)
}
deezerTrackID := songLinkExtractDeezerTrackID(track)
if deezerTrackID == "" {
return nil, fmt.Errorf("failed to resolve Deezer track ID from ISRC %s", isrc)
}
availability, err := songLinkCheckAvailabilityFromDeezer(s, deezerTrackID)
if err != nil {
return nil, fmt.Errorf("failed to resolve SongLink availability from ISRC %s via Deezer %s: %w", isrc, deezerTrackID, err)
}
return availability, nil
}
func songLinkExtractDeezerTrackID(track *TrackMetadata) string {
if track == nil {
return ""
}
if deezerID, ok := strings.CutPrefix(strings.TrimSpace(track.SpotifyID), "deezer:"); ok {
deezerID = strings.TrimSpace(deezerID)
if deezerID != "" {
return deezerID
}
}
if deezerID := extractDeezerIDFromURL(strings.TrimSpace(track.ExternalURL)); deezerID != "" {
return deezerID
}
return ""
}
func (s *SongLinkClient) GetStreamingURLs(spotifyTrackID string) (map[string]string, error) {
availability, err := s.CheckTrackAvailability(spotifyTrackID, "")
if err != nil {
+65 -4
View File
@@ -539,12 +539,65 @@ func ExtractYouTubeVideoID(urlStr string) (string, error) {
return "", fmt.Errorf("could not extract video ID from URL")
}
// searchYouTubeMusicViaExtension uses the YT Music extension's customSearch
// to find a track by artist + title. It filters for tracks only (not videos,
// albums, or playlists) and returns the YouTube Music watch URL for the first
// matching track, or "" if nothing was found.
func searchYouTubeMusicViaExtension(artistName, trackName string) string {
extManager := GetExtensionManager()
searchProviders := extManager.GetSearchProviders()
// Find the ytmusic-spotiflac extension
var ytProvider *ExtensionProviderWrapper
for _, p := range searchProviders {
if p.extension.ID == "ytmusic-spotiflac" {
ytProvider = p
break
}
}
if ytProvider == nil {
GoLog("[YouTube] YT Music extension not found or not enabled, skipping fallback\n")
return ""
}
query := strings.TrimSpace(artistName + " " + trackName)
if query == "" {
return ""
}
GoLog("[YouTube] Searching YT Music extension for: %s\n", query)
results, err := ytProvider.CustomSearch(query, map[string]interface{}{
"filter": "tracks",
})
if err != nil {
GoLog("[YouTube] YT Music extension search failed: %v\n", err)
return ""
}
// Find the first track result (item_type == "track" with a valid video ID)
for _, track := range results {
if track.ItemType != "" && track.ItemType != "track" {
continue
}
videoID := strings.TrimSpace(track.ID)
if videoID == "" {
continue
}
if isYouTubeVideoID(videoID) {
return BuildYouTubeWatchURL(videoID)
}
}
GoLog("[YouTube] YT Music extension returned no matching tracks for: %s\n", query)
return ""
}
func downloadFromYouTube(req DownloadRequest) (YouTubeDownloadResult, error) {
downloader := NewYouTubeDownloader()
format, bitrate, quality := parseYouTubeQualityInput(req.Quality)
// URL lookup priority: YouTube video ID > Spotify ID > Deezer ID > ISRC
// URL lookup priority: YouTube video ID > YT Music extension > SongLink (Spotify/Deezer/ISRC)
var youtubeURL string
var lookupErr error
@@ -554,7 +607,15 @@ func downloadFromYouTube(req DownloadRequest) (YouTubeDownloadResult, error) {
GoLog("[YouTube] SpotifyID appears to be YouTube video ID, using directly: %s\n", youtubeURL)
}
// Try Spotify ID via SongLink
// Try YT Music extension search first (if installed) - more accurate, tracks only
if youtubeURL == "" && (req.TrackName != "" || req.ArtistName != "") {
youtubeURL = searchYouTubeMusicViaExtension(req.ArtistName, req.TrackName)
if youtubeURL != "" {
GoLog("[YouTube] Found YouTube URL via YT Music extension: %s\n", youtubeURL)
}
}
// Fallback: Try Spotify ID via SongLink
if youtubeURL == "" && req.SpotifyID != "" && !isYouTubeVideoID(req.SpotifyID) {
GoLog("[YouTube] Looking up YouTube URL via SongLink for Spotify ID: %s\n", req.SpotifyID)
songlink := NewSongLinkClient()
@@ -566,7 +627,7 @@ func downloadFromYouTube(req DownloadRequest) (YouTubeDownloadResult, error) {
}
}
// Try Deezer ID via SongLink
// Fallback: Try Deezer ID via SongLink
if youtubeURL == "" && req.DeezerID != "" {
GoLog("[YouTube] Looking up YouTube URL via SongLink for Deezer ID: %s\n", req.DeezerID)
songlink := NewSongLinkClient()
@@ -578,7 +639,7 @@ func downloadFromYouTube(req DownloadRequest) (YouTubeDownloadResult, error) {
}
}
// Try ISRC via SongLink
// Fallback: Try ISRC via SongLink
if youtubeURL == "" && req.ISRC != "" {
GoLog("[YouTube] Looking up YouTube URL via SongLink for ISRC: %s\n", req.ISRC)
songlink := NewSongLinkClient()
+2 -2
View File
@@ -1,8 +1,8 @@
/// App version and info constants
/// Update version here only - all other files will reference this
class AppInfo {
static const String version = '3.7.0';
static const String buildNumber = '103';
static const String version = '3.7.1';
static const String buildNumber = '104';
static const String fullVersion = '$version+$buildNumber';
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 {
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;
}
+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)) {
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)
-15
View File
@@ -74,13 +74,6 @@ class UpdateChecker {
return null;
}
// Ignore releases from a different major version (e.g. v4.x when we
// rolled back to v3.x). Only offer updates within the same major line.
if (_majorVersion(latestVersion) != _majorVersion(AppInfo.version)) {
_log.i('Skipping update from different major version (current: ${AppInfo.version}, latest: $latestVersion)');
return null;
}
final body = releaseData['body'] as String? ?? 'No changelog available';
final htmlUrl = releaseData['html_url'] as String? ?? '${AppInfo.githubUrl}/releases';
final publishedAt = DateTime.tryParse(releaseData['published_at'] as String? ?? '') ?? DateTime.now();
@@ -125,14 +118,6 @@ class UpdateChecker {
}
}
static int _majorVersion(String version) {
try {
return int.parse(version.split('-').first.split('.').first);
} catch (_) {
return -1;
}
}
static bool _isNewerVersion(String latest, String current) {
try {
final latestBase = latest.split('-').first;
+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(
id: 'youtube',
label: 'YouTube',
+13 -13
View File
@@ -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
View File
@@ -1,7 +1,7 @@
name: spotiflac_android
description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music
publish_to: "none"
version: 3.7.0+103
version: 3.7.1+104
environment:
sdk: ^3.10.0
@@ -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: