mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-07-03 03:15:51 +02:00
Compare commits
45 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 66a89d9e8e | |||
| 814deca19d | |||
| 3bb6754d9c | |||
| 7d11d67cd2 | |||
| c0bd10cfca | |||
| e003b15ffd | |||
| ac1c7d31c9 | |||
| 6fc9ffeb23 | |||
| 9bebed506b | |||
| 6ecb69feae | |||
| feff985439 | |||
| 2e8fe34824 | |||
| f58005f406 | |||
| 75abc03a4f | |||
| 84381d142a | |||
| 3747ffff64 | |||
| ed47efed17 | |||
| c0d72e89d7 | |||
| a4313cfe0f | |||
| c7bef03ee3 | |||
| ce5a9e0cff | |||
| 859b823e77 | |||
| 7d8cf5f7ca | |||
| 4adaed8da0 | |||
| 554fe08fcd | |||
| b8af75bf6e | |||
| 35f2f119db | |||
| f36096e0ac | |||
| 1665e4cd57 | |||
| 42f0267277 | |||
| 82f59d32b9 | |||
| 941347b007 | |||
| 739c89569f | |||
| 7bb808cba5 | |||
| bb342c01e2 | |||
| 8a5dc0edfe | |||
| 20f789f8e0 | |||
| 3e89326c95 | |||
| a7ea4de25a | |||
| aabfbf062e | |||
| 7b9ed3ec8e | |||
| 6dad66d62d | |||
| 31018230ee | |||
| 54ddc1f59c | |||
| c6856bd1a1 |
@@ -346,11 +346,12 @@ func calculateNextDelay(currentDelay time.Duration, config RetryConfig) time.Dur
|
|||||||
return min(nextDelay, config.MaxDelay)
|
return min(nextDelay, config.MaxDelay)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Returns 60 seconds as default if header is missing or invalid
|
// Returns 0 if the header is missing or invalid so callers can keep their
|
||||||
|
// normal exponential backoff instead of stalling for an arbitrary minute.
|
||||||
func getRetryAfterDuration(resp *http.Response) time.Duration {
|
func getRetryAfterDuration(resp *http.Response) time.Duration {
|
||||||
retryAfter := resp.Header.Get("Retry-After")
|
retryAfter := resp.Header.Get("Retry-After")
|
||||||
if retryAfter == "" {
|
if retryAfter == "" {
|
||||||
return 60 * time.Second
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
if seconds, err := strconv.Atoi(retryAfter); err == nil {
|
if seconds, err := strconv.Atoi(retryAfter); err == nil {
|
||||||
@@ -364,7 +365,7 @@ func getRetryAfterDuration(resp *http.Response) time.Duration {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return 60 * time.Second
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
func ReadResponseBody(resp *http.Response) ([]byte, error) {
|
func ReadResponseBody(resp *http.Response) ([]byte, error) {
|
||||||
|
|||||||
+144
-43
@@ -1,6 +1,7 @@
|
|||||||
package gobackend
|
package gobackend
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -14,6 +15,10 @@ type SongLinkClient struct {
|
|||||||
client *http.Client
|
client *http.Client
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type songLinkPlatformLink struct {
|
||||||
|
URL string `json:"url"`
|
||||||
|
}
|
||||||
|
|
||||||
type TrackAvailability struct {
|
type TrackAvailability struct {
|
||||||
SpotifyID string `json:"spotify_id"`
|
SpotifyID string `json:"spotify_id"`
|
||||||
Tidal bool `json:"tidal"`
|
Tidal bool `json:"tidal"`
|
||||||
@@ -43,6 +48,7 @@ var (
|
|||||||
songLinkCheckAvailabilityFromDeezer = func(s *SongLinkClient, deezerTrackID string) (*TrackAvailability, error) {
|
songLinkCheckAvailabilityFromDeezer = func(s *SongLinkClient, deezerTrackID string) (*TrackAvailability, error) {
|
||||||
return s.CheckAvailabilityFromDeezer(deezerTrackID)
|
return s.CheckAvailabilityFromDeezer(deezerTrackID)
|
||||||
}
|
}
|
||||||
|
songLinkRetryConfig = DefaultRetryConfig
|
||||||
)
|
)
|
||||||
|
|
||||||
func NewSongLinkClient() *SongLinkClient {
|
func NewSongLinkClient() *SongLinkClient {
|
||||||
@@ -130,7 +136,14 @@ func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc stri
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *SongLinkClient) checkTrackAvailabilityFromSpotify(spotifyTrackID string) (*TrackAvailability, error) {
|
func (s *SongLinkClient) checkTrackAvailabilityFromSpotify(spotifyTrackID string) (*TrackAvailability, error) {
|
||||||
songLinkRateLimiter.WaitForSlot()
|
availability, pageErr := s.checkTrackAvailabilityFromSpotifyPage(spotifyTrackID)
|
||||||
|
if pageErr == nil {
|
||||||
|
return availability, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if !songLinkRateLimiter.TryAcquire() {
|
||||||
|
return nil, fmt.Errorf("song.link page lookup failed: %w (SongLink local rate limit exceeded)", pageErr)
|
||||||
|
}
|
||||||
|
|
||||||
spotifyURL := fmt.Sprintf("https://open.spotify.com/track/%s", spotifyTrackID)
|
spotifyURL := fmt.Sprintf("https://open.spotify.com/track/%s", spotifyTrackID)
|
||||||
apiURL := buildSongLinkURLFromTarget(spotifyURL, "")
|
apiURL := buildSongLinkURLFromTarget(spotifyURL, "")
|
||||||
@@ -140,10 +153,10 @@ func (s *SongLinkClient) checkTrackAvailabilityFromSpotify(spotifyTrackID string
|
|||||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
retryConfig := DefaultRetryConfig()
|
retryConfig := songLinkRetryConfig()
|
||||||
resp, err := DoRequestWithRetry(s.client, req, retryConfig)
|
resp, err := DoRequestWithRetry(s.client, req, retryConfig)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to check availability: %w", err)
|
return nil, fmt.Errorf("song.link page lookup failed: %w; SongLink API lookup failed: %w", pageErr, err)
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
@@ -154,10 +167,10 @@ func (s *SongLinkClient) checkTrackAvailabilityFromSpotify(spotifyTrackID string
|
|||||||
return nil, fmt.Errorf("track not found on any streaming platform")
|
return nil, fmt.Errorf("track not found on any streaming platform")
|
||||||
}
|
}
|
||||||
if resp.StatusCode == 429 {
|
if resp.StatusCode == 429 {
|
||||||
return nil, fmt.Errorf("SongLink rate limit exceeded")
|
return nil, fmt.Errorf("song.link page lookup failed: %w; SongLink API rate limit exceeded", pageErr)
|
||||||
}
|
}
|
||||||
if resp.StatusCode != 200 {
|
if resp.StatusCode != 200 {
|
||||||
return nil, fmt.Errorf("SongLink API returned status %d", resp.StatusCode)
|
return nil, fmt.Errorf("song.link page lookup failed: %w; SongLink API returned status %d", pageErr, resp.StatusCode)
|
||||||
}
|
}
|
||||||
|
|
||||||
body, err := ReadResponseBody(resp)
|
body, err := ReadResponseBody(resp)
|
||||||
@@ -166,59 +179,102 @@ func (s *SongLinkClient) checkTrackAvailabilityFromSpotify(spotifyTrackID string
|
|||||||
}
|
}
|
||||||
|
|
||||||
var songLinkResp struct {
|
var songLinkResp struct {
|
||||||
LinksByPlatform map[string]struct {
|
LinksByPlatform map[string]songLinkPlatformLink `json:"linksByPlatform"`
|
||||||
URL string `json:"url"`
|
|
||||||
} `json:"linksByPlatform"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := json.Unmarshal(body, &songLinkResp); err != nil {
|
if err := json.Unmarshal(body, &songLinkResp); err != nil {
|
||||||
return nil, fmt.Errorf("failed to decode response: %w", err)
|
return nil, fmt.Errorf("failed to decode response: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
availability := &TrackAvailability{
|
LogWarn("SongLink", "Spotify %s resolved via SongLink API after song.link page failure: %v", spotifyTrackID, pageErr)
|
||||||
SpotifyID: spotifyTrackID,
|
return buildTrackAvailabilityFromSongLinkLinks(spotifyTrackID, songLinkResp.LinksByPlatform), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SongLinkClient) checkTrackAvailabilityFromSpotifyPage(spotifyTrackID string) (*TrackAvailability, error) {
|
||||||
|
pageURL := fmt.Sprintf("https://song.link/s/%s", spotifyTrackID)
|
||||||
|
req, err := http.NewRequest("GET", pageURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create song.link page request: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if tidalLink, ok := songLinkResp.LinksByPlatform["tidal"]; ok && tidalLink.URL != "" {
|
req.Header.Set("Accept", "text/html,application/xhtml+xml")
|
||||||
availability.Tidal = true
|
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||||
availability.TidalURL = tidalLink.URL
|
|
||||||
availability.TidalID = extractTidalIDFromURL(tidalLink.URL)
|
resp, err := s.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to fetch song.link page: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode == 404 {
|
||||||
|
return nil, fmt.Errorf("track not found on song.link page")
|
||||||
|
}
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
return nil, fmt.Errorf("song.link page returned status %d", resp.StatusCode)
|
||||||
}
|
}
|
||||||
|
|
||||||
if amazonLink, ok := songLinkResp.LinksByPlatform["amazonMusic"]; ok && amazonLink.URL != "" {
|
body, err := ReadResponseBody(resp)
|
||||||
availability.Amazon = true
|
if err != nil {
|
||||||
availability.AmazonURL = amazonLink.URL
|
return nil, fmt.Errorf("failed to read song.link page: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if deezerLink, ok := songLinkResp.LinksByPlatform["deezer"]; ok && deezerLink.URL != "" {
|
nextDataJSON, err := extractSongLinkNextDataJSON(body)
|
||||||
availability.Deezer = true
|
if err != nil {
|
||||||
availability.DeezerURL = deezerLink.URL
|
return nil, err
|
||||||
availability.DeezerID = extractDeezerIDFromURL(deezerLink.URL)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if qobuzLink, ok := songLinkResp.LinksByPlatform["qobuz"]; ok && qobuzLink.URL != "" {
|
var pageData struct {
|
||||||
availability.Qobuz = true
|
Props struct {
|
||||||
availability.QobuzURL = qobuzLink.URL
|
PageProps struct {
|
||||||
availability.QobuzID = extractQobuzIDFromURL(qobuzLink.URL)
|
PageData struct {
|
||||||
|
Sections []struct {
|
||||||
|
Links []struct {
|
||||||
|
Platform string `json:"platform"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
Show bool `json:"show"`
|
||||||
|
} `json:"links"`
|
||||||
|
} `json:"sections"`
|
||||||
|
} `json:"pageData"`
|
||||||
|
} `json:"pageProps"`
|
||||||
|
} `json:"props"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(nextDataJSON, &pageData); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to decode song.link page data: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prefer youtubeMusic URLs — they bypass Cobalt login requirements
|
linksByPlatform := make(map[string]songLinkPlatformLink)
|
||||||
if ytMusicLink, ok := songLinkResp.LinksByPlatform["youtubeMusic"]; ok && ytMusicLink.URL != "" {
|
for _, section := range pageData.Props.PageProps.PageData.Sections {
|
||||||
availability.YouTube = true
|
for _, link := range section.Links {
|
||||||
availability.YouTubeURL = ytMusicLink.URL
|
if !link.Show || strings.TrimSpace(link.URL) == "" {
|
||||||
availability.YouTubeID = extractYouTubeIDFromURL(ytMusicLink.URL)
|
continue
|
||||||
}
|
}
|
||||||
|
linksByPlatform[link.Platform] = songLinkPlatformLink{URL: link.URL}
|
||||||
// Fallback to regular youtube if youtubeMusic not available
|
|
||||||
if !availability.YouTube {
|
|
||||||
if youtubeLink, ok := songLinkResp.LinksByPlatform["youtube"]; ok && youtubeLink.URL != "" {
|
|
||||||
availability.YouTube = true
|
|
||||||
availability.YouTubeURL = youtubeLink.URL
|
|
||||||
availability.YouTubeID = extractYouTubeIDFromURL(youtubeLink.URL)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return availability, nil
|
if len(linksByPlatform) == 0 {
|
||||||
|
return nil, fmt.Errorf("song.link page contained no usable platform links")
|
||||||
|
}
|
||||||
|
|
||||||
|
return buildTrackAvailabilityFromSongLinkLinks(spotifyTrackID, linksByPlatform), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractSongLinkNextDataJSON(body []byte) ([]byte, error) {
|
||||||
|
const startMarker = `<script id="__NEXT_DATA__" type="application/json">`
|
||||||
|
const endMarker = `</script>`
|
||||||
|
|
||||||
|
start := bytes.Index(body, []byte(startMarker))
|
||||||
|
if start < 0 {
|
||||||
|
return nil, fmt.Errorf("song.link page missing __NEXT_DATA__")
|
||||||
|
}
|
||||||
|
start += len(startMarker)
|
||||||
|
|
||||||
|
end := bytes.Index(body[start:], []byte(endMarker))
|
||||||
|
if end < 0 {
|
||||||
|
return nil, fmt.Errorf("song.link page has unterminated __NEXT_DATA__")
|
||||||
|
}
|
||||||
|
|
||||||
|
return body[start : start+end], nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *SongLinkClient) checkTrackAvailabilityFromISRC(isrc string) (*TrackAvailability, error) {
|
func (s *SongLinkClient) checkTrackAvailabilityFromISRC(isrc string) (*TrackAvailability, error) {
|
||||||
@@ -459,7 +515,7 @@ func (s *SongLinkClient) CheckAlbumAvailability(spotifyAlbumID string) (*AlbumAv
|
|||||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
retryConfig := DefaultRetryConfig()
|
retryConfig := songLinkRetryConfig()
|
||||||
resp, err := DoRequestWithRetry(s.client, req, retryConfig)
|
resp, err := DoRequestWithRetry(s.client, req, retryConfig)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to check album availability: %w", err)
|
return nil, fmt.Errorf("failed to check album availability: %w", err)
|
||||||
@@ -542,7 +598,7 @@ func (s *SongLinkClient) checkAvailabilityFromDeezerSongLink(deezerTrackID strin
|
|||||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
retryConfig := DefaultRetryConfig()
|
retryConfig := songLinkRetryConfig()
|
||||||
resp, err := DoRequestWithRetry(s.client, req, retryConfig)
|
resp, err := DoRequestWithRetry(s.client, req, retryConfig)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to check availability: %w", err)
|
return nil, fmt.Errorf("failed to check availability: %w", err)
|
||||||
@@ -647,7 +703,7 @@ func (s *SongLinkClient) CheckAvailabilityByPlatform(platform, entityType, entit
|
|||||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
retryConfig := DefaultRetryConfig()
|
retryConfig := songLinkRetryConfig()
|
||||||
resp, err := DoRequestWithRetry(s.client, req, retryConfig)
|
resp, err := DoRequestWithRetry(s.client, req, retryConfig)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to check availability: %w", err)
|
return nil, fmt.Errorf("failed to check availability: %w", err)
|
||||||
@@ -728,6 +784,51 @@ func (s *SongLinkClient) CheckAvailabilityByPlatform(platform, entityType, entit
|
|||||||
return availability, nil
|
return availability, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func buildTrackAvailabilityFromSongLinkLinks(spotifyTrackID string, links map[string]songLinkPlatformLink) *TrackAvailability {
|
||||||
|
availability := &TrackAvailability{
|
||||||
|
SpotifyID: spotifyTrackID,
|
||||||
|
}
|
||||||
|
|
||||||
|
if availability.SpotifyID == "" {
|
||||||
|
if spotifyLink, ok := links["spotify"]; ok && spotifyLink.URL != "" {
|
||||||
|
availability.SpotifyID = extractSpotifyIDFromURL(spotifyLink.URL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if tidalLink, ok := links["tidal"]; ok && tidalLink.URL != "" {
|
||||||
|
availability.Tidal = true
|
||||||
|
availability.TidalURL = tidalLink.URL
|
||||||
|
availability.TidalID = extractTidalIDFromURL(tidalLink.URL)
|
||||||
|
}
|
||||||
|
if amazonLink, ok := links["amazonMusic"]; ok && amazonLink.URL != "" {
|
||||||
|
availability.Amazon = true
|
||||||
|
availability.AmazonURL = amazonLink.URL
|
||||||
|
}
|
||||||
|
if qobuzLink, ok := links["qobuz"]; ok && qobuzLink.URL != "" {
|
||||||
|
availability.Qobuz = true
|
||||||
|
availability.QobuzURL = qobuzLink.URL
|
||||||
|
availability.QobuzID = extractQobuzIDFromURL(qobuzLink.URL)
|
||||||
|
}
|
||||||
|
if deezerLink, ok := links["deezer"]; ok && deezerLink.URL != "" {
|
||||||
|
availability.Deezer = true
|
||||||
|
availability.DeezerURL = deezerLink.URL
|
||||||
|
availability.DeezerID = extractDeezerIDFromURL(deezerLink.URL)
|
||||||
|
}
|
||||||
|
if ytMusicLink, ok := links["youtubeMusic"]; ok && ytMusicLink.URL != "" {
|
||||||
|
availability.YouTube = true
|
||||||
|
availability.YouTubeURL = ytMusicLink.URL
|
||||||
|
availability.YouTubeID = extractYouTubeIDFromURL(ytMusicLink.URL)
|
||||||
|
}
|
||||||
|
if !availability.YouTube {
|
||||||
|
if youtubeLink, ok := links["youtube"]; ok && youtubeLink.URL != "" {
|
||||||
|
availability.YouTube = true
|
||||||
|
availability.YouTubeURL = youtubeLink.URL
|
||||||
|
availability.YouTubeID = extractYouTubeIDFromURL(youtubeLink.URL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return availability
|
||||||
|
}
|
||||||
|
|
||||||
func extractSpotifyIDFromURL(spotifyURL string) string {
|
func extractSpotifyIDFromURL(spotifyURL string) string {
|
||||||
parts := strings.Split(spotifyURL, "/track/")
|
parts := strings.Split(spotifyURL, "/track/")
|
||||||
if len(parts) > 1 {
|
if len(parts) > 1 {
|
||||||
@@ -802,7 +903,7 @@ func (s *SongLinkClient) CheckAvailabilityFromURL(inputURL string) (*TrackAvaila
|
|||||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
retryConfig := DefaultRetryConfig()
|
retryConfig := songLinkRetryConfig()
|
||||||
resp, err := DoRequestWithRetry(s.client, req, retryConfig)
|
resp, err := DoRequestWithRetry(s.client, req, retryConfig)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to check availability: %w", err)
|
return nil, fmt.Errorf("failed to check availability: %w", err)
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
package gobackend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
type roundTripFunc func(*http.Request) (*http.Response, error)
|
||||||
|
|
||||||
|
func (fn roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||||
|
return fn(req)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetRetryAfterDurationMissingHeaderReturnsZero(t *testing.T) {
|
||||||
|
resp := &http.Response{
|
||||||
|
Header: make(http.Header),
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := getRetryAfterDuration(resp); got != 0 {
|
||||||
|
t.Fatalf("getRetryAfterDuration() = %v, want 0", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckTrackAvailabilityFromSpotifyPrefersSongLinkPage(t *testing.T) {
|
||||||
|
client := &SongLinkClient{
|
||||||
|
client: &http.Client{
|
||||||
|
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||||
|
switch {
|
||||||
|
case req.URL.Host == "api.song.link":
|
||||||
|
t.Fatalf("api.song.link should not be called when song.link page succeeds")
|
||||||
|
return nil, nil
|
||||||
|
case req.URL.Host == "song.link" && req.URL.Path == "/s/testspotifyid":
|
||||||
|
body := `<!DOCTYPE html><html><body><script id="__NEXT_DATA__" type="application/json">{"props":{"pageProps":{"pageData":{"sections":[{"displayName":"Listen","links":[{"platform":"spotify","url":"https://open.spotify.com/track/testspotifyid","show":true},{"platform":"deezer","url":"https://www.deezer.com/track/908604612","show":true},{"platform":"amazonMusic","url":"https://music.amazon.com/albums/B086Q2QNLH?trackAsin=B086Q41M9C","show":true},{"platform":"tidal","url":"https://listen.tidal.com/track/134858527","show":true},{"platform":"qobuz","url":"https://open.qobuz.com/track/195125822","show":true},{"platform":"youtubeMusic","url":"https://music.youtube.com/watch?v=testvideoid1","show":true}]}]}}}}</script></body></html>`
|
||||||
|
return &http.Response{
|
||||||
|
StatusCode: 200,
|
||||||
|
Header: make(http.Header),
|
||||||
|
Body: io.NopCloser(strings.NewReader(body)),
|
||||||
|
Request: req,
|
||||||
|
}, nil
|
||||||
|
default:
|
||||||
|
t.Fatalf("unexpected request: %s", req.URL.String())
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
availability, err := client.CheckTrackAvailability("testspotifyid", "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CheckTrackAvailability() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if availability.SpotifyID != "testspotifyid" {
|
||||||
|
t.Fatalf("SpotifyID = %q, want %q", availability.SpotifyID, "testspotifyid")
|
||||||
|
}
|
||||||
|
if !availability.Deezer || availability.DeezerID != "908604612" {
|
||||||
|
t.Fatalf("Deezer availability = %+v, want DeezerID 908604612", availability)
|
||||||
|
}
|
||||||
|
if !availability.Amazon || !availability.Tidal || !availability.Qobuz || !availability.YouTube {
|
||||||
|
t.Fatalf("availability flags = %+v, want Amazon/Tidal/Qobuz/YouTube true", availability)
|
||||||
|
}
|
||||||
|
if availability.YouTubeID != "testvideoid1" {
|
||||||
|
t.Fatalf("YouTubeID = %q, want %q", availability.YouTubeID, "testvideoid1")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckTrackAvailabilityFromSpotifyFallsBackToAPIWhenPageFails(t *testing.T) {
|
||||||
|
origRetryConfig := songLinkRetryConfig
|
||||||
|
songLinkRetryConfig = func() RetryConfig {
|
||||||
|
return RetryConfig{
|
||||||
|
MaxRetries: 0,
|
||||||
|
InitialDelay: 0,
|
||||||
|
MaxDelay: 0,
|
||||||
|
BackoffFactor: 1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
songLinkRetryConfig = origRetryConfig
|
||||||
|
}()
|
||||||
|
|
||||||
|
client := &SongLinkClient{
|
||||||
|
client: &http.Client{
|
||||||
|
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||||
|
switch {
|
||||||
|
case req.URL.Host == "song.link" && req.URL.Path == "/s/testspotifyid":
|
||||||
|
return &http.Response{
|
||||||
|
StatusCode: 500,
|
||||||
|
Header: make(http.Header),
|
||||||
|
Body: io.NopCloser(strings.NewReader("page failure")),
|
||||||
|
Request: req,
|
||||||
|
}, nil
|
||||||
|
case req.URL.Host == "api.song.link":
|
||||||
|
body := `{"linksByPlatform":{"spotify":{"url":"https://open.spotify.com/track/testspotifyid"},"deezer":{"url":"https://www.deezer.com/track/908604612"},"amazonMusic":{"url":"https://music.amazon.com/albums/B086Q2QNLH?trackAsin=B086Q41M9C"},"tidal":{"url":"https://listen.tidal.com/track/134858527"},"qobuz":{"url":"https://open.qobuz.com/track/195125822"},"youtubeMusic":{"url":"https://music.youtube.com/watch?v=testvideoid1"}}}`
|
||||||
|
return &http.Response{
|
||||||
|
StatusCode: 200,
|
||||||
|
Header: make(http.Header),
|
||||||
|
Body: io.NopCloser(strings.NewReader(body)),
|
||||||
|
Request: req,
|
||||||
|
}, nil
|
||||||
|
default:
|
||||||
|
t.Fatalf("unexpected request: %s", req.URL.String())
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
availability, err := client.CheckTrackAvailability("testspotifyid", "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CheckTrackAvailability() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if availability.SpotifyID != "testspotifyid" {
|
||||||
|
t.Fatalf("SpotifyID = %q, want %q", availability.SpotifyID, "testspotifyid")
|
||||||
|
}
|
||||||
|
if !availability.Deezer || availability.DeezerID != "908604612" {
|
||||||
|
t.Fatalf("Deezer availability = %+v, want DeezerID 908604612", availability)
|
||||||
|
}
|
||||||
|
if !availability.Amazon || !availability.Tidal || !availability.Qobuz || !availability.YouTube {
|
||||||
|
t.Fatalf("availability flags = %+v, want Amazon/Tidal/Qobuz/YouTube true", availability)
|
||||||
|
}
|
||||||
|
if availability.YouTubeID != "testvideoid1" {
|
||||||
|
t.Fatalf("YouTubeID = %q, want %q", availability.YouTubeID, "testvideoid1")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,8 +3,8 @@ import 'package:flutter/foundation.dart';
|
|||||||
/// 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.8.6';
|
static const String version = '3.8.7';
|
||||||
static const String buildNumber = '112';
|
static const String buildNumber = '113';
|
||||||
static const String fullVersion = '$version+$buildNumber';
|
static const String fullVersion = '$version+$buildNumber';
|
||||||
|
|
||||||
/// Shows "Internal" in debug builds, actual version in release.
|
/// Shows "Internal" in debug builds, actual version in release.
|
||||||
|
|||||||
+3
-5
@@ -14,6 +14,7 @@ import 'package:spotiflac_android/providers/settings_provider.dart';
|
|||||||
import 'package:spotiflac_android/services/notification_service.dart';
|
import 'package:spotiflac_android/services/notification_service.dart';
|
||||||
import 'package:spotiflac_android/services/share_intent_service.dart';
|
import 'package:spotiflac_android/services/share_intent_service.dart';
|
||||||
import 'package:spotiflac_android/services/cover_cache_manager.dart';
|
import 'package:spotiflac_android/services/cover_cache_manager.dart';
|
||||||
|
import 'package:spotiflac_android/utils/local_library_scan_prefs.dart';
|
||||||
|
|
||||||
void main() async {
|
void main() async {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
@@ -100,8 +101,6 @@ class _EagerInitializationState extends ConsumerState<_EagerInitialization>
|
|||||||
bool _localLibraryWarmupScheduled = false;
|
bool _localLibraryWarmupScheduled = false;
|
||||||
bool _autoScanTriggeredOnLaunch = false;
|
bool _autoScanTriggeredOnLaunch = false;
|
||||||
|
|
||||||
static const _lastScannedAtKey = 'local_library_last_scanned_at';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
@@ -200,10 +199,9 @@ class _EagerInitializationState extends ConsumerState<_EagerInitialization>
|
|||||||
// Determine cooldown based on auto-scan mode.
|
// Determine cooldown based on auto-scan mode.
|
||||||
final now = DateTime.now();
|
final now = DateTime.now();
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
final lastScannedMs = prefs.getInt(_lastScannedAtKey);
|
final lastScanned = readLocalLibraryLastScannedAt(prefs);
|
||||||
|
|
||||||
if (lastScannedMs != null) {
|
if (lastScanned != null) {
|
||||||
final lastScanned = DateTime.fromMillisecondsSinceEpoch(lastScannedMs);
|
|
||||||
final elapsed = now.difference(lastScanned);
|
final elapsed = now.difference(lastScanned);
|
||||||
|
|
||||||
switch (settings.localLibraryAutoScan) {
|
switch (settings.localLibraryAutoScan) {
|
||||||
|
|||||||
@@ -1005,6 +1005,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
int _lastNotifPercent = -1;
|
int _lastNotifPercent = -1;
|
||||||
int _lastNotifQueueCount = -1;
|
int _lastNotifQueueCount = -1;
|
||||||
final Set<String> _locallyCancelledItemIds = {};
|
final Set<String> _locallyCancelledItemIds = {};
|
||||||
|
final Set<String> _pausePendingItemIds = {};
|
||||||
|
|
||||||
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();
|
||||||
@@ -1324,6 +1325,10 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
if (localItem == null) {
|
if (localItem == null) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
if (_isPausePending(itemId)) {
|
||||||
|
PlatformBridge.clearItemProgress(itemId).catchError((_) {});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if (localItem.status == DownloadStatus.skipped) {
|
if (localItem.status == DownloadStatus.skipped) {
|
||||||
PlatformBridge.clearItemProgress(itemId).catchError((_) {});
|
PlatformBridge.clearItemProgress(itemId).catchError((_) {});
|
||||||
continue;
|
continue;
|
||||||
@@ -2123,12 +2128,42 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
return resolved?.status == DownloadStatus.skipped;
|
return resolved?.status == DownloadStatus.skipped;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool _isPausePending(String id) => _pausePendingItemIds.contains(id);
|
||||||
|
|
||||||
|
void _requeueItemForPause(String id) {
|
||||||
|
final updatedItems = state.items
|
||||||
|
.map((item) {
|
||||||
|
if (item.id != id) return item;
|
||||||
|
if (item.status == DownloadStatus.completed ||
|
||||||
|
item.status == DownloadStatus.failed ||
|
||||||
|
item.status == DownloadStatus.skipped) {
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
return item.copyWith(
|
||||||
|
status: DownloadStatus.queued,
|
||||||
|
progress: 0,
|
||||||
|
speedMBps: 0,
|
||||||
|
bytesReceived: 0,
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.toList(growable: false);
|
||||||
|
|
||||||
|
final currentDownload = state.currentDownload?.id == id
|
||||||
|
? null
|
||||||
|
: state.currentDownload;
|
||||||
|
state = state.copyWith(
|
||||||
|
items: updatedItems,
|
||||||
|
currentDownload: currentDownload,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
void _requestNativeCancel(String id) {
|
void _requestNativeCancel(String id) {
|
||||||
PlatformBridge.cancelDownload(id).catchError((_) {});
|
PlatformBridge.cancelDownload(id).catchError((_) {});
|
||||||
PlatformBridge.clearItemProgress(id).catchError((_) {});
|
PlatformBridge.clearItemProgress(id).catchError((_) {});
|
||||||
}
|
}
|
||||||
|
|
||||||
void cancelItem(String id) {
|
void cancelItem(String id) {
|
||||||
|
_pausePendingItemIds.remove(id);
|
||||||
_locallyCancelledItemIds.add(id);
|
_locallyCancelledItemIds.add(id);
|
||||||
updateItemStatus(id, DownloadStatus.skipped);
|
updateItemStatus(id, DownloadStatus.skipped);
|
||||||
_requestNativeCancel(id);
|
_requestNativeCancel(id);
|
||||||
@@ -2161,6 +2196,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
.toList(growable: false);
|
.toList(growable: false);
|
||||||
|
|
||||||
if (activeIds.isNotEmpty) {
|
if (activeIds.isNotEmpty) {
|
||||||
|
_pausePendingItemIds.addAll(activeIds);
|
||||||
_locallyCancelledItemIds.addAll(activeIds);
|
_locallyCancelledItemIds.addAll(activeIds);
|
||||||
for (final id in activeIds) {
|
for (final id in activeIds) {
|
||||||
_requestNativeCancel(id);
|
_requestNativeCancel(id);
|
||||||
@@ -2173,11 +2209,29 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
if (!wasProcessing) {
|
if (!wasProcessing) {
|
||||||
_locallyCancelledItemIds.clear();
|
_locallyCancelledItemIds.clear();
|
||||||
}
|
}
|
||||||
|
_pausePendingItemIds.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
void pauseQueue() {
|
void pauseQueue() {
|
||||||
if (state.isProcessing && !state.isPaused) {
|
if (state.isProcessing && !state.isPaused) {
|
||||||
state = state.copyWith(isPaused: true);
|
final activeIds = state.items
|
||||||
|
.where(
|
||||||
|
(item) =>
|
||||||
|
item.status == DownloadStatus.downloading ||
|
||||||
|
item.status == DownloadStatus.finalizing,
|
||||||
|
)
|
||||||
|
.map((item) => item.id)
|
||||||
|
.toSet();
|
||||||
|
|
||||||
|
if (activeIds.isNotEmpty) {
|
||||||
|
_pausePendingItemIds.addAll(activeIds);
|
||||||
|
for (final id in activeIds) {
|
||||||
|
_requestNativeCancel(id);
|
||||||
|
_requeueItemForPause(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
state = state.copyWith(isPaused: true, currentDownload: null);
|
||||||
_notificationService.cancelDownloadNotification();
|
_notificationService.cancelDownloadNotification();
|
||||||
_log.i('Queue paused');
|
_log.i('Queue paused');
|
||||||
}
|
}
|
||||||
@@ -2379,7 +2433,11 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Deezer CDN cover size pattern: /WxH-0-0-0-0.jpg
|
||||||
|
static final _deezerSizeRegex = RegExp(r'/(\d+)x(\d+)-\d+-\d+-\d+-\d+\.jpg$');
|
||||||
|
|
||||||
String _upgradeToMaxQualityCover(String coverUrl) {
|
String _upgradeToMaxQualityCover(String coverUrl) {
|
||||||
|
// Spotify CDN upgrade (hash-based size identifiers)
|
||||||
const spotifySize300 = 'ab67616d00001e02';
|
const spotifySize300 = 'ab67616d00001e02';
|
||||||
const spotifySize640 = 'ab67616d0000b273';
|
const spotifySize640 = 'ab67616d0000b273';
|
||||||
const spotifySizeMax = 'ab67616d000082c1';
|
const spotifySizeMax = 'ab67616d000082c1';
|
||||||
@@ -2388,11 +2446,29 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
if (result.contains(spotifySize300)) {
|
if (result.contains(spotifySize300)) {
|
||||||
result = result.replaceFirst(spotifySize300, spotifySize640);
|
result = result.replaceFirst(spotifySize300, spotifySize640);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (result.contains(spotifySize640)) {
|
if (result.contains(spotifySize640)) {
|
||||||
result = result.replaceFirst(spotifySize640, spotifySizeMax);
|
result = result.replaceFirst(spotifySize640, spotifySizeMax);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Deezer CDN upgrade (1000x1000 → 1800x1800)
|
||||||
|
if (result.contains('cdn-images.dzcdn.net')) {
|
||||||
|
final upgraded = result.replaceFirst(
|
||||||
|
_deezerSizeRegex,
|
||||||
|
'/1800x1800-000000-80-0-0.jpg',
|
||||||
|
);
|
||||||
|
if (upgraded != result) {
|
||||||
|
_log.d('Cover URL upgraded (Deezer): 1800x1800');
|
||||||
|
result = upgraded;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tidal CDN upgrade (1280x1280 → origin)
|
||||||
|
if (result.contains('resources.tidal.com') &&
|
||||||
|
result.contains('/1280x1280.jpg')) {
|
||||||
|
result = result.replaceFirst('/1280x1280.jpg', '/origin.jpg');
|
||||||
|
_log.d('Cover URL upgraded (Tidal): origin');
|
||||||
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3246,7 +3322,11 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
final queuedItems = state.items
|
final queuedItems = state.items
|
||||||
.where((item) => item.status == DownloadStatus.queued)
|
.where(
|
||||||
|
(item) =>
|
||||||
|
item.status == DownloadStatus.queued &&
|
||||||
|
!_pausePendingItemIds.contains(item.id),
|
||||||
|
)
|
||||||
.toList();
|
.toList();
|
||||||
|
|
||||||
if (queuedItems.isEmpty && activeDownloads.isEmpty) {
|
if (queuedItems.isEmpty && activeDownloads.isEmpty) {
|
||||||
@@ -3291,11 +3371,13 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
_stopProgressPolling();
|
_stopProgressPolling();
|
||||||
final remainingIds = state.items.map((item) => item.id).toSet();
|
final remainingIds = state.items.map((item) => item.id).toSet();
|
||||||
_locallyCancelledItemIds.removeWhere((id) => !remainingIds.contains(id));
|
_locallyCancelledItemIds.removeWhere((id) => !remainingIds.contains(id));
|
||||||
|
_pausePendingItemIds.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}');
|
||||||
|
var pausedDuringThisRun = false;
|
||||||
|
|
||||||
final currentItem = _findItemById(item.id) ?? item;
|
final currentItem = _findItemById(item.id) ?? item;
|
||||||
if (_isLocallyCancelled(item.id, item: currentItem)) {
|
if (_isLocallyCancelled(item.id, item: currentItem)) {
|
||||||
@@ -3303,11 +3385,33 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (_isPausePending(item.id)) {
|
||||||
|
pausedDuringThisRun = true;
|
||||||
|
_requeueItemForPause(item.id);
|
||||||
|
_log.i('Download is pause-pending before start, skipping');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
state = state.copyWith(currentDownload: item);
|
state = state.copyWith(currentDownload: item);
|
||||||
|
|
||||||
updateItemStatus(item.id, DownloadStatus.downloading);
|
updateItemStatus(item.id, DownloadStatus.downloading);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
bool shouldAbortWork(String stage) {
|
||||||
|
final current = _findItemById(item.id);
|
||||||
|
if (_isLocallyCancelled(item.id, item: current)) {
|
||||||
|
_log.i('Download was cancelled $stage, skipping');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (_isPausePending(item.id)) {
|
||||||
|
pausedDuringThisRun = true;
|
||||||
|
_requeueItemForPause(item.id);
|
||||||
|
_log.i('Download pause requested $stage, re-queueing');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
final settings = ref.read(settingsProvider);
|
final settings = ref.read(settingsProvider);
|
||||||
final metadataEmbeddingEnabled = settings.embedMetadata;
|
final metadataEmbeddingEnabled = settings.embedMetadata;
|
||||||
|
|
||||||
@@ -3388,6 +3492,10 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
_log.w('Failed to enrich metadata: $e');
|
_log.w('Failed to enrich metadata: $e');
|
||||||
_log.w('Stack trace: $stack');
|
_log.w('Stack trace: $stack');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (shouldAbortWork('during metadata enrichment')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_log.d('Track coverUrl after enrichment: ${trackToDownload.coverUrl}');
|
_log.d('Track coverUrl after enrichment: ${trackToDownload.coverUrl}');
|
||||||
@@ -3501,6 +3609,10 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
_log.w('Failed to search Deezer by ISRC: $e');
|
_log.w('Failed to search Deezer by ISRC: $e');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (shouldAbortWork('during Deezer ISRC lookup')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback: Use SongLink to convert Spotify ID to Deezer ID
|
// Fallback: Use SongLink to convert Spotify ID to Deezer ID
|
||||||
@@ -3601,6 +3713,10 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
_log.w('Failed to convert Spotify to Deezer via SongLink: $e');
|
_log.w('Failed to convert Spotify to Deezer via SongLink: $e');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (shouldAbortWork('during SongLink availability lookup')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (deezerTrackId != null && deezerTrackId.isNotEmpty) {
|
if (deezerTrackId != null && deezerTrackId.isNotEmpty) {
|
||||||
@@ -3620,6 +3736,10 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
_log.w('Failed to fetch extended metadata from Deezer: $e');
|
_log.w('Failed to fetch extended metadata from Deezer: $e');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (shouldAbortWork('during extended metadata lookup')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Map<String, dynamic> result;
|
Map<String, dynamic> result;
|
||||||
@@ -3738,8 +3858,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (_isLocallyCancelled(item.id)) {
|
if (shouldAbortWork('before native download start')) {
|
||||||
_log.i('Download was cancelled before native download start, skipping');
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3781,10 +3900,8 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
_log.d('Result: $result');
|
_log.d('Result: $result');
|
||||||
|
|
||||||
final itemAfterResult = _findItemById(item.id);
|
final itemAfterResult = _findItemById(item.id);
|
||||||
final cancelledAfterResult =
|
if (itemAfterResult == null ||
|
||||||
itemAfterResult == null ||
|
_isLocallyCancelled(item.id, item: itemAfterResult)) {
|
||||||
_isLocallyCancelled(item.id, item: itemAfterResult);
|
|
||||||
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) {
|
||||||
@@ -3794,6 +3911,18 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (_isPausePending(item.id)) {
|
||||||
|
pausedDuringThisRun = true;
|
||||||
|
final filePath = result['file_path'] as String?;
|
||||||
|
if (filePath != null && result['success'] == true) {
|
||||||
|
await deleteFile(filePath);
|
||||||
|
_log.d('Deleted paused download file: $filePath');
|
||||||
|
}
|
||||||
|
_requeueItemForPause(item.id);
|
||||||
|
_log.i('Download pause requested after result, re-queueing');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (result['success'] == true) {
|
if (result['success'] == true) {
|
||||||
var filePath = result['file_path'] as String?;
|
var filePath = result['file_path'] as String?;
|
||||||
final reportedFileName = result['file_name'] as String?;
|
final reportedFileName = result['file_name'] as String?;
|
||||||
@@ -4327,6 +4456,19 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (_isPausePending(item.id)) {
|
||||||
|
pausedDuringThisRun = true;
|
||||||
|
if (filePath != null) {
|
||||||
|
await deleteFile(filePath);
|
||||||
|
_log.d(
|
||||||
|
'Deleted paused download file during finalization: $filePath',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
_requeueItemForPause(item.id);
|
||||||
|
_log.i('Download pause requested during finalization, re-queueing');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// SAF downloads should end with content URI. If we still have a
|
// SAF downloads should end with content URI. If we still have a
|
||||||
// transient FD path, recover URI from SAF metadata to keep history
|
// transient FD path, recover URI from SAF metadata to keep history
|
||||||
// dedup/exclusion stable.
|
// dedup/exclusion stable.
|
||||||
@@ -4594,11 +4736,26 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (_isPausePending(item.id)) {
|
||||||
|
pausedDuringThisRun = true;
|
||||||
|
_requeueItemForPause(item.id);
|
||||||
|
_log.i('Download pause requested after backend failure, re-queueing');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
final errorMsg = result['error'] as String? ?? 'Download failed';
|
final errorMsg = result['error'] as String? ?? 'Download failed';
|
||||||
final errorTypeStr = result['error_type'] as String? ?? 'unknown';
|
final errorTypeStr = result['error_type'] as String? ?? 'unknown';
|
||||||
if (errorTypeStr == 'cancelled') {
|
if (errorTypeStr == 'cancelled') {
|
||||||
_log.i('Download was cancelled by backend, skipping error handling');
|
if (_isPausePending(item.id)) {
|
||||||
updateItemStatus(item.id, DownloadStatus.skipped);
|
pausedDuringThisRun = true;
|
||||||
|
_requeueItemForPause(item.id);
|
||||||
|
_log.i('Download was paused by backend cancellation, re-queueing');
|
||||||
|
} else {
|
||||||
|
_log.i(
|
||||||
|
'Download was cancelled by backend, skipping error handling',
|
||||||
|
);
|
||||||
|
updateItemStatus(item.id, DownloadStatus.skipped);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4657,6 +4814,13 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (_isPausePending(item.id)) {
|
||||||
|
pausedDuringThisRun = true;
|
||||||
|
_requeueItemForPause(item.id);
|
||||||
|
_log.i('Download pause requested after exception, re-queueing');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
_log.e('Exception: $e', e, stackTrace);
|
_log.e('Exception: $e', e, stackTrace);
|
||||||
|
|
||||||
String errorMsg = e.toString();
|
String errorMsg = e.toString();
|
||||||
@@ -4682,6 +4846,10 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
} catch (cleanupErr) {
|
} catch (cleanupErr) {
|
||||||
_log.e('Post-exception connection cleanup failed: $cleanupErr');
|
_log.e('Post-exception connection cleanup failed: $cleanupErr');
|
||||||
}
|
}
|
||||||
|
} finally {
|
||||||
|
if (pausedDuringThisRun) {
|
||||||
|
_pausePendingItemIds.remove(item.id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,11 +9,11 @@ import 'package:spotiflac_android/services/library_database.dart';
|
|||||||
import 'package:spotiflac_android/services/notification_service.dart';
|
import 'package:spotiflac_android/services/notification_service.dart';
|
||||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||||
import 'package:spotiflac_android/utils/logger.dart';
|
import 'package:spotiflac_android/utils/logger.dart';
|
||||||
|
import 'package:spotiflac_android/utils/local_library_scan_prefs.dart';
|
||||||
import 'package:spotiflac_android/utils/path_match_keys.dart';
|
import 'package:spotiflac_android/utils/path_match_keys.dart';
|
||||||
|
|
||||||
final _log = AppLogger('LocalLibrary');
|
final _log = AppLogger('LocalLibrary');
|
||||||
|
|
||||||
const _lastScannedAtKey = 'local_library_last_scanned_at';
|
|
||||||
const _excludedDownloadedCountKey = 'local_library_excluded_downloaded_count';
|
const _excludedDownloadedCountKey = 'local_library_excluded_downloaded_count';
|
||||||
final _prefs = SharedPreferences.getInstance();
|
final _prefs = SharedPreferences.getInstance();
|
||||||
|
|
||||||
@@ -165,10 +165,7 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
|||||||
var excludedDownloadedCount = 0;
|
var excludedDownloadedCount = 0;
|
||||||
try {
|
try {
|
||||||
final prefs = await prefsFuture;
|
final prefs = await prefsFuture;
|
||||||
final lastScannedAtStr = prefs.getString(_lastScannedAtKey);
|
lastScannedAt = readLocalLibraryLastScannedAt(prefs);
|
||||||
if (lastScannedAtStr != null && lastScannedAtStr.isNotEmpty) {
|
|
||||||
lastScannedAt = DateTime.tryParse(lastScannedAtStr);
|
|
||||||
}
|
|
||||||
excludedDownloadedCount =
|
excludedDownloadedCount =
|
||||||
prefs.getInt(_excludedDownloadedCountKey) ?? 0;
|
prefs.getInt(_excludedDownloadedCountKey) ?? 0;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -336,7 +333,7 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
|||||||
final now = DateTime.now();
|
final now = DateTime.now();
|
||||||
try {
|
try {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
await prefs.setString(_lastScannedAtKey, now.toIso8601String());
|
await writeLocalLibraryLastScannedAt(prefs, now);
|
||||||
await prefs.setInt(_excludedDownloadedCountKey, skippedDownloads);
|
await prefs.setInt(_excludedDownloadedCountKey, skippedDownloads);
|
||||||
_log.d('Saved lastScannedAt: $now');
|
_log.d('Saved lastScannedAt: $now');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -500,7 +497,7 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
|||||||
final now = DateTime.now();
|
final now = DateTime.now();
|
||||||
try {
|
try {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
await prefs.setString(_lastScannedAtKey, now.toIso8601String());
|
await writeLocalLibraryLastScannedAt(prefs, now);
|
||||||
await prefs.setInt(_excludedDownloadedCountKey, skippedDownloads);
|
await prefs.setInt(_excludedDownloadedCountKey, skippedDownloads);
|
||||||
_log.d('Saved lastScannedAt: $now');
|
_log.d('Saved lastScannedAt: $now');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -818,7 +815,7 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
await prefs.remove(_lastScannedAtKey);
|
await clearLocalLibraryLastScannedAt(prefs);
|
||||||
await prefs.remove(_excludedDownloadedCountKey);
|
await prefs.remove(_excludedDownloadedCountKey);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
_log.w('Failed to clear lastScannedAt: $e');
|
_log.w('Failed to clear lastScannedAt: $e');
|
||||||
|
|||||||
@@ -872,12 +872,54 @@ class _LibraryTracksFolderScreenState
|
|||||||
|
|
||||||
void _downloadAll(List<Track> tracks) {
|
void _downloadAll(List<Track> tracks) {
|
||||||
if (tracks.isEmpty) return;
|
if (tracks.isEmpty) return;
|
||||||
|
final historyState = ref.read(downloadHistoryProvider);
|
||||||
final settings = ref.read(settingsProvider);
|
final settings = ref.read(settingsProvider);
|
||||||
final playlistName = widget.mode == LibraryTracksFolderMode.playlist ? playlist?.name ?? context.l10n.collectionPlaylist : null;
|
final localLibState =
|
||||||
|
(settings.localLibraryEnabled && settings.localLibraryShowDuplicates)
|
||||||
|
? ref.read(localLibraryProvider)
|
||||||
|
: null;
|
||||||
|
final playlistName = widget.mode == LibraryTracksFolderMode.playlist
|
||||||
|
? playlist?.name ?? context.l10n.collectionPlaylist
|
||||||
|
: null;
|
||||||
|
final tracksToQueue = <Track>[];
|
||||||
|
var skippedCount = 0;
|
||||||
|
|
||||||
|
for (final track in tracks) {
|
||||||
|
final isInHistory =
|
||||||
|
historyState.isDownloaded(track.id) ||
|
||||||
|
(track.isrc != null && historyState.getByIsrc(track.isrc!) != null) ||
|
||||||
|
historyState.findByTrackAndArtist(track.name, track.artistName) !=
|
||||||
|
null;
|
||||||
|
final isInLocal =
|
||||||
|
localLibState?.existsInLibrary(
|
||||||
|
isrc: track.isrc,
|
||||||
|
trackName: track.name,
|
||||||
|
artistName: track.artistName,
|
||||||
|
) ??
|
||||||
|
false;
|
||||||
|
|
||||||
|
if (isInHistory || isInLocal) {
|
||||||
|
skippedCount++;
|
||||||
|
} else {
|
||||||
|
tracksToQueue.add(track);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tracksToQueue.isEmpty) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(
|
||||||
|
context.l10n.discographySkippedDownloaded(0, skippedCount),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (settings.askQualityBeforeDownload) {
|
if (settings.askQualityBeforeDownload) {
|
||||||
DownloadServicePicker.show(
|
DownloadServicePicker.show(
|
||||||
context,
|
context,
|
||||||
trackName: '${tracks.length} tracks',
|
trackName: '${tracksToQueue.length} tracks',
|
||||||
artistName: switch (widget.mode) {
|
artistName: switch (widget.mode) {
|
||||||
LibraryTracksFolderMode.wishlist => context.l10n.collectionWishlist,
|
LibraryTracksFolderMode.wishlist => context.l10n.collectionWishlist,
|
||||||
LibraryTracksFolderMode.loved => context.l10n.collectionLoved,
|
LibraryTracksFolderMode.loved => context.l10n.collectionLoved,
|
||||||
@@ -886,12 +928,24 @@ class _LibraryTracksFolderScreenState
|
|||||||
onSelect: (quality, service) {
|
onSelect: (quality, service) {
|
||||||
ref
|
ref
|
||||||
.read(downloadQueueProvider.notifier)
|
.read(downloadQueueProvider.notifier)
|
||||||
.addMultipleToQueue(tracks, service, qualityOverride: quality, playlistName: playlistName);
|
.addMultipleToQueue(
|
||||||
|
tracksToQueue,
|
||||||
|
service,
|
||||||
|
qualityOverride: quality,
|
||||||
|
playlistName: playlistName,
|
||||||
|
);
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
content: Text(
|
content: Text(
|
||||||
context.l10n.snackbarAddedTracksToQueue(tracks.length),
|
skippedCount > 0
|
||||||
|
? context.l10n.discographySkippedDownloaded(
|
||||||
|
tracksToQueue.length,
|
||||||
|
skippedCount,
|
||||||
|
)
|
||||||
|
: context.l10n.snackbarAddedTracksToQueue(
|
||||||
|
tracksToQueue.length,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -900,10 +954,21 @@ class _LibraryTracksFolderScreenState
|
|||||||
} else {
|
} else {
|
||||||
ref
|
ref
|
||||||
.read(downloadQueueProvider.notifier)
|
.read(downloadQueueProvider.notifier)
|
||||||
.addMultipleToQueue(tracks, settings.defaultService, playlistName: playlistName);
|
.addMultipleToQueue(
|
||||||
|
tracksToQueue,
|
||||||
|
settings.defaultService,
|
||||||
|
playlistName: playlistName,
|
||||||
|
);
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
content: Text(context.l10n.snackbarAddedTracksToQueue(tracks.length)),
|
content: Text(
|
||||||
|
skippedCount > 0
|
||||||
|
? context.l10n.discographySkippedDownloaded(
|
||||||
|
tracksToQueue.length,
|
||||||
|
skippedCount,
|
||||||
|
)
|
||||||
|
: context.l10n.snackbarAddedTracksToQueue(tracksToQueue.length),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -626,11 +626,13 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
|||||||
|
|
||||||
slivers.add(
|
slivers.add(
|
||||||
SliverList(
|
SliverList(
|
||||||
delegate: SliverChildBuilderDelegate(
|
delegate: SliverChildBuilderDelegate((context, index) {
|
||||||
(context, index) =>
|
final track = discTracks[index];
|
||||||
_buildTrackItem(context, colorScheme, discTracks[index]),
|
return KeyedSubtree(
|
||||||
childCount: discTracks.length,
|
key: ValueKey(track.id),
|
||||||
),
|
child: _buildTrackItem(context, colorScheme, track),
|
||||||
|
);
|
||||||
|
}, childCount: discTracks.length),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -900,16 +902,19 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _queueSelectedAsFlac(List<LocalLibraryItem> allTracks) async {
|
List<LocalLibraryItem> _selectedFlacEligibleItems(
|
||||||
|
List<LocalLibraryItem> allTracks,
|
||||||
|
) {
|
||||||
final tracksById = {for (final t in allTracks) t.id: t};
|
final tracksById = {for (final t in allTracks) t.id: t};
|
||||||
final selected = <LocalLibraryItem>[];
|
return _selectedIds
|
||||||
|
.map((id) => tracksById[id])
|
||||||
|
.whereType<LocalLibraryItem>()
|
||||||
|
.where(LocalTrackRedownloadService.isFlacUpgradeEligible)
|
||||||
|
.toList(growable: false);
|
||||||
|
}
|
||||||
|
|
||||||
for (final id in _selectedIds) {
|
Future<void> _queueSelectedAsFlac(List<LocalLibraryItem> allTracks) async {
|
||||||
final item = tracksById[id];
|
final selected = _selectedFlacEligibleItems(allTracks);
|
||||||
if (item != null) {
|
|
||||||
selected.add(item);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (selected.isEmpty) {
|
if (selected.isEmpty) {
|
||||||
return;
|
return;
|
||||||
@@ -962,9 +967,7 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
|||||||
ScaffoldMessenger.of(context).clearSnackBars();
|
ScaffoldMessenger.of(context).clearSnackBars();
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
content: Text(
|
content: Text(context.l10n.queueFlacFindingProgress(i + 1, total)),
|
||||||
context.l10n.queueFlacFindingProgress(i + 1, total),
|
|
||||||
),
|
|
||||||
duration: const Duration(seconds: 30),
|
duration: const Duration(seconds: 30),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -1177,8 +1180,9 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
|||||||
String selectedFormat = formats.first;
|
String selectedFormat = formats.first;
|
||||||
bool isLosslessTarget =
|
bool isLosslessTarget =
|
||||||
selectedFormat == 'ALAC' || selectedFormat == 'FLAC';
|
selectedFormat == 'ALAC' || selectedFormat == 'FLAC';
|
||||||
String selectedBitrate =
|
String selectedBitrate = isLosslessTarget
|
||||||
isLosslessTarget ? '320k' : (selectedFormat == 'Opus' ? '128k' : '320k');
|
? '320k'
|
||||||
|
: (selectedFormat == 'Opus' ? '128k' : '320k');
|
||||||
|
|
||||||
showModalBottomSheet(
|
showModalBottomSheet(
|
||||||
context: context,
|
context: context,
|
||||||
@@ -1240,8 +1244,9 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
|||||||
isLosslessTarget =
|
isLosslessTarget =
|
||||||
format == 'ALAC' || format == 'FLAC';
|
format == 'ALAC' || format == 'FLAC';
|
||||||
if (!isLosslessTarget) {
|
if (!isLosslessTarget) {
|
||||||
selectedBitrate =
|
selectedBitrate = format == 'Opus'
|
||||||
format == 'Opus' ? '128k' : '320k';
|
? '128k'
|
||||||
|
: '320k';
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1286,11 +1291,8 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
|||||||
const SizedBox(width: 6),
|
const SizedBox(width: 6),
|
||||||
Text(
|
Text(
|
||||||
context.l10n.trackConvertLosslessHint,
|
context.l10n.trackConvertLosslessHint,
|
||||||
style: Theme.of(
|
style: Theme.of(context).textTheme.bodySmall
|
||||||
context,
|
?.copyWith(color: colorScheme.primary),
|
||||||
).textTheme.bodySmall?.copyWith(
|
|
||||||
color: colorScheme.primary,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -1371,7 +1373,8 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
|||||||
if (currentFormat == null || currentFormat == targetFormat) continue;
|
if (currentFormat == null || currentFormat == targetFormat) continue;
|
||||||
// Skip lossy sources when target is lossless (pointless re-encoding)
|
// Skip lossy sources when target is lossless (pointless re-encoding)
|
||||||
final isLosslessTarget = targetFormat == 'ALAC' || targetFormat == 'FLAC';
|
final isLosslessTarget = targetFormat == 'ALAC' || targetFormat == 'FLAC';
|
||||||
final isLosslessSource = currentFormat == 'FLAC' || currentFormat == 'M4A';
|
final isLosslessSource =
|
||||||
|
currentFormat == 'FLAC' || currentFormat == 'M4A';
|
||||||
if (isLosslessTarget && !isLosslessSource) continue;
|
if (isLosslessTarget && !isLosslessSource) continue;
|
||||||
selected.add(item);
|
selected.add(item);
|
||||||
}
|
}
|
||||||
@@ -1656,6 +1659,7 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
|||||||
double bottomPadding,
|
double bottomPadding,
|
||||||
) {
|
) {
|
||||||
final selectedCount = _selectedIds.length;
|
final selectedCount = _selectedIds.length;
|
||||||
|
final flacEligibleCount = _selectedFlacEligibleItems(tracks).length;
|
||||||
final allSelected = selectedCount == tracks.length && tracks.isNotEmpty;
|
final allSelected = selectedCount == tracks.length && tracks.isNotEmpty;
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
@@ -1747,17 +1751,18 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
|||||||
|
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
if (flacEligibleCount > 0) ...[
|
||||||
child: _LocalAlbumSelectionActionButton(
|
Expanded(
|
||||||
icon: Icons.download_for_offline_outlined,
|
child: _LocalAlbumSelectionActionButton(
|
||||||
label: '${context.l10n.queueFlacAction} ($selectedCount)',
|
icon: Icons.download_for_offline_outlined,
|
||||||
onPressed: selectedCount > 0
|
label:
|
||||||
? () => _queueSelectedAsFlac(tracks)
|
'${context.l10n.queueFlacAction} ($flacEligibleCount)',
|
||||||
: null,
|
onPressed: () => _queueSelectedAsFlac(tracks),
|
||||||
colorScheme: colorScheme,
|
colorScheme: colorScheme,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
const SizedBox(width: 8),
|
||||||
const SizedBox(width: 8),
|
],
|
||||||
Expanded(
|
Expanded(
|
||||||
child: _LocalAlbumSelectionActionButton(
|
child: _LocalAlbumSelectionActionButton(
|
||||||
icon: Icons.auto_fix_high_outlined,
|
icon: Icons.auto_fix_high_outlined,
|
||||||
|
|||||||
+29
-23
@@ -4484,14 +4484,21 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
List<LocalLibraryItem> _selectedFlacEligibleLocalItems(
|
||||||
|
List<UnifiedLibraryItem> allItems,
|
||||||
|
) {
|
||||||
|
final selectedItems = _selectedItemsFromAll(allItems);
|
||||||
|
return selectedItems
|
||||||
|
.map((item) => item.localItem)
|
||||||
|
.whereType<LocalLibraryItem>()
|
||||||
|
.where(LocalTrackRedownloadService.isFlacUpgradeEligible)
|
||||||
|
.toList(growable: false);
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _queueSelectedLocalAsFlac(
|
Future<void> _queueSelectedLocalAsFlac(
|
||||||
List<UnifiedLibraryItem> allItems,
|
List<UnifiedLibraryItem> allItems,
|
||||||
) async {
|
) async {
|
||||||
final selectedItems = _selectedItemsFromAll(allItems);
|
final selectedLocalItems = _selectedFlacEligibleLocalItems(allItems);
|
||||||
final selectedLocalItems = selectedItems
|
|
||||||
.map((item) => item.localItem)
|
|
||||||
.whereType<LocalLibraryItem>()
|
|
||||||
.toList(growable: false);
|
|
||||||
|
|
||||||
if (selectedLocalItems.isEmpty) {
|
if (selectedLocalItems.isEmpty) {
|
||||||
return;
|
return;
|
||||||
@@ -4546,9 +4553,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
ScaffoldMessenger.of(context).clearSnackBars();
|
ScaffoldMessenger.of(context).clearSnackBars();
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
content: Text(
|
content: Text(context.l10n.queueFlacFindingProgress(i + 1, total)),
|
||||||
context.l10n.queueFlacFindingProgress(i + 1, total),
|
|
||||||
),
|
|
||||||
duration: const Duration(seconds: 30),
|
duration: const Duration(seconds: 30),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -4797,8 +4802,9 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
String selectedFormat = formats.first;
|
String selectedFormat = formats.first;
|
||||||
bool isLosslessTarget =
|
bool isLosslessTarget =
|
||||||
selectedFormat == 'ALAC' || selectedFormat == 'FLAC';
|
selectedFormat == 'ALAC' || selectedFormat == 'FLAC';
|
||||||
String selectedBitrate =
|
String selectedBitrate = isLosslessTarget
|
||||||
isLosslessTarget ? '320k' : (selectedFormat == 'Opus' ? '128k' : '320k');
|
? '320k'
|
||||||
|
: (selectedFormat == 'Opus' ? '128k' : '320k');
|
||||||
var didStartConversion = false;
|
var didStartConversion = false;
|
||||||
|
|
||||||
_hideSelectionOverlay();
|
_hideSelectionOverlay();
|
||||||
@@ -4864,8 +4870,9 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
isLosslessTarget =
|
isLosslessTarget =
|
||||||
format == 'ALAC' || format == 'FLAC';
|
format == 'ALAC' || format == 'FLAC';
|
||||||
if (!isLosslessTarget) {
|
if (!isLosslessTarget) {
|
||||||
selectedBitrate =
|
selectedBitrate = format == 'Opus'
|
||||||
format == 'Opus' ? '128k' : '320k';
|
? '128k'
|
||||||
|
: '320k';
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -4910,11 +4917,8 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
const SizedBox(width: 6),
|
const SizedBox(width: 6),
|
||||||
Text(
|
Text(
|
||||||
context.l10n.trackConvertLosslessHint,
|
context.l10n.trackConvertLosslessHint,
|
||||||
style: Theme.of(
|
style: Theme.of(context).textTheme.bodySmall
|
||||||
context,
|
?.copyWith(color: colorScheme.primary),
|
||||||
).textTheme.bodySmall?.copyWith(
|
|
||||||
color: colorScheme.primary,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -5054,7 +5058,8 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
int successCount = 0;
|
int successCount = 0;
|
||||||
final total = selectedItems.length;
|
final total = selectedItems.length;
|
||||||
final historyDb = HistoryDatabase.instance;
|
final historyDb = HistoryDatabase.instance;
|
||||||
final newQuality = (targetFormat.toUpperCase() == 'ALAC' ||
|
final newQuality =
|
||||||
|
(targetFormat.toUpperCase() == 'ALAC' ||
|
||||||
targetFormat.toUpperCase() == 'FLAC')
|
targetFormat.toUpperCase() == 'FLAC')
|
||||||
? '${targetFormat.toUpperCase()} Lossless'
|
? '${targetFormat.toUpperCase()} Lossless'
|
||||||
: '${targetFormat.toUpperCase()} ${bitrate.trim().toLowerCase()}';
|
: '${targetFormat.toUpperCase()} ${bitrate.trim().toLowerCase()}';
|
||||||
@@ -5375,6 +5380,9 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
final allSelected =
|
final allSelected =
|
||||||
selectedCount == unifiedItems.length && unifiedItems.isNotEmpty;
|
selectedCount == unifiedItems.length && unifiedItems.isNotEmpty;
|
||||||
final localOnlySelection = _isLocalOnlySelection(unifiedItems);
|
final localOnlySelection = _isLocalOnlySelection(unifiedItems);
|
||||||
|
final flacEligibleCount = _selectedFlacEligibleLocalItems(
|
||||||
|
unifiedItems,
|
||||||
|
).length;
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
@@ -5464,15 +5472,13 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
// Action buttons row: Share/Re-enrich, Convert, Delete
|
// Action buttons row: Share/Re-enrich, Convert, Delete
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
if (localOnlySelection) ...[
|
if (localOnlySelection && flacEligibleCount > 0) ...[
|
||||||
Expanded(
|
Expanded(
|
||||||
child: _SelectionActionButton(
|
child: _SelectionActionButton(
|
||||||
icon: Icons.download_for_offline_outlined,
|
icon: Icons.download_for_offline_outlined,
|
||||||
label:
|
label:
|
||||||
'${context.l10n.queueFlacAction} ($selectedCount)',
|
'${context.l10n.queueFlacAction} ($flacEligibleCount)',
|
||||||
onPressed: selectedCount > 0
|
onPressed: () => _queueSelectedLocalAsFlac(unifiedItems),
|
||||||
? () => _queueSelectedLocalAsFlac(unifiedItems)
|
|
||||||
: null,
|
|
||||||
colorScheme: colorScheme,
|
colorScheme: colorScheme,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -23,6 +23,15 @@ class LocalTrackRedownloadService {
|
|||||||
static const int _minimumConfidenceScore = 85;
|
static const int _minimumConfidenceScore = 85;
|
||||||
static const int _ambiguousScoreGap = 8;
|
static const int _ambiguousScoreGap = 8;
|
||||||
|
|
||||||
|
static bool isFlacUpgradeEligible(LocalLibraryItem item) {
|
||||||
|
final format = item.format?.trim().toLowerCase();
|
||||||
|
if (format == 'flac') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return !item.filePath.toLowerCase().endsWith('.flac');
|
||||||
|
}
|
||||||
|
|
||||||
static Future<LocalTrackRedownloadResolution> resolveBestMatch(
|
static Future<LocalTrackRedownloadResolution> resolveBestMatch(
|
||||||
LocalLibraryItem item, {
|
LocalLibraryItem item, {
|
||||||
required bool includeExtensions,
|
required bool includeExtensions,
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
const localLibraryLastScannedAtKey = 'local_library_last_scanned_at';
|
||||||
|
|
||||||
|
DateTime? readLocalLibraryLastScannedAt(SharedPreferences prefs) {
|
||||||
|
final lastScannedAtStr = prefs.getString(localLibraryLastScannedAtKey);
|
||||||
|
if (lastScannedAtStr != null && lastScannedAtStr.isNotEmpty) {
|
||||||
|
return DateTime.tryParse(lastScannedAtStr);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Backward compatibility for older builds that may have stored epoch millis.
|
||||||
|
final lastScannedAtMs = prefs.getInt(localLibraryLastScannedAtKey);
|
||||||
|
if (lastScannedAtMs != null) {
|
||||||
|
return DateTime.fromMillisecondsSinceEpoch(lastScannedAtMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> writeLocalLibraryLastScannedAt(
|
||||||
|
SharedPreferences prefs,
|
||||||
|
DateTime value,
|
||||||
|
) {
|
||||||
|
return prefs.setString(localLibraryLastScannedAtKey, value.toIso8601String());
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> clearLocalLibraryLastScannedAt(SharedPreferences prefs) {
|
||||||
|
return prefs.remove(localLibraryLastScannedAtKey);
|
||||||
|
}
|
||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
name: spotiflac_android
|
name: spotiflac_android
|
||||||
description: Download Spotify tracks in FLAC from Tidal, Qobuz & Deezer
|
description: Download Spotify tracks in FLAC from Tidal, Qobuz & Deezer
|
||||||
publish_to: "none"
|
publish_to: "none"
|
||||||
version: 3.8.6+112
|
version: 3.8.7+113
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.10.0
|
sdk: ^3.10.0
|
||||||
|
|||||||
Reference in New Issue
Block a user