Compare commits

...

8 Commits

Author SHA1 Message Date
zarzet 23cab16471 feat: enable Tidal ISRC and metadata search 2026-03-18 18:14:01 +07:00
zarzet 0a892011de refactor: migrate lyrics providers to Paxsenix endpoints 2026-03-18 17:11:17 +07:00
zarzet acb1d957d3 feat: add McNuggets Jimmy as supporter 2026-03-18 17:10:44 +07:00
zarzet 4a492aeefc chore: bump version to 3.8.8+114 2026-03-18 01:23:55 +07:00
zarzet eb143a41fc refactor: remove redundant comments and fix setMetadataSource bug
- Fix setMetadataSource always returning 'deezer' regardless of input parameter
- Remove self-evident doc comments that restate method/class names across
  app_theme, dynamic_color_wrapper, cover_cache_manager, history_database,
  library_database, and download_service_picker
- Remove stale migration inline notes (// 12 -> 16, // 20 -> 16, etc.) from app_theme
- Remove trivial section-label comments in queue_tab batch conversion method
- Remove duplicate 'wait up to 5 seconds' comment in main_shell
2026-03-18 01:12:16 +07:00
zarzet 75db2f162b fix: improve extension download reliability and Qobuz API integration
- Add dedicated long-timeout download client (24h) for extension file downloads,
  preventing timeouts on large lossless audio files
- Skip unnecessary SongLink Deezer prelookup when an extension download provider
  handles the track, reducing latency and avoiding spurious API failures
- Prefer native track ID over Spotify ID when a source/provider is set, ensuring
  extension providers receive their own IDs correctly
- Update Qobuz MusicDL API endpoint and switch payload URL to open.qobuz.com
- Extract buildQobuzMusicDLPayload helper and add test coverage
2026-03-18 01:06:22 +07:00
zarzet 855d0e3ffc feat: add zcc09 as supporter (thank you) 2026-03-18 00:19:36 +07:00
zarzet 5ccd06cc68 fix: stabilize library scan IDs, pause queue behavior, and scan race condition
- Generate stable SHA-1 based IDs for SAF-scanned library items to prevent null ID crashes on the Dart side
- Suppress false queue-complete notification when user pauses instead of finishing the queue, and break out of parallel loop immediately when paused with no active downloads
- Use SQLite as the single source of truth for library scan results to fix a race condition where auto-scan could fire before provider state finished loading, dropping unchanged rows
2026-03-17 23:54:49 +07:00
26 changed files with 590 additions and 487 deletions
@@ -30,6 +30,7 @@ import org.json.JSONObject
import java.io.File import java.io.File
import java.io.FileInputStream import java.io.FileInputStream
import java.io.FileOutputStream import java.io.FileOutputStream
import java.security.MessageDigest
import java.util.Locale import java.util.Locale
class MainActivity: FlutterFragmentActivity() { class MainActivity: FlutterFragmentActivity() {
@@ -111,6 +112,13 @@ class MainActivity: FlutterFragmentActivity() {
} }
} }
private fun buildStableLibraryId(filePath: String): String {
val digest = MessageDigest.getInstance("SHA-1")
val bytes = digest.digest(filePath.toByteArray(Charsets.UTF_8))
val hex = bytes.joinToString("") { "%02x".format(it) }
return "lib_$hex"
}
data class SafScanProgress( data class SafScanProgress(
var totalFiles: Int = 0, var totalFiles: Int = 0,
var scannedFiles: Int = 0, var scannedFiles: Int = 0,
@@ -1263,7 +1271,9 @@ class MainActivity: FlutterFragmentActivity() {
} else { } else {
try { try {
val lastModified = try { doc.lastModified() } catch (_: Exception) { 0L } val lastModified = try { doc.lastModified() } catch (_: Exception) { 0L }
metadataObj.put("filePath", doc.uri.toString()) val stableUri = doc.uri.toString()
metadataObj.put("id", buildStableLibraryId(stableUri))
metadataObj.put("filePath", stableUri)
metadataObj.put("fileModTime", lastModified) metadataObj.put("fileModTime", lastModified)
results.put(metadataObj) results.put(metadataObj)
} catch (_: Exception) { } catch (_: Exception) {
@@ -1680,7 +1690,9 @@ class MainActivity: FlutterFragmentActivity() {
} else { } else {
try { try {
val safeLastModified = try { doc.lastModified() } catch (_: Exception) { lastModified } val safeLastModified = try { doc.lastModified() } catch (_: Exception) { lastModified }
metadataObj.put("filePath", doc.uri.toString()) val stableUri = doc.uri.toString()
metadataObj.put("id", buildStableLibraryId(stableUri))
metadataObj.put("filePath", stableUri)
metadataObj.put("fileModTime", safeLastModified) metadataObj.put("fileModTime", safeLastModified)
metadataObj.put("lastModified", safeLastModified) metadataObj.put("lastModified", safeLastModified)
results.put(metadataObj) results.put(metadataObj)
+17 -11
View File
@@ -81,13 +81,14 @@ func SetExtensionTokens(extensionID string, accessToken, refreshToken string, ex
} }
type ExtensionRuntime struct { type ExtensionRuntime struct {
extensionID string extensionID string
manifest *ExtensionManifest manifest *ExtensionManifest
settings map[string]interface{} settings map[string]interface{}
httpClient *http.Client httpClient *http.Client
cookieJar http.CookieJar downloadClient *http.Client
dataDir string cookieJar http.CookieJar
vm *goja.Runtime dataDir string
vm *goja.Runtime
storageMu sync.RWMutex storageMu sync.RWMutex
storageCache map[string]interface{} storageCache map[string]interface{}
@@ -132,13 +133,20 @@ func NewExtensionRuntime(ext *LoadedExtension) *ExtensionRuntime {
storageFlushDelay: defaultStorageFlushDelay, storageFlushDelay: defaultStorageFlushDelay,
} }
runtime.httpClient = newExtensionHTTPClient(ext, jar, 30*time.Second)
runtime.downloadClient = newExtensionHTTPClient(ext, jar, DownloadTimeout)
return runtime
}
func newExtensionHTTPClient(ext *LoadedExtension, jar http.CookieJar, timeout time.Duration) *http.Client {
// Extension sandbox enforces HTTPS-only domains. Do not apply global // Extension sandbox enforces HTTPS-only domains. Do not apply global
// allow_http scheme downgrade here, because some extension APIs (e.g. // allow_http scheme downgrade here, because some extension APIs (e.g.
// spotify-web) will redirect http -> https and can end up in 301 loops. // spotify-web) will redirect http -> https and can end up in 301 loops.
// We still reuse sharedTransport so insecure TLS compatibility mode remains effective. // We still reuse sharedTransport so insecure TLS compatibility mode remains effective.
client := &http.Client{ client := &http.Client{
Transport: sharedTransport, Transport: sharedTransport,
Timeout: 30 * time.Second, Timeout: timeout,
Jar: jar, Jar: jar,
} }
client.CheckRedirect = func(req *http.Request, via []*http.Request) error { client.CheckRedirect = func(req *http.Request, via []*http.Request) error {
@@ -165,9 +173,7 @@ func NewExtensionRuntime(ext *LoadedExtension) *ExtensionRuntime {
} }
return nil return nil
} }
runtime.httpClient = client return client
return runtime
} }
type RedirectBlockedError struct { type RedirectBlockedError struct {
+6 -1
View File
@@ -174,7 +174,12 @@ func (r *ExtensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
req.Header.Set("User-Agent", "SpotiFLAC-Extension/1.0") req.Header.Set("User-Agent", "SpotiFLAC-Extension/1.0")
} }
resp, err := r.httpClient.Do(req) client := r.downloadClient
if client == nil {
client = r.httpClient
}
resp, err := client.Do(req)
if err != nil { if err != nil {
return r.vm.ToValue(map[string]interface{}{ return r.vm.ToValue(map[string]interface{}{
"success": false, "success": false,
+107 -64
View File
@@ -3,6 +3,7 @@ package gobackend
import ( import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"io"
"math" "math"
"net/http" "net/http"
"net/url" "net/url"
@@ -121,12 +122,12 @@ func GetLyricsProviderOrder() []string {
// GetAvailableLyricsProviders returns metadata about all available providers. // GetAvailableLyricsProviders returns metadata about all available providers.
func GetAvailableLyricsProviders() []map[string]interface{} { func GetAvailableLyricsProviders() []map[string]interface{} {
return []map[string]interface{}{ return []map[string]interface{}{
{"id": LyricsProviderSpotifyAPI, "name": "Spotify Lyrics API", "has_proxy_dependency": true, "description": "Spotify-sourced synced lyrics via community API"}, {"id": LyricsProviderSpotifyAPI, "name": "Spotify Lyrics API", "has_proxy_dependency": true, "description": "Spotify-sourced lyrics via Paxsenix"},
{"id": LyricsProviderLRCLIB, "name": "LRCLIB", "has_proxy_dependency": false, "description": "Open-source synced lyrics database"}, {"id": LyricsProviderLRCLIB, "name": "LRCLIB", "has_proxy_dependency": false, "description": "Open-source synced lyrics database"},
{"id": LyricsProviderNetease, "name": "Netease", "has_proxy_dependency": false, "description": "NetEase Cloud Music (good for Asian songs)"}, {"id": LyricsProviderNetease, "name": "Netease", "has_proxy_dependency": true, "description": "NetEase Cloud Music lyrics via Paxsenix"},
{"id": LyricsProviderMusixmatch, "name": "Musixmatch", "has_proxy_dependency": true, "description": "Largest lyrics database (multi-language)"}, {"id": LyricsProviderMusixmatch, "name": "Musixmatch", "has_proxy_dependency": true, "description": "Musixmatch lyrics via Paxsenix"},
{"id": LyricsProviderAppleMusic, "name": "Apple Music", "has_proxy_dependency": true, "description": "Word-by-word synced lyrics"}, {"id": LyricsProviderAppleMusic, "name": "Apple Music", "has_proxy_dependency": true, "description": "Apple Music synced lyrics via Paxsenix"},
{"id": LyricsProviderQQMusic, "name": "QQ Music", "has_proxy_dependency": true, "description": "QQ Music lyrics (good for Chinese songs)"}, {"id": LyricsProviderQQMusic, "name": "QQ Music", "has_proxy_dependency": true, "description": "QQ Music lyrics via Paxsenix"},
} }
} }
@@ -431,6 +432,99 @@ func parseSpotifyRetryAfter(retryAfter string, now time.Time) time.Time {
return now.Add(10 * time.Minute) return now.Add(10 * time.Minute)
} }
func buildSpotifyLyricsResponse(lines []LyricsLine, syncType, plainLyrics string) (*LyricsResponse, error) {
if len(lines) == 0 {
return nil, fmt.Errorf("Spotify Lyrics API returned empty lines")
}
if syncType == "" {
if len(lines) > 0 && lines[0].StartTimeMs > 0 {
syncType = "LINE_SYNCED"
} else {
syncType = "UNSYNCED"
}
}
return &LyricsResponse{
Lines: lines,
SyncType: syncType,
Instrumental: false,
PlainLyrics: plainLyrics,
Provider: "Spotify Lyrics API",
Source: "Spotify Lyrics API",
}, nil
}
func plainLyricsFromTimedLines(lines []LyricsLine) string {
parts := make([]string, 0, len(lines))
for _, line := range lines {
words := strings.TrimSpace(line.Words)
if words == "" {
continue
}
parts = append(parts, words)
}
return strings.Join(parts, "\n")
}
func parseSpotifyLyricsResponseBody(body []byte) (*LyricsResponse, error) {
var lrcPayload string
if err := json.Unmarshal(body, &lrcPayload); err == nil {
trimmed := strings.TrimSpace(lrcPayload)
if trimmed == "" {
return nil, fmt.Errorf("Spotify Lyrics API returned empty payload")
}
lines := parseSyncedLyrics(trimmed)
if len(lines) > 0 {
return buildSpotifyLyricsResponse(lines, "LINE_SYNCED", plainLyricsFromTimedLines(lines))
}
plainLines := plainTextLyricsLines(trimmed)
return buildSpotifyLyricsResponse(plainLines, "UNSYNCED", trimmed)
}
var apiResp SpotifyLyricsAPIResponse
if err := json.Unmarshal(body, &apiResp); err != nil {
return nil, fmt.Errorf("failed to parse Spotify Lyrics API response: %w", err)
}
if apiResp.Error {
msg := strings.TrimSpace(apiResp.Message)
if msg == "" {
msg = "Spotify Lyrics API returned error"
}
return nil, fmt.Errorf("%s", msg)
}
lines := make([]LyricsLine, 0, len(apiResp.Lines))
for _, line := range apiResp.Lines {
words := strings.TrimSpace(line.Words)
if words == "" {
continue
}
startMs := parseSpotifyLyricsTimeTagToMs(line.TimeTag)
lines = append(lines, LyricsLine{
StartTimeMs: startMs,
Words: words,
EndTimeMs: 0,
})
}
for i := 0; i < len(lines)-1; i++ {
nextStart := lines[i+1].StartTimeMs
if nextStart > lines[i].StartTimeMs {
lines[i].EndTimeMs = nextStart
}
}
if len(lines) > 0 {
last := len(lines) - 1
if lines[last].EndTimeMs == 0 {
lines[last].EndTimeMs = lines[last].StartTimeMs + 5000
}
}
return buildSpotifyLyricsResponse(lines, apiResp.SyncType, plainLyricsFromTimedLines(lines))
}
func (c *LyricsClient) FetchLyricsFromSpotifyAPI(spotifyID string) (*LyricsResponse, error) { func (c *LyricsClient) FetchLyricsFromSpotifyAPI(spotifyID string) (*LyricsResponse, error) {
now := time.Now() now := time.Now()
if limitedUntil := getSpotifyLyricsRateLimitUntil(); limitedUntil.After(now) { if limitedUntil := getSpotifyLyricsRateLimitUntil(); limitedUntil.After(now) {
@@ -449,7 +543,7 @@ func (c *LyricsClient) FetchLyricsFromSpotifyAPI(spotifyID string) (*LyricsRespo
spotifyID = parsed.ID spotifyID = parsed.ID
} }
apiURL := fmt.Sprintf("https://spotify-lyrics-api-pi.vercel.app/?trackid=%s&format=lrc", url.QueryEscape(spotifyID)) apiURL := fmt.Sprintf("https://lyrics.paxsenix.org/spotify/lyrics?id=%s", url.QueryEscape(spotifyID))
req, err := http.NewRequest("GET", apiURL, nil) req, err := http.NewRequest("GET", apiURL, nil)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err) return nil, fmt.Errorf("failed to create request: %w", err)
@@ -462,13 +556,18 @@ func (c *LyricsClient) FetchLyricsFromSpotifyAPI(spotifyID string) (*LyricsRespo
} }
defer resp.Body.Close() defer resp.Body.Close()
bodyBytes, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read Spotify Lyrics API response: %w", err)
}
if resp.StatusCode != 200 { if resp.StatusCode != 200 {
if resp.StatusCode == http.StatusTooManyRequests { if resp.StatusCode == http.StatusTooManyRequests {
retryUntil := parseSpotifyRetryAfter(resp.Header.Get("Retry-After"), now) retryUntil := parseSpotifyRetryAfter(resp.Header.Get("Retry-After"), now)
setSpotifyLyricsRateLimitUntil(retryUntil) setSpotifyLyricsRateLimitUntil(retryUntil)
} }
var payload map[string]interface{} var payload map[string]interface{}
if err := json.NewDecoder(resp.Body).Decode(&payload); err == nil { if err := json.Unmarshal(bodyBytes, &payload); err == nil {
if msg, ok := payload["message"].(string); ok && strings.TrimSpace(msg) != "" { if msg, ok := payload["message"].(string); ok && strings.TrimSpace(msg) != "" {
return nil, fmt.Errorf("Spotify Lyrics API returned status %d: %s", resp.StatusCode, strings.TrimSpace(msg)) return nil, fmt.Errorf("Spotify Lyrics API returned status %d: %s", resp.StatusCode, strings.TrimSpace(msg))
} }
@@ -479,63 +578,7 @@ func (c *LyricsClient) FetchLyricsFromSpotifyAPI(spotifyID string) (*LyricsRespo
return nil, fmt.Errorf("Spotify Lyrics API returned status %d", resp.StatusCode) return nil, fmt.Errorf("Spotify Lyrics API returned status %d", resp.StatusCode)
} }
var apiResp SpotifyLyricsAPIResponse return parseSpotifyLyricsResponseBody(bodyBytes)
if err := json.NewDecoder(resp.Body).Decode(&apiResp); err != nil {
return nil, fmt.Errorf("failed to parse Spotify Lyrics API response: %w", err)
}
if apiResp.Error {
msg := strings.TrimSpace(apiResp.Message)
if msg == "" {
msg = "Spotify Lyrics API returned error"
}
return nil, fmt.Errorf("%s", msg)
}
result := &LyricsResponse{
Lines: make([]LyricsLine, 0, len(apiResp.Lines)),
SyncType: apiResp.SyncType,
Instrumental: false,
PlainLyrics: "",
Provider: "Spotify Lyrics API",
Source: "Spotify Lyrics API",
}
for _, line := range apiResp.Lines {
words := strings.TrimSpace(line.Words)
if words == "" {
continue
}
startMs := parseSpotifyLyricsTimeTagToMs(line.TimeTag)
result.Lines = append(result.Lines, LyricsLine{
StartTimeMs: startMs,
Words: words,
EndTimeMs: 0,
})
}
if len(result.Lines) > 1 {
for i := 0; i < len(result.Lines)-1; i++ {
nextStart := result.Lines[i+1].StartTimeMs
if nextStart > result.Lines[i].StartTimeMs {
result.Lines[i].EndTimeMs = nextStart
}
}
last := len(result.Lines) - 1
if result.Lines[last].EndTimeMs == 0 {
result.Lines[last].EndTimeMs = result.Lines[last].StartTimeMs + 5000
}
}
if len(result.Lines) == 0 {
return nil, fmt.Errorf("Spotify Lyrics API returned empty lines")
}
if result.SyncType == "" {
result.SyncType = "LINE_SYNCED"
}
return result, nil
} }
func (c *LyricsClient) findBestMatch(results []LRCLibResponse, targetDurationSec float64) *LRCLibResponse { func (c *LyricsClient) findBestMatch(results []LRCLibResponse, targetDurationSec float64) *LRCLibResponse {
+65 -126
View File
@@ -4,121 +4,25 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"io" "io"
"math"
"net/http" "net/http"
"net/url" "net/url"
"regexp"
"strings" "strings"
"sync"
"time" "time"
) )
// AppleMusicClient fetches lyrics from Apple Music. // AppleMusicClient fetches lyrics from Apple Music.
// Uses a scraped JWT token for search and a proxy for lyrics. // Uses Paxsenix endpoints for search and lyrics.
type AppleMusicClient struct { type AppleMusicClient struct {
httpClient *http.Client httpClient *http.Client
} }
// Apple Music token manager — singleton with mutex for thread safety type appleMusicSearchResult struct {
type appleTokenManager struct { ID string `json:"id"`
mu sync.Mutex SongName string `json:"songName"`
token string ArtistName string `json:"artistName"`
} AlbumName string `json:"albumName"`
Duration int `json:"duration"`
var globalAppleTokenManager = &appleTokenManager{}
func (m *appleTokenManager) getToken(client *http.Client) (string, error) {
m.mu.Lock()
defer m.mu.Unlock()
if m.token != "" {
return m.token, nil
}
// Step 1: Fetch the Apple Music beta page
req, err := http.NewRequest("GET", "https://beta.music.apple.com", nil)
if err != nil {
return "", fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("User-Agent", getRandomUserAgent())
resp, err := client.Do(req)
if err != nil {
return "", fmt.Errorf("failed to fetch Apple Music page: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("failed to read Apple Music page: %w", err)
}
// Step 2: Find the index JS file URL
indexJsRegex := regexp.MustCompile(`/assets/index~[^/]+\.js`)
match := indexJsRegex.Find(body)
if match == nil {
return "", fmt.Errorf("could not find index JS script URL on Apple Music page")
}
indexJsURL := "https://beta.music.apple.com" + string(match)
// Step 3: Fetch the JS file
jsReq, err := http.NewRequest("GET", indexJsURL, nil)
if err != nil {
return "", fmt.Errorf("failed to create JS request: %w", err)
}
jsReq.Header.Set("User-Agent", getRandomUserAgent())
jsResp, err := client.Do(jsReq)
if err != nil {
return "", fmt.Errorf("failed to fetch Apple Music JS: %w", err)
}
defer jsResp.Body.Close()
jsBody, err := io.ReadAll(jsResp.Body)
if err != nil {
return "", fmt.Errorf("failed to read Apple Music JS: %w", err)
}
// Step 4: Extract JWT token (starts with eyJh)
tokenRegex := regexp.MustCompile(`eyJh[^"]*`)
tokenMatch := tokenRegex.Find(jsBody)
if tokenMatch == nil {
return "", fmt.Errorf("could not find JWT token in Apple Music JS")
}
m.token = string(tokenMatch)
GoLog("[AppleMusic] Token obtained successfully (length: %d)\n", len(m.token))
return m.token, nil
}
func (m *appleTokenManager) clearToken() {
m.mu.Lock()
defer m.mu.Unlock()
m.token = ""
}
type appleMusicSearchResponse struct {
Results struct {
Songs *struct {
Data []struct {
ID string `json:"id"`
Type string `json:"type"`
} `json:"data"`
} `json:"songs"`
} `json:"results"`
Resources *struct {
Songs map[string]struct {
Attributes struct {
Name string `json:"name"`
ArtistName string `json:"artistName"`
AlbumName string `json:"albumName"`
URL string `json:"url"`
Artwork struct {
URL string `json:"url"`
} `json:"artwork"`
} `json:"attributes"`
} `json:"songs"`
} `json:"resources"`
} }
// PaxResponse represents the lyrics proxy response for word-by-word / line lyrics // PaxResponse represents the lyrics proxy response for word-by-word / line lyrics
@@ -149,32 +53,71 @@ func NewAppleMusicClient() *AppleMusicClient {
} }
} }
func selectBestAppleMusicSearchResult(results []appleMusicSearchResult, trackName, artistName string, durationSec float64) *appleMusicSearchResult {
if len(results) == 0 {
return nil
}
normalizedTrack := strings.ToLower(strings.TrimSpace(simplifyTrackName(trackName)))
normalizedArtist := strings.ToLower(strings.TrimSpace(normalizeArtistName(artistName)))
if normalizedArtist == "" {
normalizedArtist = strings.ToLower(strings.TrimSpace(artistName))
}
bestIndex := 0
bestScore := -1
for i := range results {
result := &results[i]
score := 0
candidateTrack := strings.ToLower(strings.TrimSpace(simplifyTrackName(result.SongName)))
candidateArtist := strings.ToLower(strings.TrimSpace(normalizeArtistName(result.ArtistName)))
switch {
case candidateTrack == normalizedTrack:
score += 50
case strings.Contains(candidateTrack, normalizedTrack) || strings.Contains(normalizedTrack, candidateTrack):
score += 25
}
switch {
case candidateArtist == normalizedArtist:
score += 60
case strings.Contains(candidateArtist, normalizedArtist) || strings.Contains(normalizedArtist, candidateArtist):
score += 30
}
if durationSec > 0 && result.Duration > 0 {
diff := math.Abs(float64(result.Duration)/1000.0 - durationSec)
if diff <= durationToleranceSec {
score += 20
}
}
if score > bestScore {
bestScore = score
bestIndex = i
}
}
return &results[bestIndex]
}
// SearchSong searches for a song on Apple Music and returns its ID. // SearchSong searches for a song on Apple Music and returns its ID.
func (c *AppleMusicClient) SearchSong(trackName, artistName string) (string, error) { func (c *AppleMusicClient) SearchSong(trackName, artistName string, durationSec float64) (string, error) {
query := trackName + " " + artistName query := trackName + " " + artistName
if strings.TrimSpace(query) == "" { if strings.TrimSpace(query) == "" {
return "", fmt.Errorf("empty search query") return "", fmt.Errorf("empty search query")
} }
token, err := globalAppleTokenManager.getToken(c.httpClient)
if err != nil {
return "", fmt.Errorf("apple music token error: %w", err)
}
encodedQuery := url.QueryEscape(query) encodedQuery := url.QueryEscape(query)
searchURL := fmt.Sprintf( searchURL := fmt.Sprintf("https://lyrics.paxsenix.org/apple-music/search?q=%s", encodedQuery)
"https://amp-api.music.apple.com/v1/catalog/us/search?term=%s&types=songs&limit=5&l=en-US&platform=web&format[resources]=map&include[songs]=artists&extend=artistUrl",
encodedQuery,
)
req, err := http.NewRequest("GET", searchURL, nil) req, err := http.NewRequest("GET", searchURL, nil)
if err != nil { if err != nil {
return "", fmt.Errorf("failed to create request: %w", err) return "", fmt.Errorf("failed to create request: %w", err)
} }
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Origin", "https://music.apple.com")
req.Header.Set("Referer", "https://music.apple.com/")
req.Header.Set("User-Agent", getRandomUserAgent()) req.Header.Set("User-Agent", getRandomUserAgent())
req.Header.Set("Accept", "application/json") req.Header.Set("Accept", "application/json")
@@ -184,25 +127,21 @@ func (c *AppleMusicClient) SearchSong(trackName, artistName string) (string, err
} }
defer resp.Body.Close() defer resp.Body.Close()
if resp.StatusCode == 401 {
globalAppleTokenManager.clearToken()
return "", fmt.Errorf("apple music token expired")
}
if resp.StatusCode != 200 { if resp.StatusCode != 200 {
return "", fmt.Errorf("apple music search returned HTTP %d", resp.StatusCode) return "", fmt.Errorf("apple music search returned HTTP %d", resp.StatusCode)
} }
var searchResp appleMusicSearchResponse var searchResp []appleMusicSearchResult
if err := json.NewDecoder(resp.Body).Decode(&searchResp); err != nil { if err := json.NewDecoder(resp.Body).Decode(&searchResp); err != nil {
return "", fmt.Errorf("failed to decode apple music response: %w", err) return "", fmt.Errorf("failed to decode apple music response: %w", err)
} }
if searchResp.Results.Songs == nil || len(searchResp.Results.Songs.Data) == 0 { best := selectBestAppleMusicSearchResult(searchResp, trackName, artistName, durationSec)
if best == nil || strings.TrimSpace(best.ID) == "" {
return "", fmt.Errorf("no songs found on apple music") return "", fmt.Errorf("no songs found on apple music")
} }
return searchResp.Results.Songs.Data[0].ID, nil return strings.TrimSpace(best.ID), nil
} }
// FetchLyricsByID fetches lyrics from the paxsenix proxy using Apple Music song ID. // FetchLyricsByID fetches lyrics from the paxsenix proxy using Apple Music song ID.
@@ -320,7 +259,7 @@ func (c *AppleMusicClient) FetchLyrics(
durationSec float64, durationSec float64,
multiPersonWordByWord bool, multiPersonWordByWord bool,
) (*LyricsResponse, error) { ) (*LyricsResponse, error) {
songID, err := c.SearchSong(trackName, artistName) songID, err := c.SearchSong(trackName, artistName, durationSec)
if err != nil { if err != nil {
return nil, err return nil, err
} }
+92 -89
View File
@@ -3,6 +3,8 @@ package gobackend
import ( import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"io"
"math"
"net/http" "net/http"
"net/url" "net/url"
"strings" "strings"
@@ -45,100 +47,105 @@ type musixmatchLyricsResponse struct {
func NewMusixmatchClient() *MusixmatchClient { func NewMusixmatchClient() *MusixmatchClient {
return &MusixmatchClient{ return &MusixmatchClient{
httpClient: NewMetadataHTTPClient(15 * time.Second), httpClient: NewMetadataHTTPClient(15 * time.Second),
baseURL: "http://158.180.60.95", baseURL: "https://lyrics.paxsenix.org/musixmatch/lyrics",
} }
} }
// searchAndGetLyrics searches for a song and retrieves its lyrics in one call. func (c *MusixmatchClient) fetchLyricsPayload(trackName, artistName string, durationSec float64, lyricsType, language string) (string, error) {
// The Musixmatch proxy returns both search result and lyrics in a single response.
func (c *MusixmatchClient) searchAndGetLyrics(trackName, artistName string) (*musixmatchSearchResponse, error) {
if strings.TrimSpace(trackName) == "" || strings.TrimSpace(artistName) == "" { if strings.TrimSpace(trackName) == "" || strings.TrimSpace(artistName) == "" {
return nil, fmt.Errorf("empty track or artist name") return "", fmt.Errorf("empty track or artist name")
} }
encodedArtist := url.QueryEscape(artistName) params := url.Values{}
encodedTrack := url.QueryEscape(trackName) params.Set("t", trackName)
params.Set("a", artistName)
fullURL := fmt.Sprintf("%s/v2/full?artist=%s&track=%s", c.baseURL, encodedArtist, encodedTrack) params.Set("type", lyricsType)
params.Set("format", "lrc")
if durationSec > 0 {
params.Set("d", fmt.Sprintf("%d", int(math.Round(durationSec))))
}
if strings.TrimSpace(language) != "" {
params.Set("l", strings.ToLower(strings.TrimSpace(language)))
}
fullURL := c.baseURL + "?" + params.Encode()
req, err := http.NewRequest("GET", fullURL, nil) req, err := http.NewRequest("GET", fullURL, nil)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err) return "", fmt.Errorf("failed to create request: %w", err)
} }
req.Header.Set("Accept", "application/json")
req.Header.Set("User-Agent", getRandomUserAgent()) req.Header.Set("User-Agent", getRandomUserAgent())
resp, err := c.httpClient.Do(req) resp, err := c.httpClient.Do(req)
if err != nil { if err != nil {
return nil, fmt.Errorf("musixmatch search failed: %w", err) return "", fmt.Errorf("musixmatch request failed: %w", err)
} }
defer resp.Body.Close() defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("failed to read musixmatch response: %w", err)
}
if resp.StatusCode != 200 { if resp.StatusCode != 200 {
return nil, fmt.Errorf("musixmatch proxy returned HTTP %d", resp.StatusCode) trimmed := strings.TrimSpace(string(body))
if errMsg, isErrorPayload := detectLyricsErrorPayload(trimmed); isErrorPayload {
return "", fmt.Errorf("musixmatch proxy returned HTTP %d: %s", resp.StatusCode, errMsg)
}
return "", fmt.Errorf("musixmatch proxy returned HTTP %d", resp.StatusCode)
} }
var result musixmatchSearchResponse var lrcPayload string
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { if err := json.Unmarshal(body, &lrcPayload); err == nil {
return nil, fmt.Errorf("failed to decode musixmatch response: %w", err) lrcPayload = strings.TrimSpace(lrcPayload)
if lrcPayload == "" {
return "", fmt.Errorf("empty musixmatch lyrics payload")
}
return lrcPayload, nil
} }
return &result, nil trimmed := strings.TrimSpace(string(body))
if errMsg, isErrorPayload := detectLyricsErrorPayload(trimmed); isErrorPayload {
return "", fmt.Errorf("%s", errMsg)
}
if trimmed != "" && !strings.HasPrefix(trimmed, "{") {
return trimmed, nil
}
return "", fmt.Errorf("failed to decode musixmatch response")
} }
// FetchLyricsInLanguage retrieves lyrics from Musixmatch for a specific language code. // FetchLyricsInLanguage retrieves lyrics from Musixmatch for a specific language code.
func (c *MusixmatchClient) FetchLyricsInLanguage(songID int64, language string) (*LyricsResponse, error) { func (c *MusixmatchClient) FetchLyricsInLanguage(trackName, artistName string, durationSec float64, language string) (*LyricsResponse, error) {
lang := strings.ToLower(strings.TrimSpace(language)) lang := strings.ToLower(strings.TrimSpace(language))
if songID <= 0 || lang == "" { if lang == "" {
return nil, fmt.Errorf("invalid song id or language") return nil, fmt.Errorf("invalid language")
} }
fullURL := fmt.Sprintf("%s/v2/full?id=%d&lang=%s", c.baseURL, songID, url.QueryEscape(lang)) lrcText, err := c.fetchLyricsPayload(trackName, artistName, durationSec, "translate", lang)
req, err := http.NewRequest("GET", fullURL, nil)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err) return nil, err
}
req.Header.Set("User-Agent", getRandomUserAgent())
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("musixmatch language fetch failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return nil, fmt.Errorf("musixmatch language endpoint returned HTTP %d", resp.StatusCode)
} }
var result musixmatchSearchResponse lines := parseSyncedLyrics(lrcText)
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { if len(lines) > 0 {
return nil, fmt.Errorf("failed to decode musixmatch language response: %w", err) return &LyricsResponse{
Lines: lines,
SyncType: "LINE_SYNCED",
PlainLyrics: plainLyricsFromTimedLines(lines),
Provider: "Musixmatch",
Source: fmt.Sprintf("Musixmatch (%s)", lang),
}, nil
} }
if result.SyncedLyrics != nil && strings.TrimSpace(result.SyncedLyrics.Lyrics) != "" { plainLines := plainTextLyricsLines(lrcText)
lines := parseSyncedLyrics(result.SyncedLyrics.Lyrics) if len(plainLines) > 0 {
if len(lines) > 0 { return &LyricsResponse{
return &LyricsResponse{ Lines: plainLines,
Lines: lines, SyncType: "UNSYNCED",
SyncType: "LINE_SYNCED", PlainLyrics: lrcText,
Provider: "Musixmatch", Provider: "Musixmatch",
Source: fmt.Sprintf("Musixmatch (%s)", lang), Source: fmt.Sprintf("Musixmatch (%s)", lang),
}, nil }, nil
}
}
if result.UnsyncedLyrics != nil && strings.TrimSpace(result.UnsyncedLyrics.Lyrics) != "" {
lines := plainTextLyricsLines(result.UnsyncedLyrics.Lyrics)
if len(lines) > 0 {
return &LyricsResponse{
Lines: lines,
SyncType: "UNSYNCED",
PlainLyrics: result.UnsyncedLyrics.Lyrics,
Provider: "Musixmatch",
Source: fmt.Sprintf("Musixmatch (%s)", lang),
}, nil
}
} }
return nil, fmt.Errorf("no lyrics found on musixmatch for language %s", lang) return nil, fmt.Errorf("no lyrics found on musixmatch for language %s", lang)
@@ -146,43 +153,39 @@ func (c *MusixmatchClient) FetchLyricsInLanguage(songID int64, language string)
// FetchLyrics searches Musixmatch and returns parsed LyricsResponse. // FetchLyrics searches Musixmatch and returns parsed LyricsResponse.
func (c *MusixmatchClient) FetchLyrics(trackName, artistName string, durationSec float64, preferredLanguage string) (*LyricsResponse, error) { func (c *MusixmatchClient) FetchLyrics(trackName, artistName string, durationSec float64, preferredLanguage string) (*LyricsResponse, error) {
result, err := c.searchAndGetLyrics(trackName, artistName) if preferred := strings.ToLower(strings.TrimSpace(preferredLanguage)); preferred != "" {
if err != nil { localized, localizedErr := c.FetchLyricsInLanguage(trackName, artistName, durationSec, preferred)
return nil, err
}
if preferred := strings.ToLower(strings.TrimSpace(preferredLanguage)); preferred != "" && result.ID > 0 {
localized, localizedErr := c.FetchLyricsInLanguage(result.ID, preferred)
if localizedErr == nil { if localizedErr == nil {
return localized, nil return localized, nil
} }
GoLog("[Musixmatch] Language override '%s' failed: %v\n", preferred, localizedErr) GoLog("[Musixmatch] Language override '%s' failed: %v\n", preferred, localizedErr)
} }
if result.SyncedLyrics != nil && strings.TrimSpace(result.SyncedLyrics.Lyrics) != "" { lrcText, err := c.fetchLyricsPayload(trackName, artistName, durationSec, "word", "")
lines := parseSyncedLyrics(result.SyncedLyrics.Lyrics) if err != nil {
if len(lines) > 0 { return nil, err
return &LyricsResponse{
Lines: lines,
SyncType: "LINE_SYNCED",
Provider: "Musixmatch",
Source: "Musixmatch",
}, nil
}
} }
if result.UnsyncedLyrics != nil && strings.TrimSpace(result.UnsyncedLyrics.Lyrics) != "" { lines := parseSyncedLyrics(lrcText)
lines := plainTextLyricsLines(result.UnsyncedLyrics.Lyrics) if len(lines) > 0 {
return &LyricsResponse{
Lines: lines,
SyncType: "LINE_SYNCED",
PlainLyrics: plainLyricsFromTimedLines(lines),
Provider: "Musixmatch",
Source: "Musixmatch",
}, nil
}
if len(lines) > 0 { plainLines := plainTextLyricsLines(lrcText)
return &LyricsResponse{ if len(plainLines) > 0 {
Lines: lines, return &LyricsResponse{
SyncType: "UNSYNCED", Lines: plainLines,
PlainLyrics: result.UnsyncedLyrics.Lyrics, SyncType: "UNSYNCED",
Provider: "Musixmatch", PlainLyrics: lrcText,
Source: "Musixmatch", Provider: "Musixmatch",
}, nil Source: "Musixmatch",
} }, nil
} }
return nil, fmt.Errorf("no lyrics found on musixmatch") return nil, fmt.Errorf("no lyrics found on musixmatch")
+4 -11
View File
@@ -9,8 +9,7 @@ import (
"time" "time"
) )
// NeteaseClient fetches lyrics from NetEase Cloud Music (music.163.com). // NeteaseClient fetches lyrics through Paxsenix's NetEase endpoints.
// This is a direct public API — no proxy dependency.
type NeteaseClient struct { type NeteaseClient struct {
httpClient *http.Client httpClient *http.Client
} }
@@ -59,12 +58,9 @@ func (c *NeteaseClient) SearchSong(trackName, artistName string) (int64, error)
return 0, fmt.Errorf("empty search query") return 0, fmt.Errorf("empty search query")
} }
searchURL := "http://music.163.com/api/search/pc" searchURL := "https://lyrics.paxsenix.org/netease/search"
params := url.Values{} params := url.Values{}
params.Set("s", query) params.Set("q", query)
params.Set("type", "1")
params.Set("limit", "1")
params.Set("offset", "0")
fullURL := searchURL + "?" + params.Encode() fullURL := searchURL + "?" + params.Encode()
@@ -102,12 +98,9 @@ func (c *NeteaseClient) SearchSong(trackName, artistName string) (int64, error)
// FetchLyricsByID fetches synced lyrics for a given Netease song ID. // FetchLyricsByID fetches synced lyrics for a given Netease song ID.
func (c *NeteaseClient) FetchLyricsByID(songID int64, includeTranslation, includeRomanization bool) (string, error) { func (c *NeteaseClient) FetchLyricsByID(songID int64, includeTranslation, includeRomanization bool) (string, error) {
lyricsURL := "http://music.163.com/api/song/lyric" lyricsURL := "https://lyrics.paxsenix.org/netease/lyrics"
params := url.Values{} params := url.Values{}
params.Set("id", fmt.Sprintf("%d", songID)) params.Set("id", fmt.Sprintf("%d", songID))
params.Set("lv", "1")
params.Set("tv", "1")
params.Set("rv", "1")
fullURL := lyricsURL + "?" + params.Encode() fullURL := lyricsURL + "?" + params.Encode()
+39 -95
View File
@@ -1,45 +1,31 @@
package gobackend package gobackend
import ( import (
"bytes"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io" "io"
"math"
"net/http" "net/http"
"net/url"
"strings" "strings"
"time" "time"
) )
// QQMusicClient fetches lyrics from QQ Music. // QQMusicClient fetches lyrics from QQ Music.
// Search uses public QQ Music API, lyrics use the paxsenix proxy. // Uses Paxsenix metadata lookup for lyrics.
type QQMusicClient struct { type QQMusicClient struct {
httpClient *http.Client httpClient *http.Client
} }
type qqMusicSearchResponse struct { type qqLyricsMetadataRequest struct {
Data struct { Artist []string `json:"artist"`
Song struct { Album string `json:"album,omitempty"`
List []struct { SongID int64 `json:"songid,omitempty"`
Title string `json:"title"` Title string `json:"title"`
Singer []struct { Duration int64 `json:"duration,omitempty"`
Name string `json:"name"`
} `json:"singer"`
Album struct {
Name string `json:"name"`
} `json:"album"`
ID int64 `json:"id"`
} `json:"list"`
} `json:"song"`
} `json:"data"`
} }
// QQ Music lyrics request payload for paxsenix proxy type qqLyricsMetadataResponse struct {
type qqLyricsPayload struct { Lyrics []paxLyrics `json:"lyrics"`
Artist []string `json:"artist"`
Album string `json:"album"`
ID int64 `json:"id"`
Title string `json:"title"`
} }
func NewQQMusicClient() *QQMusicClient { func NewQQMusicClient() *QQMusicClient {
@@ -48,79 +34,29 @@ func NewQQMusicClient() *QQMusicClient {
} }
} }
// searchSong searches QQ Music and returns the song info needed for lyrics fetch. // fetchLyricsByMetadata asks Paxsenix to resolve and return QQ lyrics using track metadata.
func (c *QQMusicClient) searchSong(trackName, artistName string) (*qqLyricsPayload, error) { func (c *QQMusicClient) fetchLyricsByMetadata(trackName, artistName string, durationSec float64) (string, error) {
query := trackName + " " + artistName payload := qqLyricsMetadataRequest{
if strings.TrimSpace(query) == "" { Artist: []string{artistName},
return nil, fmt.Errorf("empty search query") Title: trackName,
}
if durationSec > 0 {
payload.Duration = int64(math.Round(durationSec))
} }
searchURL := "https://c.y.qq.com/soso/fcgi-bin/client_search_cp" lyricsURL := "https://lyrics.paxsenix.org/qq/lyrics-metadata"
params := url.Values{}
params.Set("format", "json")
params.Set("inCharset", "utf8")
params.Set("outCharset", "utf8")
params.Set("platform", "yqq.json")
params.Set("new_json", "1")
params.Set("w", query)
fullURL := searchURL + "?" + params.Encode()
req, err := http.NewRequest("GET", fullURL, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("User-Agent", getRandomUserAgent())
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("qqmusic search failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return nil, fmt.Errorf("qqmusic search returned HTTP %d", resp.StatusCode)
}
var searchResp qqMusicSearchResponse
if err := json.NewDecoder(resp.Body).Decode(&searchResp); err != nil {
return nil, fmt.Errorf("failed to decode qqmusic response: %w", err)
}
if len(searchResp.Data.Song.List) == 0 {
return nil, fmt.Errorf("no songs found on qqmusic")
}
song := searchResp.Data.Song.List[0]
var artists []string
for _, singer := range song.Singer {
artists = append(artists, singer.Name)
}
return &qqLyricsPayload{
Artist: artists,
Album: song.Album.Name,
ID: song.ID,
Title: song.Title,
}, nil
}
// fetchLyricsByPayload fetches lyrics from the paxsenix proxy using QQ Music song info.
func (c *QQMusicClient) fetchLyricsByPayload(payload *qqLyricsPayload) (string, error) {
lyricsURL := "https://paxsenix.alwaysdata.net/getQQLyrics.php"
payloadBytes, err := json.Marshal(payload) payloadBytes, err := json.Marshal(payload)
if err != nil { if err != nil {
return "", fmt.Errorf("failed to marshal payload: %w", err) return "", fmt.Errorf("failed to marshal payload: %w", err)
} }
req, err := http.NewRequest("POST", lyricsURL, bytes.NewReader(payloadBytes)) req, err := http.NewRequest("POST", lyricsURL, strings.NewReader(string(payloadBytes)))
if err != nil { if err != nil {
return "", fmt.Errorf("failed to create request: %w", err) return "", fmt.Errorf("failed to create request: %w", err)
} }
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
req.Header.Set("User-Agent", getRandomUserAgent()) req.Header.Set("User-Agent", getRandomUserAgent())
resp, err := c.httpClient.Do(req) resp, err := c.httpClient.Do(req)
@@ -146,6 +82,17 @@ func (c *QQMusicClient) fetchLyricsByPayload(payload *qqLyricsPayload) (string,
return bodyStr, nil return bodyStr, nil
} }
func formatQQLyricsMetadataToLRC(rawJSON string, multiPersonWordByWord bool) (string, error) {
var response qqLyricsMetadataResponse
if err := json.Unmarshal([]byte(rawJSON), &response); err != nil {
return "", fmt.Errorf("failed to parse qq metadata lyrics response")
}
if len(response.Lyrics) == 0 {
return "", fmt.Errorf("qq metadata lyrics response was empty")
}
return formatPaxContent("Syllable", response.Lyrics, multiPersonWordByWord), nil
}
// FetchLyrics searches QQ Music and returns parsed LyricsResponse. // FetchLyrics searches QQ Music and returns parsed LyricsResponse.
func (c *QQMusicClient) FetchLyrics( func (c *QQMusicClient) FetchLyrics(
trackName, trackName,
@@ -153,12 +100,7 @@ func (c *QQMusicClient) FetchLyrics(
durationSec float64, durationSec float64,
multiPersonWordByWord bool, multiPersonWordByWord bool,
) (*LyricsResponse, error) { ) (*LyricsResponse, error) {
payload, err := c.searchSong(trackName, artistName) rawLyrics, err := c.fetchLyricsByMetadata(trackName, artistName, durationSec)
if err != nil {
return nil, err
}
rawLyrics, err := c.fetchLyricsByPayload(payload)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -166,11 +108,13 @@ func (c *QQMusicClient) FetchLyrics(
return nil, fmt.Errorf("qqmusic proxy returned non-lyric payload: %s", errMsg) return nil, fmt.Errorf("qqmusic proxy returned non-lyric payload: %s", errMsg)
} }
// Try to parse as pax format (word-by-word or line) lrcText, err := formatQQLyricsMetadataToLRC(rawLyrics, multiPersonWordByWord)
lrcText, err := formatPaxLyricsToLRC(rawLyrics, multiPersonWordByWord)
if err != nil { if err != nil {
// If pax parsing fails, try to use as direct LRC text if fallback, fallbackErr := formatPaxLyricsToLRC(rawLyrics, multiPersonWordByWord); fallbackErr == nil {
lrcText = rawLyrics lrcText = fallback
} else {
lrcText = rawLyrics
}
} }
lines := parseSyncedLyrics(lrcText) lines := parseSyncedLyrics(lrcText)
+13 -9
View File
@@ -49,9 +49,10 @@ const (
qobuzArtistGetBaseURL = "https://www.qobuz.com/api.json/0.2/artist/get?artist_id=" qobuzArtistGetBaseURL = "https://www.qobuz.com/api.json/0.2/artist/get?artist_id="
qobuzPlaylistGetBaseURL = "https://www.qobuz.com/api.json/0.2/playlist/get?playlist_id=" qobuzPlaylistGetBaseURL = "https://www.qobuz.com/api.json/0.2/playlist/get?playlist_id="
qobuzStoreSearchBaseURL = "https://www.qobuz.com/us-en/search/tracks/" qobuzStoreSearchBaseURL = "https://www.qobuz.com/us-en/search/tracks/"
qobuzTrackOpenBaseURL = "https://open.qobuz.com/track/"
qobuzTrackPlayBaseURL = "https://play.qobuz.com/track/" qobuzTrackPlayBaseURL = "https://play.qobuz.com/track/"
qobuzStoreBaseURL = "https://www.qobuz.com/us-en" qobuzStoreBaseURL = "https://www.qobuz.com/us-en"
qobuzDownloadAPIURL = "https://www.musicdl.me/api/qobuz/download" qobuzDownloadAPIURL = "https://dl.musicdl.me/qobuz/download"
qobuzDabMusicAPIURL = "https://dabmusic.xyz/api/stream?trackId=" qobuzDabMusicAPIURL = "https://dabmusic.xyz/api/stream?trackId="
qobuzDeebAPIURL = "https://dab.yeet.su/api/stream?trackId=" qobuzDeebAPIURL = "https://dab.yeet.su/api/stream?trackId="
qobuzAfkarAPIURL = "https://qbz.afkarxyz.qzz.io/api/track/" qobuzAfkarAPIURL = "https://qbz.afkarxyz.qzz.io/api/track/"
@@ -1631,19 +1632,23 @@ func fetchQobuzURLWithRetry(provider qobuzAPIProvider, trackID int64, quality st
return fetchQobuzURLSingleAttempt(provider, trackID, quality, timeout, "") return fetchQobuzURLSingleAttempt(provider, trackID, quality, timeout, "")
} }
func buildQobuzMusicDLPayload(trackID int64, quality string) ([]byte, error) {
requestQuality := mapQobuzQualityCodeToAPI(quality)
payload := map[string]any{
"quality": requestQuality,
"upload_to_r2": false,
"url": fmt.Sprintf("%s%d", qobuzTrackOpenBaseURL, trackID),
}
return json.Marshal(payload)
}
func fetchQobuzURLSingleAttempt(provider qobuzAPIProvider, trackID int64, quality string, timeout time.Duration, country string) (qobuzDownloadInfo, error) { func fetchQobuzURLSingleAttempt(provider qobuzAPIProvider, trackID int64, quality string, timeout time.Duration, country string) (qobuzDownloadInfo, error) {
var lastErr error var lastErr error
retryDelay := qobuzRetryDelay retryDelay := qobuzRetryDelay
var payloadBytes []byte var payloadBytes []byte
if provider.Kind == qobuzAPIKindMusicDL { if provider.Kind == qobuzAPIKindMusicDL {
requestQuality := mapQobuzQualityCodeToAPI(quality)
payload := map[string]any{
"quality": requestQuality,
"upload_to_r2": false,
"url": fmt.Sprintf("%s%d", qobuzTrackPlayBaseURL, trackID),
}
var err error var err error
payloadBytes, err = json.Marshal(payload) payloadBytes, err = buildQobuzMusicDLPayload(trackID, quality)
if err != nil { if err != nil {
return qobuzDownloadInfo{}, fmt.Errorf("failed to encode qobuz request: %w", err) return qobuzDownloadInfo{}, fmt.Errorf("failed to encode qobuz request: %w", err)
} }
@@ -1688,7 +1693,6 @@ func fetchQobuzURLSingleAttempt(provider qobuzAPIProvider, trackID int64, qualit
} }
if provider.Kind == qobuzAPIKindMusicDL { if provider.Kind == qobuzAPIKindMusicDL {
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Debug-Key", getQobuzDebugKey())
} }
resp, err := DoRequestWithUserAgent(client, req) resp, err := DoRequestWithUserAgent(client, req)
+26 -1
View File
@@ -1,6 +1,9 @@
package gobackend package gobackend
import "testing" import (
"encoding/json"
"testing"
)
func TestParseQobuzURL(t *testing.T) { func TestParseQobuzURL(t *testing.T) {
tests := []struct { tests := []struct {
@@ -195,6 +198,28 @@ func TestGetQobuzDebugKey(t *testing.T) {
} }
} }
func TestBuildQobuzMusicDLPayloadUsesOpenTrackURL(t *testing.T) {
payloadBytes, err := buildQobuzMusicDLPayload(374610875, "7")
if err != nil {
t.Fatalf("buildQobuzMusicDLPayload returned error: %v", err)
}
var payload map[string]any
if err := json.Unmarshal(payloadBytes, &payload); err != nil {
t.Fatalf("payload is not valid JSON: %v", err)
}
if got := payload["url"]; got != "https://open.qobuz.com/track/374610875" {
t.Fatalf("payload url = %v, want open.qobuz.com track URL", got)
}
if got := payload["quality"]; got != "hi-res" {
t.Fatalf("payload quality = %v, want hi-res", got)
}
if got := payload["upload_to_r2"]; got != false {
t.Fatalf("payload upload_to_r2 = %v, want false", got)
}
}
func TestExtractQobuzAlbumIDsFromArtistHTML(t *testing.T) { func TestExtractQobuzAlbumIDsFromArtistHTML(t *testing.T) {
body := []byte(` body := []byte(`
<button data-itemtype="album" data-itemId="0886446451985"></button> <button data-itemtype="album" data-itemId="0886446451985"></button>
+128 -6
View File
@@ -26,8 +26,14 @@ type TidalDownloader struct {
} }
var ( var (
globalTidalDownloader *TidalDownloader globalTidalDownloader *TidalDownloader
tidalDownloaderOnce sync.Once tidalDownloaderOnce sync.Once
tidalGetTrackSearchPageFunc = func(t *TidalDownloader, query string, limit int) (*tidalPublicTrackSearchResponse, error) {
return t.getTrackSearchPage(query, limit)
}
tidalGetPublicTrackFunc = func(t *TidalDownloader, resourceID string) (*TidalTrack, error) {
return t.getPublicTrack(resourceID)
}
) )
const ( const (
@@ -758,15 +764,101 @@ func (t *TidalDownloader) GetTrackInfoByID(trackID int64) (*TidalTrack, error) {
} }
func (t *TidalDownloader) SearchTrackByISRC(isrc string) (*TidalTrack, error) { func (t *TidalDownloader) SearchTrackByISRC(isrc string) (*TidalTrack, error) {
return nil, fmt.Errorf("tidal ISRC search API disabled: no client credentials mode") normalizedISRC := strings.ToUpper(strings.TrimSpace(isrc))
if normalizedISRC == "" {
return nil, fmt.Errorf("empty tidal ISRC")
}
page, err := tidalGetTrackSearchPageFunc(t, normalizedISRC, 20)
if err != nil {
return nil, err
}
for i := range page.Items {
if strings.EqualFold(strings.TrimSpace(page.Items[i].ISRC), normalizedISRC) {
return &page.Items[i], nil
}
}
return nil, fmt.Errorf("no exact tidal ISRC match found for %s", normalizedISRC)
} }
func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, albumName, spotifyISRC string, expectedDuration int) (*TidalTrack, error) { func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, albumName, spotifyISRC string, expectedDuration int) (*TidalTrack, error) {
return nil, fmt.Errorf("tidal metadata search API disabled: no client credentials mode") queryParts := make([]string, 0, 3)
if trimmed := strings.TrimSpace(trackName); trimmed != "" {
queryParts = append(queryParts, trimmed)
}
if trimmed := strings.TrimSpace(artistName); trimmed != "" {
queryParts = append(queryParts, trimmed)
}
if len(queryParts) == 0 {
return nil, fmt.Errorf("tidal metadata search requires track or artist name")
}
queries := []string{strings.Join(queryParts, " ")}
if trimmedAlbum := strings.TrimSpace(albumName); trimmedAlbum != "" {
queries = append(queries, strings.Join(append(queryParts, trimmedAlbum), " "))
}
req := DownloadRequest{
TrackName: strings.TrimSpace(trackName),
ArtistName: strings.TrimSpace(artistName),
AlbumName: strings.TrimSpace(albumName),
ISRC: strings.ToUpper(strings.TrimSpace(spotifyISRC)),
DurationMS: expectedDuration * 1000,
}
seenQueries := make(map[string]struct{}, len(queries))
for _, query := range queries {
if _, seen := seenQueries[query]; seen {
continue
}
seenQueries[query] = struct{}{}
page, err := tidalGetTrackSearchPageFunc(t, query, 20)
if err != nil {
return nil, err
}
var candidates []*TidalTrack
for i := range page.Items {
track := &page.Items[i]
if req.ISRC != "" && !strings.EqualFold(strings.TrimSpace(track.ISRC), req.ISRC) {
continue
}
resolved := resolvedTrackInfo{
Title: strings.TrimSpace(track.Title),
ArtistName: tidalTrackArtistsDisplay(track),
Duration: track.Duration,
}
if trackMatchesRequest(req, resolved, "Tidal search") {
candidates = append(candidates, track)
}
}
if len(candidates) == 0 {
continue
}
if req.AlbumName != "" {
for _, candidate := range candidates {
if titlesMatch(req.AlbumName, candidate.Album.Title) {
return candidate, nil
}
}
}
return candidates[0], nil
}
if req.ISRC != "" {
return nil, fmt.Errorf("no tidal metadata match found for exact ISRC %s", req.ISRC)
}
return nil, fmt.Errorf("no tidal metadata match found")
} }
func (t *TidalDownloader) SearchTrackByMetadata(trackName, artistName string) (*TidalTrack, error) { func (t *TidalDownloader) SearchTrackByMetadata(trackName, artistName string) (*TidalTrack, error) {
return nil, fmt.Errorf("tidal metadata search API disabled: no client credentials mode") return t.SearchTrackByMetadataWithISRC(trackName, artistName, "", "", 0)
} }
func (t *TidalDownloader) SearchTracks(query string, limit int) ([]ExtTrackMetadata, error) { func (t *TidalDownloader) SearchTracks(query string, limit int) ([]ExtTrackMetadata, error) {
@@ -1847,6 +1939,36 @@ func resolveTidalTrackForRequest(req DownloadRequest, downloader *TidalDownloade
} }
} }
if !gotTidalID && req.ISRC != "" {
GoLog("[%s] Trying direct Tidal ISRC search: %s\n", logPrefix, req.ISRC)
directTrack, directErr := downloader.SearchTrackByISRC(req.ISRC)
if directErr == nil && directTrack != nil && directTrack.ID > 0 {
trackID = directTrack.ID
gotTidalID = true
GoLog("[%s] Got Tidal ID %d from direct ISRC search\n", logPrefix, trackID)
} else if directErr != nil {
GoLog("[%s] Direct Tidal ISRC search failed: %v\n", logPrefix, directErr)
}
}
if !gotTidalID && req.ISRC != "" && req.TrackName != "" && req.ArtistName != "" {
GoLog("[%s] Trying Tidal public metadata search with ISRC\n", logPrefix)
searchTrack, searchErr := downloader.SearchTrackByMetadataWithISRC(
req.TrackName,
req.ArtistName,
req.AlbumName,
req.ISRC,
expectedDurationSec,
)
if searchErr == nil && searchTrack != nil && searchTrack.ID > 0 {
trackID = searchTrack.ID
gotTidalID = true
GoLog("[%s] Got Tidal ID %d from public metadata search\n", logPrefix, trackID)
} else if searchErr != nil {
GoLog("[%s] Tidal public metadata search failed: %v\n", logPrefix, searchErr)
}
}
if !gotTidalID && (req.SpotifyID != "" || req.DeezerID != "") { if !gotTidalID && (req.SpotifyID != "" || req.DeezerID != "") {
GoLog("[%s] Trying SongLink for Tidal ID...\n", logPrefix) GoLog("[%s] Trying SongLink for Tidal ID...\n", logPrefix)
@@ -1912,7 +2034,7 @@ func resolveTidalTrackForRequest(req DownloadRequest, downloader *TidalDownloade
} }
// Verify the resolved track matches the request. // Verify the resolved track matches the request.
actualTrack, fetchErr := downloader.getPublicTrack(strconv.FormatInt(trackID, 10)) actualTrack, fetchErr := tidalGetPublicTrackFunc(downloader, strconv.FormatInt(trackID, 10))
if fetchErr != nil { if fetchErr != nil {
GoLog("[%s] Warning: could not fetch Tidal track %d for verification: %v\n", logPrefix, trackID, fetchErr) GoLog("[%s] Warning: could not fetch Tidal track %d for verification: %v\n", logPrefix, trackID, fetchErr)
// Continue without verification — better than failing entirely. // Continue without verification — better than failing entirely.
+2 -2
View File
@@ -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.7'; static const String version = '3.8.8';
static const String buildNumber = '113'; static const String buildNumber = '114';
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.
+31 -6
View File
@@ -3248,6 +3248,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
} }
_log.d('Concurrent downloads: ${state.concurrentDownloads}'); _log.d('Concurrent downloads: ${state.concurrentDownloads}');
await _processQueueParallel(); await _processQueueParallel();
final stoppedWhilePaused = state.isPaused;
_stopProgressPolling(); _stopProgressPolling();
@@ -3273,7 +3274,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
_log.i( _log.i(
'Queue stats - completed: $_completedInSession, failed: $_failedInSession, totalAtStart: $_totalQueuedAtStart', 'Queue stats - completed: $_completedInSession, failed: $_failedInSession, totalAtStart: $_totalQueuedAtStart',
); );
if (_totalQueuedAtStart > 0) { if (!stoppedWhilePaused && _totalQueuedAtStart > 0) {
await _notificationService.showQueueComplete( await _notificationService.showQueueComplete(
completedCount: _completedInSession, completedCount: _completedInSession,
failedCount: _failedInSession, failedCount: _failedInSession,
@@ -3288,13 +3289,17 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
} }
} }
_log.i('Queue processing finished'); if (stoppedWhilePaused) {
_log.i('Queue processing paused');
} else {
_log.i('Queue processing finished');
}
state = state.copyWith(isProcessing: false, currentDownload: null); state = state.copyWith(isProcessing: false, currentDownload: null);
final hasQueuedItems = state.items.any( final hasQueuedItems = state.items.any(
(item) => item.status == DownloadStatus.queued, (item) => item.status == DownloadStatus.queued,
); );
if (hasQueuedItems) { if (hasQueuedItems && !state.isPaused) {
_log.i( _log.i(
'Found queued items after processing finished, restarting queue...', 'Found queued items after processing finished, restarting queue...',
); );
@@ -3310,8 +3315,15 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
while (true) { while (true) {
if (state.isPaused) { if (state.isPaused) {
if (activeDownloads.isEmpty) {
_log.d('Queue is paused and no active downloads remain');
break;
}
_log.d('Queue is paused, waiting for active downloads...'); _log.d('Queue is paused, waiting for active downloads...');
await Future.delayed(_queueSchedulingInterval); await Future.any([
Future.wait(activeDownloads.values),
Future.delayed(_queueSchedulingInterval),
]);
continue; continue;
} }
@@ -3582,6 +3594,15 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
String? genre; String? genre;
String? label; String? label;
String? copyright; String? copyright;
final extensionState = ref.read(extensionProvider);
final selectedExtensionDownloadProvider =
settings.useExtensionProviders &&
extensionState.extensions.any(
(e) =>
e.enabled &&
e.hasDownloadProvider &&
e.id.toLowerCase() == item.service.toLowerCase(),
);
String? deezerTrackId = trackToDownload.deezerId; String? deezerTrackId = trackToDownload.deezerId;
if (deezerTrackId == null && trackToDownload.id.startsWith('deezer:')) { if (deezerTrackId == null && trackToDownload.id.startsWith('deezer:')) {
@@ -3616,7 +3637,8 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
} }
// Fallback: Use SongLink to convert Spotify ID to Deezer ID // Fallback: Use SongLink to convert Spotify ID to Deezer ID
if (deezerTrackId == null && if (!selectedExtensionDownloadProvider &&
deezerTrackId == null &&
trackToDownload.id.isNotEmpty && trackToDownload.id.isNotEmpty &&
!trackToDownload.id.startsWith('deezer:') && !trackToDownload.id.startsWith('deezer:') &&
!trackToDownload.id.startsWith('extension:')) { !trackToDownload.id.startsWith('extension:')) {
@@ -3717,6 +3739,10 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
if (shouldAbortWork('during SongLink availability lookup')) { if (shouldAbortWork('during SongLink availability lookup')) {
return; return;
} }
} else if (selectedExtensionDownloadProvider && deezerTrackId == null) {
_log.d(
'Skipping Flutter SongLink Deezer prelookup for extension provider: ${item.service}',
);
} }
if (deezerTrackId != null && deezerTrackId.isNotEmpty) { if (deezerTrackId != null && deezerTrackId.isNotEmpty) {
@@ -3744,7 +3770,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
Map<String, dynamic> result; Map<String, dynamic> result;
final extensionState = ref.read(extensionProvider);
final hasActiveExtensions = extensionState.extensions.any( final hasActiveExtensions = extensionState.extensions.any(
(e) => e.enabled, (e) => e.enabled,
); );
+20 -6
View File
@@ -329,6 +329,11 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
if (items.isNotEmpty) { if (items.isNotEmpty) {
await _db.upsertBatch(items.map((e) => e.toJson()).toList()); await _db.upsertBatch(items.map((e) => e.toJson()).toList());
} }
final persistedItems =
(await _db.getAll())
.map(LocalLibraryItem.fromJson)
.toList(growable: false)
..sort(_compareLibraryItems);
final now = DateTime.now(); final now = DateTime.now();
try { try {
@@ -341,7 +346,7 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
} }
state = state.copyWith( state = state.copyWith(
items: items, items: persistedItems,
isScanning: false, isScanning: false,
scanProgress: 100, scanProgress: 100,
lastScannedAt: now, lastScannedAt: now,
@@ -350,11 +355,11 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
); );
_log.i( _log.i(
'Full scan complete: ${items.length} tracks found, ' 'Full scan complete: ${persistedItems.length} tracks found, '
'$skippedDownloads already in downloads', '$skippedDownloads already in downloads',
); );
await _showScanCompleteNotification( await _showScanCompleteNotification(
totalTracks: items.length, totalTracks: persistedItems.length,
excludedDownloadedCount: skippedDownloads, excludedDownloadedCount: skippedDownloads,
errorCount: state.scanErrorCount, errorCount: state.scanErrorCount,
); );
@@ -439,8 +444,14 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
'$skippedCount skipped, ${deletedPaths.length} deleted, $totalFiles total', '$skippedCount skipped, ${deletedPaths.length} deleted, $totalFiles total',
); );
// Build the incremental merge base from SQLite, not the current
// provider state. Startup auto-scan can fire before `state.items` has
// finished loading, which would otherwise drop unchanged rows from the
// in-memory library until a manual full rescan.
final existingJson = await _db.getAll();
final currentByPath = <String, LocalLibraryItem>{ final currentByPath = <String, LocalLibraryItem>{
for (final item in state.items) item.filePath: item, for (final item in existingJson.map(LocalLibraryItem.fromJson))
item.filePath: item,
}; };
final existingDownloadedPaths = <String>[]; final existingDownloadedPaths = <String>[];
currentByPath.removeWhere((path, _) { currentByPath.removeWhere((path, _) {
@@ -491,8 +502,11 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
_log.i('Deleted $deleteCount items from database'); _log.i('Deleted $deleteCount items from database');
} }
final items = currentByPath.values.toList(growable: false) final items =
..sort(_compareLibraryItems); (await _db.getAll())
.map(LocalLibraryItem.fromJson)
.toList(growable: false)
..sort(_compareLibraryItems);
final now = DateTime.now(); final now = DateTime.now();
try { try {
+1 -2
View File
@@ -406,8 +406,7 @@ class SettingsNotifier extends Notifier<AppSettings> {
} }
void setMetadataSource(String source) { void setMetadataSource(String source) {
final normalized = source == 'deezer' ? 'deezer' : 'deezer'; state = state.copyWith(metadataSource: source);
state = state.copyWith(metadataSource: normalized);
_saveSettings(); _saveSettings();
} }
+9 -5
View File
@@ -958,9 +958,16 @@ class TrackNotifier extends Notifier<TrackState> {
final durationMs = _extractDurationMs(data); final durationMs = _extractDurationMs(data);
final itemType = data['item_type']?.toString(); final itemType = data['item_type']?.toString();
final effectiveSource =
source ?? data['source']?.toString() ?? data['provider_id']?.toString();
final spotifyId = (data['spotify_id'] ?? '').toString();
final nativeId = (data['id'] ?? '').toString();
final preferredId = effectiveSource != null && effectiveSource.isNotEmpty
? (nativeId.isNotEmpty ? nativeId : spotifyId)
: (spotifyId.isNotEmpty ? spotifyId : nativeId);
return Track( return Track(
id: (data['spotify_id'] ?? data['id'] ?? '').toString(), id: preferredId,
name: (data['name'] ?? '').toString(), name: (data['name'] ?? '').toString(),
artistName: (data['artists'] ?? data['artist'] ?? '').toString(), artistName: (data['artists'] ?? data['artist'] ?? '').toString(),
albumName: (data['album_name'] ?? data['album'] ?? '').toString(), albumName: (data['album_name'] ?? data['album'] ?? '').toString(),
@@ -974,10 +981,7 @@ class TrackNotifier extends Notifier<TrackState> {
discNumber: data['disc_number'] as int?, discNumber: data['disc_number'] as int?,
releaseDate: data['release_date']?.toString(), releaseDate: data['release_date']?.toString(),
totalTracks: data['total_tracks'] as int?, totalTracks: data['total_tracks'] as int?,
source: source: effectiveSource,
source ??
data['source']?.toString() ??
data['provider_id']?.toString(),
albumType: data['album_type']?.toString(), albumType: data['album_type']?.toString(),
itemType: itemType, itemType: itemType,
); );
-1
View File
@@ -83,7 +83,6 @@ class _MainShellState extends ConsumerState<MainShell> {
final extState = ref.read(extensionProvider); final extState = ref.read(extensionProvider);
if (!extState.isInitialized) { if (!extState.isInitialized) {
_log.d('Waiting for extensions to initialize before handling URL...'); _log.d('Waiting for extensions to initialize before handling URL...');
// Wait up to 5 seconds for extensions to initialize
for (int i = 0; i < 50; i++) { for (int i = 0; i < 50; i++) {
await Future.delayed(const Duration(milliseconds: 100)); await Future.delayed(const Duration(milliseconds: 100));
if (!mounted) return; if (!mounted) return;
-5
View File
@@ -5082,7 +5082,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
); );
try { try {
// Read metadata from file
final metadata = <String, String>{ final metadata = <String, String>{
'TITLE': item.trackName, 'TITLE': item.trackName,
'ARTIST': item.artistName, 'ARTIST': item.artistName,
@@ -5111,7 +5110,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
1000, 1000,
); );
// Extract cover art
String? coverPath; String? coverPath;
try { try {
final tempDir = await getTemporaryDirectory(); final tempDir = await getTemporaryDirectory();
@@ -5126,7 +5124,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
} }
} catch (_) {} } catch (_) {}
// Handle SAF vs regular file
String workingPath = item.filePath; String workingPath = item.filePath;
final isSaf = isContentUri(item.filePath); final isSaf = isContentUri(item.filePath);
String? safTempPath; String? safTempPath;
@@ -5139,7 +5136,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
workingPath = safTempPath; workingPath = safTempPath;
} }
// Convert
final newPath = await FFmpegService.convertAudioFormat( final newPath = await FFmpegService.convertAudioFormat(
inputPath: workingPath, inputPath: workingPath,
targetFormat: targetFormat.toLowerCase(), targetFormat: targetFormat.toLowerCase(),
@@ -5149,7 +5145,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
deleteOriginal: !isSaf, deleteOriginal: !isSaf,
); );
// Cleanup cover temp
if (coverPath != null) { if (coverPath != null) {
try { try {
await File(coverPath).delete(); await File(coverPath).delete();
+2 -2
View File
@@ -164,7 +164,7 @@ class _RecentDonorsCard extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark; final isDark = Theme.of(context).brightness == Brightness.dark;
const donorNames = <String>['micahRichie', 'a fan', 'mc nuggets jimmy', 'CJBGR']; const donorNames = <String>['McNuggets Jimmy', 'zcc09', 'micahRichie', 'a fan', 'CJBGR'];
// Match SettingsGroup color logic // Match SettingsGroup color logic
final cardColor = isDark final cardColor = isDark
@@ -480,7 +480,7 @@ int _cr(String v) {
} }
// Highlighted supporters (hashes of names). // Highlighted supporters (hashes of names).
const _cv = <int>{1211573191}; const _cv = <int>{1211573191, 1003219236, 560908930};
class _SupporterChip extends StatelessWidget { class _SupporterChip extends StatelessWidget {
final String name; final String name;
-2
View File
@@ -37,7 +37,6 @@ class CoverCacheManager {
final appDir = await getApplicationSupportDirectory(); final appDir = await getApplicationSupportDirectory();
_cachePath = p.join(appDir.path, 'cover_cache'); _cachePath = p.join(appDir.path, 'cover_cache');
// Ensure cache directory exists
await Directory(_cachePath!).create(recursive: true); await Directory(_cachePath!).create(recursive: true);
debugPrint('CoverCacheManager: Initializing at $_cachePath'); debugPrint('CoverCacheManager: Initializing at $_cachePath');
@@ -48,7 +47,6 @@ class CoverCacheManager {
debugPrint('CoverCacheManager: Initialized successfully'); debugPrint('CoverCacheManager: Initialized successfully');
} catch (e) { } catch (e) {
debugPrint('CoverCacheManager: Failed to initialize: $e'); debugPrint('CoverCacheManager: Failed to initialize: $e');
// Will fallback to DefaultCacheManager
} }
} }
-11
View File
@@ -12,7 +12,6 @@ final Future<SharedPreferences> _prefs = SharedPreferences.getInstance();
/// Cached current iOS container path for path normalization /// Cached current iOS container path for path normalization
String? _currentContainerPath; String? _currentContainerPath;
/// SQLite database service for download history
/// Provides O(1) lookups by spotify_id and isrc with proper indexing /// Provides O(1) lookups by spotify_id and isrc with proper indexing
class HistoryDatabase { class HistoryDatabase {
static final HistoryDatabase instance = HistoryDatabase._init(); static final HistoryDatabase instance = HistoryDatabase._init();
@@ -78,7 +77,6 @@ class HistoryDatabase {
) )
'''); ''');
// Indexes for fast lookups
await db.execute('CREATE INDEX idx_spotify_id ON history(spotify_id)'); await db.execute('CREATE INDEX idx_spotify_id ON history(spotify_id)');
await db.execute('CREATE INDEX idx_isrc ON history(isrc)'); await db.execute('CREATE INDEX idx_isrc ON history(isrc)');
await db.execute( await db.execute(
@@ -171,7 +169,6 @@ class HistoryDatabase {
try { try {
final db = await database; final db = await database;
// Get all items with iOS paths
final rows = await db.query('history', columns: ['id', 'file_path']); final rows = await db.query('history', columns: ['id', 'file_path']);
int updatedCount = 0; int updatedCount = 0;
final batch = db.batch(); final batch = db.batch();
@@ -198,7 +195,6 @@ class HistoryDatabase {
await batch.commit(noResult: true); await batch.commit(noResult: true);
} }
// Save current container path
await prefs.setString('ios_last_container_path', _currentContainerPath!); await prefs.setString('ios_last_container_path', _currentContainerPath!);
_log.i('iOS path migration complete: $updatedCount paths updated'); _log.i('iOS path migration complete: $updatedCount paths updated');
@@ -323,7 +319,6 @@ class HistoryDatabase {
}; };
} }
/// Insert or update a history item
Future<void> upsert(Map<String, dynamic> json) async { Future<void> upsert(Map<String, dynamic> json) async {
final db = await database; final db = await database;
await db.insert( await db.insert(
@@ -345,7 +340,6 @@ class HistoryDatabase {
return rows.map(_dbRowToJson).toList(); return rows.map(_dbRowToJson).toList();
} }
/// Get item by ID
Future<Map<String, dynamic>?> getById(String id) async { Future<Map<String, dynamic>?> getById(String id) async {
final db = await database; final db = await database;
final rows = await db.query( final rows = await db.query(
@@ -403,26 +397,22 @@ class HistoryDatabase {
return rows.map((r) => r['spotify_id'] as String).toSet(); return rows.map((r) => r['spotify_id'] as String).toSet();
} }
/// Delete by ID
Future<void> deleteById(String id) async { Future<void> deleteById(String id) async {
final db = await database; final db = await database;
await db.delete('history', where: 'id = ?', whereArgs: [id]); await db.delete('history', where: 'id = ?', whereArgs: [id]);
} }
/// Delete by Spotify ID
Future<void> deleteBySpotifyId(String spotifyId) async { Future<void> deleteBySpotifyId(String spotifyId) async {
final db = await database; final db = await database;
await db.delete('history', where: 'spotify_id = ?', whereArgs: [spotifyId]); await db.delete('history', where: 'spotify_id = ?', whereArgs: [spotifyId]);
} }
/// Clear all history
Future<void> clearAll() async { Future<void> clearAll() async {
final db = await database; final db = await database;
await db.delete('history'); await db.delete('history');
_log.i('Cleared all history'); _log.i('Cleared all history');
} }
/// Get total count
Future<int> getCount() async { Future<int> getCount() async {
final db = await database; final db = await database;
final result = await db.rawQuery('SELECT COUNT(*) as count FROM history'); final result = await db.rawQuery('SELECT COUNT(*) as count FROM history');
@@ -459,7 +449,6 @@ class HistoryDatabase {
return null; return null;
} }
/// Close database
Future<void> close() async { Future<void> close() async {
final db = await database; final db = await database;
await db.close(); await db.close();
+1 -4
View File
@@ -123,7 +123,7 @@ class LibraryDatabase {
return await openDatabase( return await openDatabase(
path, path,
version: 4, // Bumped version for bitrate column version: 4,
onConfigure: (db) async { onConfigure: (db) async {
await db.rawQuery('PRAGMA journal_mode = WAL'); await db.rawQuery('PRAGMA journal_mode = WAL');
await db.execute('PRAGMA synchronous = NORMAL'); await db.execute('PRAGMA synchronous = NORMAL');
@@ -331,13 +331,11 @@ class LibraryDatabase {
String? trackName, String? trackName,
String? artistName, String? artistName,
}) async { }) async {
// First try ISRC if available
if (isrc != null && isrc.isNotEmpty) { if (isrc != null && isrc.isNotEmpty) {
final byIsrc = await getByIsrc(isrc); final byIsrc = await getByIsrc(isrc);
if (byIsrc != null) return byIsrc; if (byIsrc != null) return byIsrc;
} }
// Then try name matching
if (trackName != null && artistName != null) { if (trackName != null && artistName != null) {
final matches = await findByTrackAndArtist(trackName, artistName); final matches = await findByTrackAndArtist(trackName, artistName);
if (matches.isNotEmpty) return matches.first; if (matches.isNotEmpty) return matches.first;
@@ -523,7 +521,6 @@ class LibraryDatabase {
return rows.map((r) => r['file_path'] as String).toSet(); return rows.map((r) => r['file_path'] as String).toSet();
} }
/// Delete multiple items by their file paths
Future<int> deleteByPaths(List<String> filePaths) async { Future<int> deleteByPaths(List<String> filePaths) async {
if (filePaths.isEmpty) return 0; if (filePaths.isEmpty) return 0;
final db = await database; final db = await database;
+12 -20
View File
@@ -35,7 +35,6 @@ class AppTheme {
); );
} }
/// Create dark theme
static ThemeData dark({ static ThemeData dark({
ColorScheme? dynamicScheme, ColorScheme? dynamicScheme,
Color? seedColor, Color? seedColor,
@@ -88,12 +87,11 @@ class AppTheme {
), ),
); );
/// Card theme
static CardThemeData _cardTheme(ColorScheme scheme) => CardThemeData( static CardThemeData _cardTheme(ColorScheme scheme) => CardThemeData(
elevation: 0, elevation: 0,
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
), // 12 -> 16 ),
color: scheme.surfaceContainerLow, color: scheme.surfaceContainerLow,
surfaceTintColor: scheme.surfaceTint, surfaceTintColor: scheme.surfaceTint,
); );
@@ -104,18 +102,17 @@ class AppTheme {
elevation: 1, elevation: 1,
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
), // 20 -> 16 ),
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
), ),
); );
/// Filled button theme
static FilledButtonThemeData _filledButtonTheme(ColorScheme scheme) => static FilledButtonThemeData _filledButtonTheme(ColorScheme scheme) =>
FilledButtonThemeData( FilledButtonThemeData(
style: FilledButton.styleFrom( style: FilledButton.styleFrom(
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
), // 20 -> 16 ),
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
), ),
); );
@@ -125,18 +122,17 @@ class AppTheme {
style: OutlinedButton.styleFrom( style: OutlinedButton.styleFrom(
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
), // 20 -> 16 ),
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
), ),
); );
/// Text button theme
static TextButtonThemeData _textButtonTheme(ColorScheme scheme) => static TextButtonThemeData _textButtonTheme(ColorScheme scheme) =>
TextButtonThemeData( TextButtonThemeData(
style: TextButton.styleFrom( style: TextButton.styleFrom(
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
), // 20 -> 16 ),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
), ),
); );
@@ -149,40 +145,39 @@ class AppTheme {
foregroundColor: scheme.onPrimaryContainer, foregroundColor: scheme.onPrimaryContainer,
); );
/// Input decoration theme
static InputDecorationTheme _inputDecorationTheme(ColorScheme scheme) => static InputDecorationTheme _inputDecorationTheme(ColorScheme scheme) =>
InputDecorationTheme( InputDecorationTheme(
filled: true, filled: true,
fillColor: scheme.surfaceContainerHighest.withValues( fillColor: scheme.surfaceContainerHighest.withValues(
alpha: 0.3, alpha: 0.3,
), // Added transparency ),
border: OutlineInputBorder( border: OutlineInputBorder(
borderRadius: BorderRadius.circular(16), // 12 -> 16 borderRadius: BorderRadius.circular(16),
borderSide: BorderSide.none, borderSide: BorderSide.none,
), ),
enabledBorder: OutlineInputBorder( enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(16), // 12 -> 16 borderRadius: BorderRadius.circular(16),
borderSide: BorderSide.none, borderSide: BorderSide.none,
), ),
focusedBorder: OutlineInputBorder( focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(16), // 12 -> 16 borderRadius: BorderRadius.circular(16),
borderSide: BorderSide(color: scheme.primary, width: 2), borderSide: BorderSide(color: scheme.primary, width: 2),
), ),
errorBorder: OutlineInputBorder( errorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(16), // 12 -> 16 borderRadius: BorderRadius.circular(16),
borderSide: BorderSide(color: scheme.error, width: 1), borderSide: BorderSide(color: scheme.error, width: 1),
), ),
contentPadding: const EdgeInsets.symmetric( contentPadding: const EdgeInsets.symmetric(
horizontal: 20, horizontal: 20,
vertical: 16, vertical: 16,
), // consistent padding ),
); );
static ListTileThemeData _listTileTheme(ColorScheme scheme) => static ListTileThemeData _listTileTheme(ColorScheme scheme) =>
ListTileThemeData( ListTileThemeData(
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
), // 12 -> 16 ),
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
); );
@@ -193,7 +188,6 @@ class AppTheme {
surfaceTintColor: scheme.surfaceTint, surfaceTintColor: scheme.surfaceTint,
); );
/// Navigation bar theme
static NavigationBarThemeData _navigationBarTheme( static NavigationBarThemeData _navigationBarTheme(
ColorScheme scheme, { ColorScheme scheme, {
bool isAmoled = false, bool isAmoled = false,
@@ -213,7 +207,6 @@ class AppTheme {
contentTextStyle: TextStyle(color: scheme.onInverseSurface), contentTextStyle: TextStyle(color: scheme.onInverseSurface),
); );
/// Progress indicator theme
static ProgressIndicatorThemeData _progressIndicatorTheme( static ProgressIndicatorThemeData _progressIndicatorTheme(
ColorScheme scheme, ColorScheme scheme,
) => ProgressIndicatorThemeData( ) => ProgressIndicatorThemeData(
@@ -243,7 +236,6 @@ class AppTheme {
}), }),
); );
/// Chip theme
static ChipThemeData _chipTheme(ColorScheme scheme) => ChipThemeData( static ChipThemeData _chipTheme(ColorScheme scheme) => ChipThemeData(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
backgroundColor: scheme.surfaceContainerLow, backgroundColor: scheme.surfaceContainerLow,
-3
View File
@@ -4,7 +4,6 @@ import 'package:dynamic_color/dynamic_color.dart';
import 'package:spotiflac_android/providers/theme_provider.dart'; import 'package:spotiflac_android/providers/theme_provider.dart';
import 'package:spotiflac_android/theme/app_theme.dart'; import 'package:spotiflac_android/theme/app_theme.dart';
/// Wrapper widget that provides dynamic color support from device wallpaper
class DynamicColorWrapper extends ConsumerWidget { class DynamicColorWrapper extends ConsumerWidget {
final Widget Function(ThemeData light, ThemeData dark, ThemeMode mode) builder; final Widget Function(ThemeData light, ThemeData dark, ThemeMode mode) builder;
@@ -23,7 +22,6 @@ class DynamicColorWrapper extends ConsumerWidget {
ColorScheme darkScheme; ColorScheme darkScheme;
if (themeSettings.useDynamicColor && lightDynamic != null && darkDynamic != null) { if (themeSettings.useDynamicColor && lightDynamic != null && darkDynamic != null) {
// Use dynamic colors from wallpaper (Android 12+)
lightScheme = lightDynamic; lightScheme = lightDynamic;
darkScheme = darkDynamic; darkScheme = darkDynamic;
} else { } else {
@@ -38,7 +36,6 @@ class DynamicColorWrapper extends ConsumerWidget {
); );
} }
// Apply AMOLED mode if enabled (pure black background)
if (themeSettings.useAmoled) { if (themeSettings.useAmoled) {
darkScheme = _applyAmoledColors(darkScheme); darkScheme = _applyAmoledColors(darkScheme);
} }
-2
View File
@@ -22,7 +22,6 @@ class BuiltInService {
}); });
} }
/// Default quality options for built-in services
/// Default quality options for each built-in service /// Default quality options for each built-in service
const _builtInServices = [ const _builtInServices = [
BuiltInService( BuiltInService(
@@ -98,7 +97,6 @@ const _builtInServices = [
), ),
]; ];
/// A reusable widget for selecting download service (built-in + extensions)
class DownloadServicePicker extends ConsumerStatefulWidget { class DownloadServicePicker extends ConsumerStatefulWidget {
final String? trackName; final String? trackName;
final String? artistName; final String? artistName;
+1 -1
View File
@@ -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.7+113 version: 3.8.8+114
environment: environment:
sdk: ^3.10.0 sdk: ^3.10.0