mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-07-03 03:15:51 +02:00
Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 23cab16471 | |||
| 0a892011de | |||
| acb1d957d3 | |||
| 4a492aeefc | |||
| eb143a41fc | |||
| 75db2f162b | |||
| 855d0e3ffc | |||
| 5ccd06cc68 |
@@ -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)
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user