mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-07-03 03:15:51 +02:00
Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4966a84614 | |||
| 9247a775fa | |||
| b185b51b31 | |||
| d98960d053 | |||
| d417743654 | |||
| c4bea124fb | |||
| c37410b5de | |||
| b90c94125c | |||
| efbf5d4c5b | |||
| 35532b0c73 | |||
| 4c09b988e4 | |||
| c673581c32 | |||
| bcd718b178 | |||
| 2b9357cb6d | |||
| 26d84041c7 | |||
| a6d488696b |
@@ -16,7 +16,7 @@ body:
|
||||
options:
|
||||
- label: I have searched existing issues and this bug hasn't been reported yet
|
||||
required: true
|
||||
- label: I am using the latest version of SpotiFLAC
|
||||
- label: I am using the latest version of SpotiFLAC (Stable Version)
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
|
||||
@@ -3,3 +3,6 @@ contact_links:
|
||||
- name: README
|
||||
url: https://github.com/zarzet/SpotiFLAC-Mobile#readme
|
||||
about: Check the README for setup instructions and FAQ
|
||||
- name: Extension Development Guide
|
||||
url: https://zarz.moe/docs
|
||||
about: Documentation for building SpotiFLAC extensions
|
||||
|
||||
@@ -16,7 +16,7 @@ body:
|
||||
options:
|
||||
- label: I have tried downloading with a different service (Tidal/Qobuz/Amazon)
|
||||
required: true
|
||||
- label: I am using the latest version of SpotiFLAC
|
||||
- label: I am using the latest version of SpotiFLAC (Stable Version)
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
|
||||
+113
@@ -1,5 +1,92 @@
|
||||
# Changelog
|
||||
|
||||
## [3.0.0-alpha.3] - 2026-01-12
|
||||
|
||||
### Added
|
||||
|
||||
- **Separate Singles Folder**: Option to organize downloads into Albums/ and Singles/ folders
|
||||
- Based on `album_type` from Spotify/Deezer metadata
|
||||
- Toggle in Settings > Download > Separate Singles Folder
|
||||
- Singles saved to `{output}/Singles/`, albums to `{output}/Albums/`
|
||||
- **Browser-like Polyfills**: New global APIs for easier library porting
|
||||
- `fetch()` - Browser-compatible HTTP API with `json()`, `text()`, `arrayBuffer()` methods
|
||||
- `atob()` / `btoa()` - Global Base64 encoding/decoding
|
||||
- `TextEncoder` / `TextDecoder` - UTF-8 text encoding classes
|
||||
- `URL` / `URLSearchParams` - URL parsing and manipulation classes
|
||||
- Makes porting browser libraries (like `youtubei.js`) much easier
|
||||
|
||||
### Performance
|
||||
|
||||
- **Parallel API Calls**: Download URL fetching now uses parallel requests
|
||||
- Tidal: All 8 APIs requested simultaneously, first success wins
|
||||
- Qobuz: Both APIs requested simultaneously, first success wins
|
||||
- Significantly reduces download URL fetch time
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Duplicate History Entries**: Fixed duplicate entries when re-downloading same track
|
||||
- Detects existing entries by Spotify ID, Deezer ID, or ISRC
|
||||
- Replaces existing entry and moves to top of list
|
||||
- Auto-deduplicates existing history on app load
|
||||
- **Extension Search Fallback**: Fixed error when extension is disabled but still called for search
|
||||
- Now checks if extension is still enabled before calling custom search
|
||||
- Auto-resets search provider to default if extension was disabled
|
||||
- **Permission Error Message**: Fixed download showing "Song not found" when actually a permission error
|
||||
- Now shows proper message: "Cannot write to folder, check storage permission"
|
||||
- Added `permission` error type detection in backend
|
||||
- **Android 13+ Storage Permission**: Fixed storage permission not working on Android 13+
|
||||
- Android 13+ now requests both `MANAGE_EXTERNAL_STORAGE` and `READ_MEDIA_AUDIO`
|
||||
- `MANAGE_EXTERNAL_STORAGE` opens Settings (system-level, persists across app data clear)
|
||||
- `READ_MEDIA_AUDIO` shows dialog (app-level, resets on app data clear)
|
||||
- Proper permission check before showing "granted" status
|
||||
|
||||
---
|
||||
|
||||
## [3.0.0-alpha.2] - 2026-01-12
|
||||
|
||||
### Added
|
||||
|
||||
- **Full HTTP Method Support**: New shortcut methods for all common HTTP verbs
|
||||
- `http.put(url, body, headers)` - PUT requests
|
||||
- `http.delete(url, headers)` - DELETE requests
|
||||
- `http.patch(url, body, headers)` - PATCH requests
|
||||
- `http.clearCookies()` - Clear all cookies for the extension
|
||||
- **Persistent Cookie Jar**: Each extension now has its own cookie jar
|
||||
- Cookies automatically stored from `Set-Cookie` headers
|
||||
- Cookies automatically sent with subsequent requests to same domain
|
||||
- Useful for APIs requiring session cookies (YouTube, etc.)
|
||||
- **Multi-Value Header Support**: Response headers now return arrays for multi-value headers
|
||||
- `Set-Cookie` and other headers with multiple values returned as arrays
|
||||
- Single-value headers still returned as strings for convenience
|
||||
- **Generic HTTP Request Method**: New `http.request()` for full HTTP control
|
||||
- Supports all HTTP methods (GET, POST, PUT, DELETE, PATCH, etc.)
|
||||
- Single options object for cleaner API: `http.request(url, { method, body, headers })`
|
||||
- **Response Helper Properties**: HTTP responses now include convenience properties
|
||||
- `response.ok` - true if status code is 2xx
|
||||
- `response.status` - alias for `statusCode`
|
||||
|
||||
### Fixed
|
||||
|
||||
- **User-Agent Header Respect**: Custom `User-Agent` headers are now respected
|
||||
- Previously, extension-provided User-Agent was overwritten
|
||||
- Now only sets default User-Agent if extension doesn't provide one
|
||||
- **HTTP POST Body Auto-Stringify**: `http.post()` now automatically stringifies objects to JSON
|
||||
- Previously, passing an object as body resulted in `[object Object]`
|
||||
- Now objects and arrays are automatically JSON.stringify'd
|
||||
- String bodies still work as before (no double-encoding)
|
||||
|
||||
### Documentation
|
||||
|
||||
- Updated `docs/EXTENSION_DEVELOPMENT.md`:
|
||||
- Added complete HTTP API documentation with all methods
|
||||
- Added Cookie Jar documentation
|
||||
- Added `http.put()`, `http.delete()`, `http.patch()`, `http.clearCookies()` docs
|
||||
- Added YouTube Music / Innertube API example with custom User-Agent
|
||||
- Added common domain lists for YouTube, SoundCloud, Bandcamp
|
||||
- Improved HTTP API documentation with response properties
|
||||
|
||||
---
|
||||
|
||||
## [3.0.0-alpha.1] - 2026-01-11
|
||||
|
||||
#### Extension System
|
||||
@@ -93,6 +180,32 @@
|
||||
|
||||
---
|
||||
|
||||
## [2.2.8] - 2026-01-12
|
||||
|
||||
### Added
|
||||
|
||||
- **Multi-Select Batch Delete**: Long-press tracks in History to enter selection mode
|
||||
- Select multiple tracks at once
|
||||
- "Select All" and "Delete Selected" actions
|
||||
- Modern Material 3 bottom action bar (slides up from bottom)
|
||||
- Works in both grid and list view modes
|
||||
- **History Filter Tabs**: Filter history by All/Albums/Singles
|
||||
- Album = tracks where album has >1 track in history
|
||||
- Single = tracks where album has only 1 track in history
|
||||
- Filter chips show counts for each category
|
||||
- **Album Grouping View**: When "Albums" filter is selected, tracks are grouped by album
|
||||
- Album cards displayed in 2-column grid with cover art and track count badge
|
||||
- Tap album to open dedicated album detail screen
|
||||
- Album detail shows all downloaded tracks from that album
|
||||
- Multi-select delete support within album view
|
||||
- Auto-navigates back when album has <2 tracks remaining
|
||||
|
||||
### Changed
|
||||
|
||||
- **Issue Templates**: Updated version confirmation checkbox to specify "(Stable Version)"
|
||||
|
||||
---
|
||||
|
||||
## [2.2.7] - 2026-01-11
|
||||
|
||||
### Added
|
||||
|
||||
@@ -218,6 +218,12 @@ class MainActivity: FlutterActivity() {
|
||||
}
|
||||
result.success(null)
|
||||
}
|
||||
"hasSpotifyCredentials" -> {
|
||||
val hasCredentials = withContext(Dispatchers.IO) {
|
||||
Gobackend.checkSpotifyCredentials()
|
||||
}
|
||||
result.success(hasCredentials)
|
||||
}
|
||||
"preWarmTrackCache" -> {
|
||||
val tracksJson = call.argument<String>("tracks") ?: "[]"
|
||||
withContext(Dispatchers.IO) {
|
||||
|
||||
@@ -146,6 +146,7 @@ type deezerAlbumFull struct {
|
||||
CoverXL string `json:"cover_xl"`
|
||||
ReleaseDate string `json:"release_date"`
|
||||
NbTracks int `json:"nb_tracks"`
|
||||
RecordType string `json:"record_type"` // album, single, ep, compile
|
||||
Artist deezerArtist `json:"artist"`
|
||||
Contributors []deezerArtist `json:"contributors"`
|
||||
Tracks struct {
|
||||
@@ -326,6 +327,12 @@ func (c *DeezerClient) GetAlbum(ctx context.Context, albumID string) (*AlbumResp
|
||||
isrcMap := c.fetchISRCsParallel(ctx, album.Tracks.Data)
|
||||
|
||||
tracks := make([]AlbumTrackMetadata, 0, len(album.Tracks.Data))
|
||||
// Normalize record_type (Deezer uses "compile" instead of "compilation")
|
||||
albumType := album.RecordType
|
||||
if albumType == "compile" {
|
||||
albumType = "compilation"
|
||||
}
|
||||
|
||||
for _, track := range album.Tracks.Data {
|
||||
trackIDStr := fmt.Sprintf("%d", track.ID)
|
||||
isrc := isrcMap[trackIDStr]
|
||||
@@ -345,6 +352,7 @@ func (c *DeezerClient) GetAlbum(ctx context.Context, albumID string) (*AlbumResp
|
||||
ExternalURL: track.Link,
|
||||
ISRC: isrc,
|
||||
AlbumID: fmt.Sprintf("deezer:%d", album.ID),
|
||||
AlbumType: albumType,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
+42
-17
@@ -32,18 +32,26 @@ func ParseSpotifyURL(url string) (string, error) {
|
||||
}
|
||||
|
||||
// SetSpotifyAPICredentials sets custom Spotify API credentials from Flutter
|
||||
// Pass empty strings to use default credentials
|
||||
func SetSpotifyAPICredentials(clientID, clientSecret string) {
|
||||
SetSpotifyCredentials(clientID, clientSecret)
|
||||
}
|
||||
|
||||
// CheckSpotifyCredentials checks if Spotify credentials are configured
|
||||
// Returns true if credentials are available (custom or env vars)
|
||||
func CheckSpotifyCredentials() bool {
|
||||
return HasSpotifyCredentials()
|
||||
}
|
||||
|
||||
// GetSpotifyMetadata fetches metadata from Spotify URL
|
||||
// Returns JSON with track/album/playlist data
|
||||
func GetSpotifyMetadata(spotifyURL string) (string, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
client := NewSpotifyMetadataClient()
|
||||
client, err := NewSpotifyMetadataClient()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
data, err := client.GetFilteredData(ctx, spotifyURL, false, 0)
|
||||
if err != nil {
|
||||
return "", err
|
||||
@@ -63,7 +71,10 @@ func SearchSpotify(query string, limit int) (string, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
client := NewSpotifyMetadataClient()
|
||||
client, err := NewSpotifyMetadataClient()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
results, err := client.SearchTracks(ctx, query, limit)
|
||||
if err != nil {
|
||||
return "", err
|
||||
@@ -83,7 +94,10 @@ func SearchSpotifyAll(query string, trackLimit, artistLimit int) (string, error)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
client := NewSpotifyMetadataClient()
|
||||
client, err := NewSpotifyMetadataClient()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
results, err := client.SearchAll(ctx, query, trackLimit, artistLimit)
|
||||
if err != nil {
|
||||
return "", err
|
||||
@@ -893,21 +907,26 @@ func GetSpotifyMetadataWithDeezerFallback(spotifyURL string) (string, error) {
|
||||
defer cancel()
|
||||
|
||||
// Try Spotify first
|
||||
client := NewSpotifyMetadataClient()
|
||||
data, err := client.GetFilteredData(ctx, spotifyURL, false, 0)
|
||||
if err == nil {
|
||||
jsonBytes, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
client, err := NewSpotifyMetadataClient()
|
||||
if err != nil {
|
||||
// No Spotify credentials - fall through to Deezer fallback
|
||||
LogWarn("Spotify", "Credentials not configured, falling back to Deezer")
|
||||
} else {
|
||||
data, err := client.GetFilteredData(ctx, spotifyURL, false, 0)
|
||||
if err == nil {
|
||||
jsonBytes, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
// Check if it's a rate limit error
|
||||
errStr := strings.ToLower(err.Error())
|
||||
if !strings.Contains(errStr, "429") && !strings.Contains(errStr, "rate") && !strings.Contains(errStr, "limit") {
|
||||
// Not a rate limit error, return original error
|
||||
return "", err
|
||||
}
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
// Check if it's a rate limit error
|
||||
errStr := strings.ToLower(err.Error())
|
||||
if !strings.Contains(errStr, "429") && !strings.Contains(errStr, "rate") && !strings.Contains(errStr, "limit") {
|
||||
// Not a rate limit error, return original error
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Rate limited - try Deezer fallback for tracks and albums
|
||||
@@ -996,6 +1015,12 @@ func errorResponse(msg string) (string, error) {
|
||||
strings.Contains(lowerMsg, "try using vpn") ||
|
||||
strings.Contains(lowerMsg, "change dns") {
|
||||
errorType = "isp_blocked"
|
||||
} else if strings.Contains(lowerMsg, "permission") ||
|
||||
strings.Contains(lowerMsg, "operation not permitted") ||
|
||||
strings.Contains(lowerMsg, "access denied") ||
|
||||
strings.Contains(lowerMsg, "failed to create file") ||
|
||||
strings.Contains(lowerMsg, "failed to create directory") {
|
||||
errorType = "permission"
|
||||
} else if strings.Contains(lowerMsg, "not found") ||
|
||||
strings.Contains(lowerMsg, "not available") ||
|
||||
strings.Contains(lowerMsg, "no results") ||
|
||||
|
||||
+851
-17
@@ -101,24 +101,57 @@ type ExtensionRuntime struct {
|
||||
manifest *ExtensionManifest
|
||||
settings map[string]interface{}
|
||||
httpClient *http.Client
|
||||
cookieJar http.CookieJar
|
||||
dataDir string
|
||||
vm *goja.Runtime
|
||||
}
|
||||
|
||||
// NewExtensionRuntime creates a new runtime for an extension
|
||||
func NewExtensionRuntime(ext *LoadedExtension) *ExtensionRuntime {
|
||||
// Create a cookie jar for this extension
|
||||
jar, _ := newSimpleCookieJar()
|
||||
|
||||
client := &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
Jar: jar,
|
||||
}
|
||||
|
||||
return &ExtensionRuntime{
|
||||
extensionID: ext.ID,
|
||||
manifest: ext.Manifest,
|
||||
settings: make(map[string]interface{}),
|
||||
httpClient: &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
},
|
||||
dataDir: ext.DataDir,
|
||||
vm: ext.VM,
|
||||
httpClient: client,
|
||||
cookieJar: jar,
|
||||
dataDir: ext.DataDir,
|
||||
vm: ext.VM,
|
||||
}
|
||||
}
|
||||
|
||||
// simpleCookieJar is a simple in-memory cookie jar
|
||||
type simpleCookieJar struct {
|
||||
cookies map[string][]*http.Cookie
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
func newSimpleCookieJar() (*simpleCookieJar, error) {
|
||||
return &simpleCookieJar{
|
||||
cookies: make(map[string][]*http.Cookie),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (j *simpleCookieJar) SetCookies(u *url.URL, cookies []*http.Cookie) {
|
||||
j.mu.Lock()
|
||||
defer j.mu.Unlock()
|
||||
key := u.Host
|
||||
j.cookies[key] = append(j.cookies[key], cookies...)
|
||||
}
|
||||
|
||||
func (j *simpleCookieJar) Cookies(u *url.URL) []*http.Cookie {
|
||||
j.mu.RLock()
|
||||
defer j.mu.RUnlock()
|
||||
return j.cookies[u.Host]
|
||||
}
|
||||
|
||||
// SetSettings updates the runtime settings
|
||||
func (r *ExtensionRuntime) SetSettings(settings map[string]interface{}) {
|
||||
r.settings = settings
|
||||
@@ -132,6 +165,11 @@ func (r *ExtensionRuntime) RegisterAPIs(vm *goja.Runtime) {
|
||||
httpObj := vm.NewObject()
|
||||
httpObj.Set("get", r.httpGet)
|
||||
httpObj.Set("post", r.httpPost)
|
||||
httpObj.Set("put", r.httpPut)
|
||||
httpObj.Set("delete", r.httpDelete)
|
||||
httpObj.Set("patch", r.httpPatch)
|
||||
httpObj.Set("request", r.httpRequest) // Generic HTTP request (GET, POST, PUT, DELETE, etc.)
|
||||
httpObj.Set("clearCookies", r.httpClearCookies)
|
||||
vm.Set("http", httpObj)
|
||||
|
||||
// Storage API
|
||||
@@ -211,6 +249,25 @@ func (r *ExtensionRuntime) RegisterAPIs(vm *goja.Runtime) {
|
||||
gobackendObj := vm.NewObject()
|
||||
gobackendObj.Set("sanitizeFilename", r.sanitizeFilenameWrapper)
|
||||
vm.Set("gobackend", gobackendObj)
|
||||
|
||||
// ==================== Browser-like Polyfills ====================
|
||||
// These make porting browser/Node.js libraries easier
|
||||
|
||||
// Global fetch() - Promise-style HTTP API (browser-compatible)
|
||||
vm.Set("fetch", r.fetchPolyfill)
|
||||
|
||||
// Global atob/btoa - Base64 encoding (browser-compatible)
|
||||
vm.Set("atob", r.atobPolyfill)
|
||||
vm.Set("btoa", r.btoaPolyfill)
|
||||
|
||||
// TextEncoder/TextDecoder constructors
|
||||
r.registerTextEncoderDecoder(vm)
|
||||
|
||||
// URL class for URL parsing
|
||||
r.registerURLClass(vm)
|
||||
|
||||
// JSON global (browser-compatible)
|
||||
r.registerJSONGlobal(vm)
|
||||
}
|
||||
|
||||
// ==================== HTTP API (Sandboxed) ====================
|
||||
@@ -274,11 +331,14 @@ func (r *ExtensionRuntime) httpGet(call goja.FunctionCall) goja.Value {
|
||||
})
|
||||
}
|
||||
|
||||
// Set headers
|
||||
// Set headers - user headers first
|
||||
for k, v := range headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
req.Header.Set("User-Agent", "Spotiflac-Extension/1.0")
|
||||
// Only set default User-Agent if not provided by extension
|
||||
if req.Header.Get("User-Agent") == "" {
|
||||
req.Header.Set("User-Agent", "Spotiflac-Extension/1.0")
|
||||
}
|
||||
|
||||
// Execute request
|
||||
resp, err := r.httpClient.Do(req)
|
||||
@@ -297,16 +357,20 @@ func (r *ExtensionRuntime) httpGet(call goja.FunctionCall) goja.Value {
|
||||
})
|
||||
}
|
||||
|
||||
// Extract response headers
|
||||
respHeaders := make(map[string]string)
|
||||
// Extract response headers - return all values as arrays for multi-value headers (cookies, etc.)
|
||||
respHeaders := make(map[string]interface{})
|
||||
for k, v := range resp.Header {
|
||||
if len(v) > 0 {
|
||||
if len(v) == 1 {
|
||||
respHeaders[k] = v[0]
|
||||
} else {
|
||||
respHeaders[k] = v // Return as array if multiple values
|
||||
}
|
||||
}
|
||||
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"statusCode": resp.StatusCode,
|
||||
"status": resp.StatusCode, // Alias for convenience
|
||||
"ok": resp.StatusCode >= 200 && resp.StatusCode < 300,
|
||||
"body": string(body),
|
||||
"headers": respHeaders,
|
||||
})
|
||||
@@ -330,10 +394,26 @@ func (r *ExtensionRuntime) httpPost(call goja.FunctionCall) goja.Value {
|
||||
})
|
||||
}
|
||||
|
||||
// Get body if provided
|
||||
// Get body if provided - support both string and object
|
||||
var bodyStr string
|
||||
if len(call.Arguments) > 1 && !goja.IsUndefined(call.Arguments[1]) && !goja.IsNull(call.Arguments[1]) {
|
||||
bodyStr = call.Arguments[1].String()
|
||||
bodyArg := call.Arguments[1].Export()
|
||||
switch v := bodyArg.(type) {
|
||||
case string:
|
||||
bodyStr = v
|
||||
case map[string]interface{}, []interface{}:
|
||||
// Auto-stringify objects and arrays to JSON
|
||||
jsonBytes, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"error": fmt.Sprintf("failed to stringify body: %v", err),
|
||||
})
|
||||
}
|
||||
bodyStr = string(jsonBytes)
|
||||
default:
|
||||
// Fallback to string conversion
|
||||
bodyStr = call.Arguments[1].String()
|
||||
}
|
||||
}
|
||||
|
||||
// Get headers if provided
|
||||
@@ -355,11 +435,14 @@ func (r *ExtensionRuntime) httpPost(call goja.FunctionCall) goja.Value {
|
||||
})
|
||||
}
|
||||
|
||||
// Set headers
|
||||
// Set headers - user headers first
|
||||
for k, v := range headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
req.Header.Set("User-Agent", "Spotiflac-Extension/1.0")
|
||||
// Only set defaults if not provided by extension
|
||||
if req.Header.Get("User-Agent") == "" {
|
||||
req.Header.Set("User-Agent", "Spotiflac-Extension/1.0")
|
||||
}
|
||||
if req.Header.Get("Content-Type") == "" {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
@@ -381,21 +464,298 @@ func (r *ExtensionRuntime) httpPost(call goja.FunctionCall) goja.Value {
|
||||
})
|
||||
}
|
||||
|
||||
// Extract response headers
|
||||
respHeaders := make(map[string]string)
|
||||
// Extract response headers - return all values as arrays for multi-value headers
|
||||
respHeaders := make(map[string]interface{})
|
||||
for k, v := range resp.Header {
|
||||
if len(v) > 0 {
|
||||
if len(v) == 1 {
|
||||
respHeaders[k] = v[0]
|
||||
} else {
|
||||
respHeaders[k] = v // Return as array if multiple values
|
||||
}
|
||||
}
|
||||
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"statusCode": resp.StatusCode,
|
||||
"status": resp.StatusCode, // Alias for convenience
|
||||
"ok": resp.StatusCode >= 200 && resp.StatusCode < 300,
|
||||
"body": string(body),
|
||||
"headers": respHeaders,
|
||||
})
|
||||
}
|
||||
|
||||
// httpRequest performs a generic HTTP request (GET, POST, PUT, DELETE, etc.)
|
||||
// Usage: http.request(url, options) where options = { method, body, headers }
|
||||
func (r *ExtensionRuntime) httpRequest(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"error": "URL is required",
|
||||
})
|
||||
}
|
||||
|
||||
urlStr := call.Arguments[0].String()
|
||||
|
||||
// Validate domain
|
||||
if err := r.validateDomain(urlStr); err != nil {
|
||||
GoLog("[Extension:%s] HTTP blocked: %v\n", r.extensionID, err)
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
// Default options
|
||||
method := "GET"
|
||||
var bodyStr string
|
||||
headers := make(map[string]string)
|
||||
|
||||
// Parse options if provided
|
||||
if len(call.Arguments) > 1 && !goja.IsUndefined(call.Arguments[1]) && !goja.IsNull(call.Arguments[1]) {
|
||||
optionsObj := call.Arguments[1].Export()
|
||||
if opts, ok := optionsObj.(map[string]interface{}); ok {
|
||||
// Get method
|
||||
if m, ok := opts["method"].(string); ok {
|
||||
method = strings.ToUpper(m)
|
||||
}
|
||||
|
||||
// Get body - support both string and object
|
||||
if bodyArg, ok := opts["body"]; ok && bodyArg != nil {
|
||||
switch v := bodyArg.(type) {
|
||||
case string:
|
||||
bodyStr = v
|
||||
case map[string]interface{}, []interface{}:
|
||||
// Auto-stringify objects and arrays to JSON
|
||||
jsonBytes, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"error": fmt.Sprintf("failed to stringify body: %v", err),
|
||||
})
|
||||
}
|
||||
bodyStr = string(jsonBytes)
|
||||
default:
|
||||
bodyStr = fmt.Sprintf("%v", v)
|
||||
}
|
||||
}
|
||||
|
||||
// Get headers
|
||||
if h, ok := opts["headers"].(map[string]interface{}); ok {
|
||||
for k, v := range h {
|
||||
headers[k] = fmt.Sprintf("%v", v)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create request
|
||||
var reqBody io.Reader
|
||||
if bodyStr != "" {
|
||||
reqBody = strings.NewReader(bodyStr)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(method, urlStr, reqBody)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
// Set headers - user headers first
|
||||
for k, v := range headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
// Only set defaults if not provided by extension
|
||||
if req.Header.Get("User-Agent") == "" {
|
||||
req.Header.Set("User-Agent", "Spotiflac-Extension/1.0")
|
||||
}
|
||||
if bodyStr != "" && req.Header.Get("Content-Type") == "" {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
|
||||
// Execute request
|
||||
resp, err := r.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Read body
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
// Extract response headers - return all values as arrays for multi-value headers
|
||||
respHeaders := make(map[string]interface{})
|
||||
for k, v := range resp.Header {
|
||||
if len(v) == 1 {
|
||||
respHeaders[k] = v[0]
|
||||
} else {
|
||||
respHeaders[k] = v // Return as array if multiple values
|
||||
}
|
||||
}
|
||||
|
||||
// Return response with helper properties
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"statusCode": resp.StatusCode,
|
||||
"status": resp.StatusCode, // Alias for convenience
|
||||
"ok": resp.StatusCode >= 200 && resp.StatusCode < 300,
|
||||
"body": string(body),
|
||||
"headers": respHeaders,
|
||||
})
|
||||
}
|
||||
|
||||
// httpPut performs a PUT request (shortcut for http.request with method: "PUT")
|
||||
func (r *ExtensionRuntime) httpPut(call goja.FunctionCall) goja.Value {
|
||||
return r.httpMethodShortcut("PUT", call)
|
||||
}
|
||||
|
||||
// httpDelete performs a DELETE request (shortcut for http.request with method: "DELETE")
|
||||
func (r *ExtensionRuntime) httpDelete(call goja.FunctionCall) goja.Value {
|
||||
return r.httpMethodShortcut("DELETE", call)
|
||||
}
|
||||
|
||||
// httpPatch performs a PATCH request (shortcut for http.request with method: "PATCH")
|
||||
func (r *ExtensionRuntime) httpPatch(call goja.FunctionCall) goja.Value {
|
||||
return r.httpMethodShortcut("PATCH", call)
|
||||
}
|
||||
|
||||
// httpMethodShortcut is a helper for PUT/DELETE/PATCH shortcuts
|
||||
// Signature: http.put(url, body, headers) / http.delete(url, headers) / http.patch(url, body, headers)
|
||||
func (r *ExtensionRuntime) httpMethodShortcut(method string, call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"error": "URL is required",
|
||||
})
|
||||
}
|
||||
|
||||
urlStr := call.Arguments[0].String()
|
||||
|
||||
// Validate domain
|
||||
if err := r.validateDomain(urlStr); err != nil {
|
||||
GoLog("[Extension:%s] HTTP blocked: %v\n", r.extensionID, err)
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
var bodyStr string
|
||||
headers := make(map[string]string)
|
||||
|
||||
// For DELETE, second arg is headers; for PUT/PATCH, second arg is body
|
||||
if method == "DELETE" {
|
||||
// http.delete(url, headers)
|
||||
if len(call.Arguments) > 1 && !goja.IsUndefined(call.Arguments[1]) && !goja.IsNull(call.Arguments[1]) {
|
||||
headersObj := call.Arguments[1].Export()
|
||||
if h, ok := headersObj.(map[string]interface{}); ok {
|
||||
for k, v := range h {
|
||||
headers[k] = fmt.Sprintf("%v", v)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// http.put(url, body, headers) / http.patch(url, body, headers)
|
||||
if len(call.Arguments) > 1 && !goja.IsUndefined(call.Arguments[1]) && !goja.IsNull(call.Arguments[1]) {
|
||||
bodyArg := call.Arguments[1].Export()
|
||||
switch v := bodyArg.(type) {
|
||||
case string:
|
||||
bodyStr = v
|
||||
case map[string]interface{}, []interface{}:
|
||||
jsonBytes, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"error": fmt.Sprintf("failed to stringify body: %v", err),
|
||||
})
|
||||
}
|
||||
bodyStr = string(jsonBytes)
|
||||
default:
|
||||
bodyStr = call.Arguments[1].String()
|
||||
}
|
||||
}
|
||||
|
||||
if len(call.Arguments) > 2 && !goja.IsUndefined(call.Arguments[2]) && !goja.IsNull(call.Arguments[2]) {
|
||||
headersObj := call.Arguments[2].Export()
|
||||
if h, ok := headersObj.(map[string]interface{}); ok {
|
||||
for k, v := range h {
|
||||
headers[k] = fmt.Sprintf("%v", v)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create request
|
||||
var reqBody io.Reader
|
||||
if bodyStr != "" {
|
||||
reqBody = strings.NewReader(bodyStr)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(method, urlStr, reqBody)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
// Set headers - user headers first
|
||||
for k, v := range headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
if req.Header.Get("User-Agent") == "" {
|
||||
req.Header.Set("User-Agent", "Spotiflac-Extension/1.0")
|
||||
}
|
||||
if bodyStr != "" && req.Header.Get("Content-Type") == "" {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
|
||||
// Execute request
|
||||
resp, err := r.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Read body
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
// Extract response headers
|
||||
respHeaders := make(map[string]interface{})
|
||||
for k, v := range resp.Header {
|
||||
if len(v) == 1 {
|
||||
respHeaders[k] = v[0]
|
||||
} else {
|
||||
respHeaders[k] = v
|
||||
}
|
||||
}
|
||||
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"statusCode": resp.StatusCode,
|
||||
"status": resp.StatusCode,
|
||||
"ok": resp.StatusCode >= 200 && resp.StatusCode < 300,
|
||||
"body": string(body),
|
||||
"headers": respHeaders,
|
||||
})
|
||||
}
|
||||
|
||||
// httpClearCookies clears all cookies for this extension
|
||||
func (r *ExtensionRuntime) httpClearCookies(call goja.FunctionCall) goja.Value {
|
||||
if jar, ok := r.cookieJar.(*simpleCookieJar); ok {
|
||||
jar.mu.Lock()
|
||||
jar.cookies = make(map[string][]*http.Cookie)
|
||||
jar.mu.Unlock()
|
||||
GoLog("[Extension:%s] Cookies cleared\n", r.extensionID)
|
||||
return r.vm.ToValue(true)
|
||||
}
|
||||
return r.vm.ToValue(false)
|
||||
}
|
||||
|
||||
// ==================== File API (Sandboxed) ====================
|
||||
|
||||
// validatePath checks if the path is within the extension's data directory
|
||||
@@ -1933,3 +2293,477 @@ func normalizeStringForMatching(s string) string {
|
||||
|
||||
return strings.TrimSpace(s)
|
||||
}
|
||||
|
||||
// ==================== Browser-like Polyfills ====================
|
||||
// These polyfills make porting browser/Node.js libraries easier
|
||||
// without compromising sandbox security
|
||||
|
||||
// fetchPolyfill implements browser-compatible fetch() API
|
||||
// Returns a Promise-like object with json(), text() methods
|
||||
func (r *ExtensionRuntime) fetchPolyfill(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.createFetchError("URL is required")
|
||||
}
|
||||
|
||||
urlStr := call.Arguments[0].String()
|
||||
|
||||
// Validate domain
|
||||
if err := r.validateDomain(urlStr); err != nil {
|
||||
GoLog("[Extension:%s] fetch blocked: %v\n", r.extensionID, err)
|
||||
return r.createFetchError(err.Error())
|
||||
}
|
||||
|
||||
// Parse options
|
||||
method := "GET"
|
||||
var bodyStr string
|
||||
headers := make(map[string]string)
|
||||
|
||||
if len(call.Arguments) > 1 && !goja.IsUndefined(call.Arguments[1]) && !goja.IsNull(call.Arguments[1]) {
|
||||
optionsObj := call.Arguments[1].Export()
|
||||
if opts, ok := optionsObj.(map[string]interface{}); ok {
|
||||
// Method
|
||||
if m, ok := opts["method"].(string); ok {
|
||||
method = strings.ToUpper(m)
|
||||
}
|
||||
|
||||
// Body - support string, object (auto-stringify), or nil
|
||||
if bodyArg, ok := opts["body"]; ok && bodyArg != nil {
|
||||
switch v := bodyArg.(type) {
|
||||
case string:
|
||||
bodyStr = v
|
||||
case map[string]interface{}, []interface{}:
|
||||
jsonBytes, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return r.createFetchError(fmt.Sprintf("failed to stringify body: %v", err))
|
||||
}
|
||||
bodyStr = string(jsonBytes)
|
||||
default:
|
||||
bodyStr = fmt.Sprintf("%v", v)
|
||||
}
|
||||
}
|
||||
|
||||
// Headers
|
||||
if h, ok := opts["headers"]; ok && h != nil {
|
||||
switch hv := h.(type) {
|
||||
case map[string]interface{}:
|
||||
for k, v := range hv {
|
||||
headers[k] = fmt.Sprintf("%v", v)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create HTTP request
|
||||
var reqBody io.Reader
|
||||
if bodyStr != "" {
|
||||
reqBody = strings.NewReader(bodyStr)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(method, urlStr, reqBody)
|
||||
if err != nil {
|
||||
return r.createFetchError(err.Error())
|
||||
}
|
||||
|
||||
// Set headers - user headers first
|
||||
for k, v := range headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
// Set defaults if not provided
|
||||
if req.Header.Get("User-Agent") == "" {
|
||||
req.Header.Set("User-Agent", "SpotiFLAC-Extension/1.0")
|
||||
}
|
||||
if bodyStr != "" && req.Header.Get("Content-Type") == "" {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
|
||||
// Execute request
|
||||
resp, err := r.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return r.createFetchError(err.Error())
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Read body
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return r.createFetchError(err.Error())
|
||||
}
|
||||
|
||||
// Extract response headers
|
||||
respHeaders := make(map[string]interface{})
|
||||
for k, v := range resp.Header {
|
||||
if len(v) == 1 {
|
||||
respHeaders[k] = v[0]
|
||||
} else {
|
||||
respHeaders[k] = v
|
||||
}
|
||||
}
|
||||
|
||||
// Create Response object (browser-compatible)
|
||||
responseObj := r.vm.NewObject()
|
||||
responseObj.Set("ok", resp.StatusCode >= 200 && resp.StatusCode < 300)
|
||||
responseObj.Set("status", resp.StatusCode)
|
||||
responseObj.Set("statusText", http.StatusText(resp.StatusCode))
|
||||
responseObj.Set("headers", respHeaders)
|
||||
responseObj.Set("url", urlStr)
|
||||
|
||||
// Store body for methods
|
||||
bodyString := string(body)
|
||||
|
||||
// text() method - returns body as string
|
||||
responseObj.Set("text", func(call goja.FunctionCall) goja.Value {
|
||||
return r.vm.ToValue(bodyString)
|
||||
})
|
||||
|
||||
// json() method - parses body as JSON
|
||||
responseObj.Set("json", func(call goja.FunctionCall) goja.Value {
|
||||
var result interface{}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
GoLog("[Extension:%s] fetch json() parse error: %v\n", r.extensionID, err)
|
||||
return goja.Undefined()
|
||||
}
|
||||
return r.vm.ToValue(result)
|
||||
})
|
||||
|
||||
// arrayBuffer() method - returns body as array (simplified)
|
||||
responseObj.Set("arrayBuffer", func(call goja.FunctionCall) goja.Value {
|
||||
// Return as array of bytes
|
||||
byteArray := make([]interface{}, len(body))
|
||||
for i, b := range body {
|
||||
byteArray[i] = int(b)
|
||||
}
|
||||
return r.vm.ToValue(byteArray)
|
||||
})
|
||||
|
||||
return responseObj
|
||||
}
|
||||
|
||||
// createFetchError creates a fetch error response
|
||||
func (r *ExtensionRuntime) createFetchError(message string) goja.Value {
|
||||
errorObj := r.vm.NewObject()
|
||||
errorObj.Set("ok", false)
|
||||
errorObj.Set("status", 0)
|
||||
errorObj.Set("statusText", "Network Error")
|
||||
errorObj.Set("error", message)
|
||||
errorObj.Set("text", func(call goja.FunctionCall) goja.Value {
|
||||
return r.vm.ToValue("")
|
||||
})
|
||||
errorObj.Set("json", func(call goja.FunctionCall) goja.Value {
|
||||
return goja.Undefined()
|
||||
})
|
||||
return errorObj
|
||||
}
|
||||
|
||||
// atobPolyfill implements browser atob() - decode base64 to string
|
||||
func (r *ExtensionRuntime) atobPolyfill(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue("")
|
||||
}
|
||||
input := call.Arguments[0].String()
|
||||
decoded, err := base64.StdEncoding.DecodeString(input)
|
||||
if err != nil {
|
||||
// Try URL-safe base64
|
||||
decoded, err = base64.URLEncoding.DecodeString(input)
|
||||
if err != nil {
|
||||
GoLog("[Extension:%s] atob decode error: %v\n", r.extensionID, err)
|
||||
return r.vm.ToValue("")
|
||||
}
|
||||
}
|
||||
return r.vm.ToValue(string(decoded))
|
||||
}
|
||||
|
||||
// btoaPolyfill implements browser btoa() - encode string to base64
|
||||
func (r *ExtensionRuntime) btoaPolyfill(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue("")
|
||||
}
|
||||
input := call.Arguments[0].String()
|
||||
return r.vm.ToValue(base64.StdEncoding.EncodeToString([]byte(input)))
|
||||
}
|
||||
|
||||
// registerTextEncoderDecoder registers TextEncoder and TextDecoder classes
|
||||
func (r *ExtensionRuntime) registerTextEncoderDecoder(vm *goja.Runtime) {
|
||||
// TextEncoder constructor
|
||||
vm.Set("TextEncoder", func(call goja.ConstructorCall) *goja.Object {
|
||||
encoder := call.This
|
||||
encoder.Set("encoding", "utf-8")
|
||||
|
||||
// encode() method - string to Uint8Array
|
||||
encoder.Set("encode", func(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return vm.ToValue([]byte{})
|
||||
}
|
||||
input := call.Arguments[0].String()
|
||||
bytes := []byte(input)
|
||||
|
||||
// Return as array (Uint8Array-like)
|
||||
result := make([]interface{}, len(bytes))
|
||||
for i, b := range bytes {
|
||||
result[i] = int(b)
|
||||
}
|
||||
return vm.ToValue(result)
|
||||
})
|
||||
|
||||
// encodeInto() method
|
||||
encoder.Set("encodeInto", func(call goja.FunctionCall) goja.Value {
|
||||
// Simplified implementation
|
||||
if len(call.Arguments) < 2 {
|
||||
return vm.ToValue(map[string]interface{}{"read": 0, "written": 0})
|
||||
}
|
||||
input := call.Arguments[0].String()
|
||||
return vm.ToValue(map[string]interface{}{
|
||||
"read": len(input),
|
||||
"written": len([]byte(input)),
|
||||
})
|
||||
})
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
// TextDecoder constructor
|
||||
vm.Set("TextDecoder", func(call goja.ConstructorCall) *goja.Object {
|
||||
decoder := call.This
|
||||
|
||||
// Get encoding from arguments (default: utf-8)
|
||||
encoding := "utf-8"
|
||||
if len(call.Arguments) > 0 && !goja.IsUndefined(call.Arguments[0]) {
|
||||
encoding = call.Arguments[0].String()
|
||||
}
|
||||
decoder.Set("encoding", encoding)
|
||||
decoder.Set("fatal", false)
|
||||
decoder.Set("ignoreBOM", false)
|
||||
|
||||
// decode() method - Uint8Array to string
|
||||
decoder.Set("decode", func(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return vm.ToValue("")
|
||||
}
|
||||
|
||||
// Handle different input types
|
||||
input := call.Arguments[0].Export()
|
||||
var bytes []byte
|
||||
|
||||
switch v := input.(type) {
|
||||
case []byte:
|
||||
bytes = v
|
||||
case []interface{}:
|
||||
bytes = make([]byte, len(v))
|
||||
for i, val := range v {
|
||||
switch n := val.(type) {
|
||||
case int64:
|
||||
bytes[i] = byte(n)
|
||||
case float64:
|
||||
bytes[i] = byte(n)
|
||||
case int:
|
||||
bytes[i] = byte(n)
|
||||
}
|
||||
}
|
||||
case string:
|
||||
// Already a string, just return it
|
||||
return vm.ToValue(v)
|
||||
default:
|
||||
return vm.ToValue("")
|
||||
}
|
||||
|
||||
return vm.ToValue(string(bytes))
|
||||
})
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// registerURLClass registers the URL class for URL parsing
|
||||
func (r *ExtensionRuntime) registerURLClass(vm *goja.Runtime) {
|
||||
vm.Set("URL", func(call goja.ConstructorCall) *goja.Object {
|
||||
urlObj := call.This
|
||||
|
||||
if len(call.Arguments) < 1 {
|
||||
urlObj.Set("href", "")
|
||||
return nil
|
||||
}
|
||||
|
||||
urlStr := call.Arguments[0].String()
|
||||
|
||||
// Handle relative URLs with base
|
||||
if len(call.Arguments) > 1 && !goja.IsUndefined(call.Arguments[1]) {
|
||||
baseStr := call.Arguments[1].String()
|
||||
baseURL, err := url.Parse(baseStr)
|
||||
if err == nil {
|
||||
relURL, err := url.Parse(urlStr)
|
||||
if err == nil {
|
||||
urlStr = baseURL.ResolveReference(relURL).String()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
parsed, err := url.Parse(urlStr)
|
||||
if err != nil {
|
||||
urlObj.Set("href", urlStr)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Set URL properties
|
||||
urlObj.Set("href", parsed.String())
|
||||
urlObj.Set("protocol", parsed.Scheme+":")
|
||||
urlObj.Set("host", parsed.Host)
|
||||
urlObj.Set("hostname", parsed.Hostname())
|
||||
urlObj.Set("port", parsed.Port())
|
||||
urlObj.Set("pathname", parsed.Path)
|
||||
urlObj.Set("search", "")
|
||||
if parsed.RawQuery != "" {
|
||||
urlObj.Set("search", "?"+parsed.RawQuery)
|
||||
}
|
||||
urlObj.Set("hash", "")
|
||||
if parsed.Fragment != "" {
|
||||
urlObj.Set("hash", "#"+parsed.Fragment)
|
||||
}
|
||||
urlObj.Set("origin", parsed.Scheme+"://"+parsed.Host)
|
||||
urlObj.Set("username", parsed.User.Username())
|
||||
password, _ := parsed.User.Password()
|
||||
urlObj.Set("password", password)
|
||||
|
||||
// searchParams object
|
||||
searchParams := vm.NewObject()
|
||||
queryValues := parsed.Query()
|
||||
|
||||
searchParams.Set("get", func(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return goja.Null()
|
||||
}
|
||||
key := call.Arguments[0].String()
|
||||
if val := queryValues.Get(key); val != "" {
|
||||
return vm.ToValue(val)
|
||||
}
|
||||
return goja.Null()
|
||||
})
|
||||
|
||||
searchParams.Set("getAll", func(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return vm.ToValue([]string{})
|
||||
}
|
||||
key := call.Arguments[0].String()
|
||||
return vm.ToValue(queryValues[key])
|
||||
})
|
||||
|
||||
searchParams.Set("has", func(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return vm.ToValue(false)
|
||||
}
|
||||
key := call.Arguments[0].String()
|
||||
return vm.ToValue(queryValues.Has(key))
|
||||
})
|
||||
|
||||
searchParams.Set("toString", func(call goja.FunctionCall) goja.Value {
|
||||
return vm.ToValue(queryValues.Encode())
|
||||
})
|
||||
|
||||
urlObj.Set("searchParams", searchParams)
|
||||
|
||||
// toString method
|
||||
urlObj.Set("toString", func(call goja.FunctionCall) goja.Value {
|
||||
return vm.ToValue(parsed.String())
|
||||
})
|
||||
|
||||
// toJSON method
|
||||
urlObj.Set("toJSON", func(call goja.FunctionCall) goja.Value {
|
||||
return vm.ToValue(parsed.String())
|
||||
})
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
// URLSearchParams constructor
|
||||
vm.Set("URLSearchParams", func(call goja.ConstructorCall) *goja.Object {
|
||||
paramsObj := call.This
|
||||
values := url.Values{}
|
||||
|
||||
// Parse initial value if provided
|
||||
if len(call.Arguments) > 0 && !goja.IsUndefined(call.Arguments[0]) {
|
||||
init := call.Arguments[0].Export()
|
||||
switch v := init.(type) {
|
||||
case string:
|
||||
// Parse query string
|
||||
parsed, _ := url.ParseQuery(strings.TrimPrefix(v, "?"))
|
||||
values = parsed
|
||||
case map[string]interface{}:
|
||||
for k, val := range v {
|
||||
values.Set(k, fmt.Sprintf("%v", val))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
paramsObj.Set("append", func(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) >= 2 {
|
||||
values.Add(call.Arguments[0].String(), call.Arguments[1].String())
|
||||
}
|
||||
return goja.Undefined()
|
||||
})
|
||||
|
||||
paramsObj.Set("delete", func(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) >= 1 {
|
||||
values.Del(call.Arguments[0].String())
|
||||
}
|
||||
return goja.Undefined()
|
||||
})
|
||||
|
||||
paramsObj.Set("get", func(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return goja.Null()
|
||||
}
|
||||
if val := values.Get(call.Arguments[0].String()); val != "" {
|
||||
return vm.ToValue(val)
|
||||
}
|
||||
return goja.Null()
|
||||
})
|
||||
|
||||
paramsObj.Set("getAll", func(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return vm.ToValue([]string{})
|
||||
}
|
||||
return vm.ToValue(values[call.Arguments[0].String()])
|
||||
})
|
||||
|
||||
paramsObj.Set("has", func(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return vm.ToValue(false)
|
||||
}
|
||||
return vm.ToValue(values.Has(call.Arguments[0].String()))
|
||||
})
|
||||
|
||||
paramsObj.Set("set", func(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) >= 2 {
|
||||
values.Set(call.Arguments[0].String(), call.Arguments[1].String())
|
||||
}
|
||||
return goja.Undefined()
|
||||
})
|
||||
|
||||
paramsObj.Set("toString", func(call goja.FunctionCall) goja.Value {
|
||||
return vm.ToValue(values.Encode())
|
||||
})
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// registerJSONGlobal ensures JSON global is properly set up
|
||||
func (r *ExtensionRuntime) registerJSONGlobal(vm *goja.Runtime) {
|
||||
// JSON is already built-in to Goja, but we can enhance it
|
||||
// This ensures JSON.parse and JSON.stringify work as expected
|
||||
|
||||
// The built-in JSON object should already work, but let's verify
|
||||
// and add any missing functionality if needed
|
||||
jsonScript := `
|
||||
if (typeof JSON === 'undefined') {
|
||||
var JSON = {
|
||||
parse: function(text) {
|
||||
return utils.parseJSON(text);
|
||||
},
|
||||
stringify: function(value, replacer, space) {
|
||||
return utils.stringifyJSON(value);
|
||||
}
|
||||
};
|
||||
}
|
||||
`
|
||||
_, _ = vm.RunString(jsonScript)
|
||||
}
|
||||
|
||||
+124
-2
@@ -12,6 +12,7 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// QobuzDownloader handles Qobuz downloads
|
||||
@@ -635,6 +636,125 @@ func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistNam
|
||||
return nil, fmt.Errorf("no matching track found for: %s - %s", artistName, trackName)
|
||||
}
|
||||
|
||||
// qobuzAPIResult holds the result from a parallel API request
|
||||
type qobuzAPIResult struct {
|
||||
apiURL string
|
||||
downloadURL string
|
||||
err error
|
||||
duration time.Duration
|
||||
}
|
||||
|
||||
// getQobuzDownloadURLParallel requests download URL from all APIs in parallel
|
||||
// "Siapa cepat dia dapat" - first successful response wins
|
||||
func getQobuzDownloadURLParallel(apis []string, trackID int64, quality string) (string, string, error) {
|
||||
if len(apis) == 0 {
|
||||
return "", "", fmt.Errorf("no APIs available")
|
||||
}
|
||||
|
||||
GoLog("[Qobuz] Requesting download URL from %d APIs in parallel...\n", len(apis))
|
||||
|
||||
resultChan := make(chan qobuzAPIResult, len(apis))
|
||||
startTime := time.Now()
|
||||
|
||||
// Start all requests in parallel
|
||||
for _, apiURL := range apis {
|
||||
go func(api string) {
|
||||
reqStart := time.Now()
|
||||
|
||||
client := &http.Client{
|
||||
Timeout: 15 * time.Second,
|
||||
}
|
||||
|
||||
reqURL := fmt.Sprintf("%s%d&quality=%s", api, trackID, quality)
|
||||
|
||||
req, err := http.NewRequest("GET", reqURL, nil)
|
||||
if err != nil {
|
||||
resultChan <- qobuzAPIResult{apiURL: api, err: err, duration: time.Since(reqStart)}
|
||||
return
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
resultChan <- qobuzAPIResult{apiURL: api, err: err, duration: time.Since(reqStart)}
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
resultChan <- qobuzAPIResult{apiURL: api, err: fmt.Errorf("HTTP %d", resp.StatusCode), duration: time.Since(reqStart)}
|
||||
return
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
resultChan <- qobuzAPIResult{apiURL: api, err: err, duration: time.Since(reqStart)}
|
||||
return
|
||||
}
|
||||
|
||||
// Check if response is HTML (error page)
|
||||
if len(body) > 0 && body[0] == '<' {
|
||||
resultChan <- qobuzAPIResult{apiURL: api, err: fmt.Errorf("received HTML instead of JSON"), duration: time.Since(reqStart)}
|
||||
return
|
||||
}
|
||||
|
||||
// Check for error in JSON response
|
||||
var errorResp struct {
|
||||
Error string `json:"error"`
|
||||
}
|
||||
if json.Unmarshal(body, &errorResp) == nil && errorResp.Error != "" {
|
||||
resultChan <- qobuzAPIResult{apiURL: api, err: fmt.Errorf(errorResp.Error), duration: time.Since(reqStart)}
|
||||
return
|
||||
}
|
||||
|
||||
var result struct {
|
||||
URL string `json:"url"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
resultChan <- qobuzAPIResult{apiURL: api, err: fmt.Errorf("invalid JSON: %v", err), duration: time.Since(reqStart)}
|
||||
return
|
||||
}
|
||||
|
||||
if result.URL != "" {
|
||||
resultChan <- qobuzAPIResult{apiURL: api, downloadURL: result.URL, err: nil, duration: time.Since(reqStart)}
|
||||
return
|
||||
}
|
||||
|
||||
resultChan <- qobuzAPIResult{apiURL: api, err: fmt.Errorf("no download URL in response"), duration: time.Since(reqStart)}
|
||||
}(apiURL)
|
||||
}
|
||||
|
||||
// Collect results - return first success
|
||||
var errors []string
|
||||
var firstSuccess *qobuzAPIResult
|
||||
|
||||
for i := 0; i < len(apis); i++ {
|
||||
result := <-resultChan
|
||||
if result.err == nil && firstSuccess == nil {
|
||||
firstSuccess = &result
|
||||
GoLog("[Qobuz] [Parallel] ✓ Got response from %s in %v\n", result.apiURL, result.duration)
|
||||
|
||||
// Drain remaining results to avoid goroutine leaks
|
||||
go func(remaining int) {
|
||||
for j := 0; j < remaining; j++ {
|
||||
<-resultChan
|
||||
}
|
||||
}(len(apis) - i - 1)
|
||||
|
||||
GoLog("[Qobuz] [Parallel] Total time: %v (first success)\n", time.Since(startTime))
|
||||
return firstSuccess.apiURL, firstSuccess.downloadURL, nil
|
||||
} else if result.err != nil {
|
||||
errMsg := result.err.Error()
|
||||
if len(errMsg) > 50 {
|
||||
errMsg = errMsg[:50] + "..."
|
||||
}
|
||||
errors = append(errors, fmt.Sprintf("%s: %s", result.apiURL, errMsg))
|
||||
}
|
||||
}
|
||||
|
||||
GoLog("[Qobuz] [Parallel] All %d APIs failed in %v\n", len(apis), time.Since(startTime))
|
||||
return "", "", fmt.Errorf("all %d Qobuz APIs failed. Errors: %v", len(apis), errors)
|
||||
}
|
||||
|
||||
// getQobuzDownloadURLSequential requests download URL from APIs sequentially
|
||||
// Uses same URL format as PC version: /api/stream?trackId={id}&quality={quality}
|
||||
func getQobuzDownloadURLSequential(apis []string, trackID int64, quality string) (string, string, error) {
|
||||
@@ -706,14 +826,16 @@ func getQobuzDownloadURLSequential(apis []string, trackID int64, quality string)
|
||||
return "", "", fmt.Errorf("all %d Qobuz APIs failed. Errors: %v", len(apis), errors)
|
||||
}
|
||||
|
||||
// GetDownloadURL gets download URL for a track - tries APIs sequentially
|
||||
// GetDownloadURL gets download URL for a track - tries ALL APIs in parallel
|
||||
// "Siapa cepat dia dapat" - first successful response wins
|
||||
func (q *QobuzDownloader) GetDownloadURL(trackID int64, quality string) (string, error) {
|
||||
apis := q.GetAvailableAPIs()
|
||||
if len(apis) == 0 {
|
||||
return "", fmt.Errorf("no Qobuz API available")
|
||||
}
|
||||
|
||||
_, downloadURL, err := getQobuzDownloadURLSequential(apis, trackID, quality)
|
||||
// Use parallel approach - request from all APIs simultaneously
|
||||
_, downloadURL, err := getQobuzDownloadURLParallel(apis, trackID, quality)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
+92
-69
@@ -2,7 +2,6 @@ package gobackend
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -17,14 +16,14 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
spotifyTokenURL = "https://accounts.spotify.com/api/token"
|
||||
playlistBaseURL = "https://api.spotify.com/v1/playlists/%s"
|
||||
albumBaseURL = "https://api.spotify.com/v1/albums/%s"
|
||||
trackBaseURL = "https://api.spotify.com/v1/tracks/%s"
|
||||
artistBaseURL = "https://api.spotify.com/v1/artists/%s"
|
||||
artistAlbumsURL = "https://api.spotify.com/v1/artists/%s/albums"
|
||||
searchBaseURL = "https://api.spotify.com/v1/search"
|
||||
|
||||
spotifyTokenURL = "https://accounts.spotify.com/api/token"
|
||||
playlistBaseURL = "https://api.spotify.com/v1/playlists/%s"
|
||||
albumBaseURL = "https://api.spotify.com/v1/albums/%s"
|
||||
trackBaseURL = "https://api.spotify.com/v1/tracks/%s"
|
||||
artistBaseURL = "https://api.spotify.com/v1/artists/%s"
|
||||
artistAlbumsURL = "https://api.spotify.com/v1/artists/%s/albums"
|
||||
searchBaseURL = "https://api.spotify.com/v1/search"
|
||||
|
||||
// Cache TTL settings
|
||||
artistCacheTTL = 10 * time.Minute
|
||||
searchCacheTTL = 5 * time.Minute
|
||||
@@ -54,7 +53,7 @@ type SpotifyMetadataClient struct {
|
||||
rng *rand.Rand
|
||||
rngMu sync.Mutex
|
||||
userAgent string
|
||||
|
||||
|
||||
// Caches to reduce API calls
|
||||
artistCache map[string]*cacheEntry // key: artistID
|
||||
searchCache map[string]*cacheEntry // key: query+type
|
||||
@@ -69,8 +68,10 @@ var (
|
||||
credentialsMu sync.RWMutex
|
||||
)
|
||||
|
||||
// ErrNoSpotifyCredentials is returned when Spotify credentials are not configured
|
||||
var ErrNoSpotifyCredentials = errors.New("Spotify credentials not configured. Please set your own Client ID and Secret in Settings, or use Deezer as metadata source (free, no credentials required)")
|
||||
|
||||
// SetSpotifyCredentials sets custom Spotify API credentials
|
||||
// Pass empty strings to use default credentials
|
||||
func SetSpotifyCredentials(clientID, clientSecret string) {
|
||||
credentialsMu.Lock()
|
||||
defer credentialsMu.Unlock()
|
||||
@@ -78,39 +79,56 @@ func SetSpotifyCredentials(clientID, clientSecret string) {
|
||||
customClientSecret = clientSecret
|
||||
}
|
||||
|
||||
// getCredentials returns the current credentials (custom or default)
|
||||
func getCredentials() (string, string) {
|
||||
// HasSpotifyCredentials checks if Spotify credentials are configured
|
||||
func HasSpotifyCredentials() bool {
|
||||
credentialsMu.RLock()
|
||||
defer credentialsMu.RUnlock()
|
||||
|
||||
|
||||
// Check custom credentials first
|
||||
if customClientID != "" && customClientSecret != "" {
|
||||
return customClientID, customClientSecret
|
||||
}
|
||||
|
||||
// Fall back to default credentials
|
||||
clientID := os.Getenv("SPOTIFY_CLIENT_ID")
|
||||
if clientID == "" {
|
||||
if decoded, err := base64.StdEncoding.DecodeString("NWY1NzNjOTYyMDQ5NGJhZTg3ODkwYzBmMDhhNjAyOTM="); err == nil {
|
||||
clientID = string(decoded)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
clientSecret := os.Getenv("SPOTIFY_CLIENT_SECRET")
|
||||
if clientSecret == "" {
|
||||
if decoded, err := base64.StdEncoding.DecodeString("MjEyNDc2ZDliMGYzNDcyZWFhNzYyZDkwYjE5YjBiYTg="); err == nil {
|
||||
clientSecret = string(decoded)
|
||||
}
|
||||
// Check environment variables
|
||||
if os.Getenv("SPOTIFY_CLIENT_ID") != "" && os.Getenv("SPOTIFY_CLIENT_SECRET") != "" {
|
||||
return true
|
||||
}
|
||||
|
||||
return clientID, clientSecret
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// getCredentials returns the current credentials or error if not configured
|
||||
func getCredentials() (string, string, error) {
|
||||
credentialsMu.RLock()
|
||||
defer credentialsMu.RUnlock()
|
||||
|
||||
// Check custom credentials first
|
||||
if customClientID != "" && customClientSecret != "" {
|
||||
return customClientID, customClientSecret, nil
|
||||
}
|
||||
|
||||
// Check environment variables
|
||||
clientID := os.Getenv("SPOTIFY_CLIENT_ID")
|
||||
clientSecret := os.Getenv("SPOTIFY_CLIENT_SECRET")
|
||||
|
||||
if clientID != "" && clientSecret != "" {
|
||||
return clientID, clientSecret, nil
|
||||
}
|
||||
|
||||
// No credentials available
|
||||
return "", "", ErrNoSpotifyCredentials
|
||||
}
|
||||
|
||||
// NewSpotifyMetadataClient creates a new Spotify client
|
||||
func NewSpotifyMetadataClient() *SpotifyMetadataClient {
|
||||
src := rand.NewSource(time.Now().UnixNano())
|
||||
// Returns error if credentials are not configured
|
||||
func NewSpotifyMetadataClient() (*SpotifyMetadataClient, error) {
|
||||
// Get credentials - will error if not configured
|
||||
clientID, clientSecret, err := getCredentials()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Get credentials (custom or default)
|
||||
clientID, clientSecret := getCredentials()
|
||||
src := rand.NewSource(time.Now().UnixNano())
|
||||
|
||||
c := &SpotifyMetadataClient{
|
||||
httpClient: NewHTTPClientWithTimeout(15 * time.Second), // Use shared transport for connection pooling
|
||||
@@ -122,7 +140,7 @@ func NewSpotifyMetadataClient() *SpotifyMetadataClient {
|
||||
albumCache: make(map[string]*cacheEntry),
|
||||
}
|
||||
c.userAgent = c.randomUserAgent()
|
||||
return c
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// TrackMetadata represents track information
|
||||
@@ -140,6 +158,7 @@ type TrackMetadata struct {
|
||||
DiscNumber int `json:"disc_number,omitempty"`
|
||||
ExternalURL string `json:"external_urls"`
|
||||
ISRC string `json:"isrc"`
|
||||
AlbumType string `json:"album_type,omitempty"` // album, single, ep, compilation
|
||||
}
|
||||
|
||||
// AlbumTrackMetadata holds per-track info for album/playlist
|
||||
@@ -159,6 +178,7 @@ type AlbumTrackMetadata struct {
|
||||
ISRC string `json:"isrc"`
|
||||
AlbumID string `json:"album_id,omitempty"`
|
||||
AlbumURL string `json:"album_url,omitempty"`
|
||||
AlbumType string `json:"album_type,omitempty"` // album, single, ep, compilation
|
||||
}
|
||||
|
||||
// AlbumInfoMetadata holds album information
|
||||
@@ -283,6 +303,7 @@ type albumSimplified struct {
|
||||
Images []image `json:"images"`
|
||||
ExternalURL externalURL `json:"external_urls"`
|
||||
Artists []artist `json:"artists"`
|
||||
AlbumType string `json:"album_type"` // album, single, compilation
|
||||
}
|
||||
|
||||
type trackFull struct {
|
||||
@@ -331,14 +352,14 @@ func (c *SpotifyMetadataClient) SearchTracks(ctx context.Context, query string,
|
||||
}
|
||||
|
||||
searchURL := fmt.Sprintf("%s?q=%s&type=track&limit=%d", searchBaseURL, url.QueryEscape(query), limit)
|
||||
|
||||
|
||||
var response struct {
|
||||
Tracks struct {
|
||||
Items []trackFull `json:"items"`
|
||||
Total int `json:"total"`
|
||||
} `json:"tracks"`
|
||||
}
|
||||
|
||||
|
||||
if err := c.getJSON(ctx, searchURL, token, &response); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -363,6 +384,7 @@ func (c *SpotifyMetadataClient) SearchTracks(ctx context.Context, query string,
|
||||
DiscNumber: track.DiscNumber,
|
||||
ExternalURL: track.ExternalURL.Spotify,
|
||||
ISRC: track.ExternalID.ISRC,
|
||||
AlbumType: track.Album.AlbumType,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -373,7 +395,7 @@ func (c *SpotifyMetadataClient) SearchTracks(ctx context.Context, query string,
|
||||
func (c *SpotifyMetadataClient) SearchAll(ctx context.Context, query string, trackLimit, artistLimit int) (*SearchAllResult, error) {
|
||||
// Create cache key
|
||||
cacheKey := fmt.Sprintf("all:%s:%d:%d", query, trackLimit, artistLimit)
|
||||
|
||||
|
||||
// Check cache first
|
||||
c.cacheMu.RLock()
|
||||
if entry, ok := c.searchCache[cacheKey]; ok && !entry.isExpired() {
|
||||
@@ -388,24 +410,24 @@ func (c *SpotifyMetadataClient) SearchAll(ctx context.Context, query string, tra
|
||||
}
|
||||
|
||||
searchURL := fmt.Sprintf("%s?q=%s&type=track,artist&limit=%d", searchBaseURL, url.QueryEscape(query), trackLimit)
|
||||
|
||||
|
||||
var response struct {
|
||||
Tracks struct {
|
||||
Items []trackFull `json:"items"`
|
||||
} `json:"tracks"`
|
||||
Artists struct {
|
||||
Items []struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Images []image `json:"images"`
|
||||
Followers struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Images []image `json:"images"`
|
||||
Followers struct {
|
||||
Total int `json:"total"`
|
||||
} `json:"followers"`
|
||||
Popularity int `json:"popularity"`
|
||||
} `json:"items"`
|
||||
} `json:"artists"`
|
||||
}
|
||||
|
||||
|
||||
if err := c.getJSON(ctx, searchURL, token, &response); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -430,6 +452,7 @@ func (c *SpotifyMetadataClient) SearchAll(ctx context.Context, query string, tra
|
||||
DiscNumber: track.DiscNumber,
|
||||
ExternalURL: track.ExternalURL.Spotify,
|
||||
ISRC: track.ExternalID.ISRC,
|
||||
AlbumType: track.Album.AlbumType,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -438,7 +461,7 @@ func (c *SpotifyMetadataClient) SearchAll(ctx context.Context, query string, tra
|
||||
if artistCount > artistLimit {
|
||||
artistCount = artistLimit
|
||||
}
|
||||
|
||||
|
||||
for i := 0; i < artistCount; i++ {
|
||||
artist := response.Artists.Items[i]
|
||||
result.Artists = append(result.Artists, SearchArtistResult{
|
||||
@@ -534,7 +557,7 @@ func (c *SpotifyMetadataClient) fetchAlbum(ctx context.Context, albumID, token s
|
||||
// Collect all tracks (including paginated)
|
||||
allTrackItems := data.Tracks.Items
|
||||
nextURL := data.Tracks.Next
|
||||
|
||||
|
||||
// Fetch remaining tracks using pagination (no limit)
|
||||
for nextURL != "" {
|
||||
var pageData struct {
|
||||
@@ -563,7 +586,7 @@ func (c *SpotifyMetadataClient) fetchAlbum(ctx context.Context, albumID, token s
|
||||
tracks := make([]AlbumTrackMetadata, 0, len(allTrackItems))
|
||||
for _, item := range allTrackItems {
|
||||
isrc := isrcMap[item.ID]
|
||||
|
||||
|
||||
tracks = append(tracks, AlbumTrackMetadata{
|
||||
SpotifyID: item.ID,
|
||||
Artists: joinArtists(item.Artists),
|
||||
@@ -602,23 +625,23 @@ func (c *SpotifyMetadataClient) fetchAlbum(ctx context.Context, albumID, token s
|
||||
// Similar to Deezer implementation for consistency
|
||||
func (c *SpotifyMetadataClient) fetchISRCsParallel(ctx context.Context, trackIDs []string, token string) map[string]string {
|
||||
const maxParallelISRC = 10 // Max concurrent ISRC fetches
|
||||
|
||||
|
||||
result := make(map[string]string)
|
||||
var resultMu sync.Mutex
|
||||
|
||||
|
||||
if len(trackIDs) == 0 {
|
||||
return result
|
||||
}
|
||||
|
||||
|
||||
// Use semaphore to limit concurrent requests
|
||||
sem := make(chan struct{}, maxParallelISRC)
|
||||
var wg sync.WaitGroup
|
||||
|
||||
|
||||
for _, trackID := range trackIDs {
|
||||
wg.Add(1)
|
||||
go func(id string) {
|
||||
defer wg.Done()
|
||||
|
||||
|
||||
// Acquire semaphore
|
||||
select {
|
||||
case sem <- struct{}{}:
|
||||
@@ -626,15 +649,15 @@ func (c *SpotifyMetadataClient) fetchISRCsParallel(ctx context.Context, trackIDs
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
isrc := c.fetchTrackISRC(ctx, id, token)
|
||||
|
||||
|
||||
resultMu.Lock()
|
||||
result[id] = isrc
|
||||
resultMu.Unlock()
|
||||
}(trackID)
|
||||
}
|
||||
|
||||
|
||||
wg.Wait()
|
||||
return result
|
||||
}
|
||||
@@ -668,7 +691,7 @@ func (c *SpotifyMetadataClient) fetchPlaylist(ctx context.Context, playlistID, t
|
||||
|
||||
// Pre-allocate with expected capacity
|
||||
tracks := make([]AlbumTrackMetadata, 0, data.Tracks.Total)
|
||||
|
||||
|
||||
// Add first batch of tracks
|
||||
for _, item := range data.Tracks.Items {
|
||||
if item.Track == nil {
|
||||
@@ -695,7 +718,7 @@ func (c *SpotifyMetadataClient) fetchPlaylist(ctx context.Context, playlistID, t
|
||||
|
||||
// Fetch remaining tracks using pagination (NO LIMIT - fetch all tracks)
|
||||
nextURL := data.Tracks.Next
|
||||
|
||||
|
||||
for nextURL != "" {
|
||||
var pageData struct {
|
||||
Items []struct {
|
||||
@@ -755,10 +778,10 @@ func (c *SpotifyMetadataClient) fetchArtist(ctx context.Context, artistID, token
|
||||
|
||||
// Fetch artist info
|
||||
var artistData struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Images []image `json:"images"`
|
||||
Followers struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Images []image `json:"images"`
|
||||
Followers struct {
|
||||
Total int `json:"total"`
|
||||
} `json:"followers"`
|
||||
Popularity int `json:"popularity"`
|
||||
@@ -941,15 +964,15 @@ func (c *SpotifyMetadataClient) randomUserAgent() string {
|
||||
defer c.rngMu.Unlock()
|
||||
|
||||
// Use Mac User-Agent format (same as PC version)
|
||||
macMajor := c.rng.Intn(4) + 11 // 11-14
|
||||
macMinor := c.rng.Intn(5) + 4 // 4-8
|
||||
webkitMajor := c.rng.Intn(7) + 530 // 530-536
|
||||
webkitMinor := c.rng.Intn(7) + 30 // 30-36
|
||||
chromeMajor := c.rng.Intn(25) + 80 // 80-104
|
||||
macMajor := c.rng.Intn(4) + 11 // 11-14
|
||||
macMinor := c.rng.Intn(5) + 4 // 4-8
|
||||
webkitMajor := c.rng.Intn(7) + 530 // 530-536
|
||||
webkitMinor := c.rng.Intn(7) + 30 // 30-36
|
||||
chromeMajor := c.rng.Intn(25) + 80 // 80-104
|
||||
chromeBuild := c.rng.Intn(1500) + 3000 // 3000-4499
|
||||
chromePatch := c.rng.Intn(65) + 60 // 60-124
|
||||
safariMajor := c.rng.Intn(7) + 530 // 530-536
|
||||
safariMinor := c.rng.Intn(6) + 30 // 30-35
|
||||
chromePatch := c.rng.Intn(65) + 60 // 60-124
|
||||
safariMajor := c.rng.Intn(7) + 530 // 530-536
|
||||
safariMinor := c.rng.Intn(6) + 30 // 30-35
|
||||
|
||||
return fmt.Sprintf(
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_%d_%d) AppleWebKit/%d.%d (KHTML, like Gecko) Chrome/%d.0.%d.%d Safari/%d.%d",
|
||||
|
||||
+131
-14
@@ -640,20 +640,135 @@ type TidalDownloadInfo struct {
|
||||
}
|
||||
|
||||
// tidalAPIResult holds the result from a parallel API request
|
||||
// Kept for potential future use with _getDownloadURLParallel
|
||||
// type tidalAPIResult struct {
|
||||
// apiURL string
|
||||
// info TidalDownloadInfo
|
||||
// err error
|
||||
// duration time.Duration
|
||||
// }
|
||||
type tidalAPIResult struct {
|
||||
apiURL string
|
||||
info TidalDownloadInfo
|
||||
err error
|
||||
duration time.Duration
|
||||
}
|
||||
|
||||
// _getDownloadURLParallel requests download URL from all APIs in parallel
|
||||
// getDownloadURLParallel requests download URL from all APIs in parallel
|
||||
// Returns the first successful result (supports both v1 and v2 API formats)
|
||||
// Kept for potential future use - currently using sequential approach
|
||||
// func _getDownloadURLParallel(apis []string, trackID int64, quality string) (string, TidalDownloadInfo, error) {
|
||||
// ... implementation commented out ...
|
||||
// }
|
||||
// "Siapa cepat dia dapat" - first success wins
|
||||
func getDownloadURLParallel(apis []string, trackID int64, quality string) (string, TidalDownloadInfo, error) {
|
||||
if len(apis) == 0 {
|
||||
return "", TidalDownloadInfo{}, fmt.Errorf("no APIs available")
|
||||
}
|
||||
|
||||
GoLog("[Tidal] Requesting download URL from %d APIs in parallel...\n", len(apis))
|
||||
|
||||
resultChan := make(chan tidalAPIResult, len(apis))
|
||||
startTime := time.Now()
|
||||
|
||||
// Start all requests in parallel
|
||||
for _, apiURL := range apis {
|
||||
go func(api string) {
|
||||
reqStart := time.Now()
|
||||
|
||||
// Create client with timeout for parallel requests
|
||||
client := &http.Client{
|
||||
Timeout: 15 * time.Second,
|
||||
}
|
||||
|
||||
reqURL := fmt.Sprintf("%s/track/?id=%d&quality=%s", api, trackID, quality)
|
||||
|
||||
req, err := http.NewRequest("GET", reqURL, nil)
|
||||
if err != nil {
|
||||
resultChan <- tidalAPIResult{apiURL: api, err: err, duration: time.Since(reqStart)}
|
||||
return
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
resultChan <- tidalAPIResult{apiURL: api, err: err, duration: time.Since(reqStart)}
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
resultChan <- tidalAPIResult{apiURL: api, err: fmt.Errorf("HTTP %d", resp.StatusCode), duration: time.Since(reqStart)}
|
||||
return
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
resultChan <- tidalAPIResult{apiURL: api, err: err, duration: time.Since(reqStart)}
|
||||
return
|
||||
}
|
||||
|
||||
// Try v2 format first (object with manifest)
|
||||
var v2Response TidalAPIResponseV2
|
||||
if err := json.Unmarshal(body, &v2Response); err == nil && v2Response.Data.Manifest != "" {
|
||||
// IMPORTANT: Reject PREVIEW responses - we need FULL tracks
|
||||
if v2Response.Data.AssetPresentation == "PREVIEW" {
|
||||
resultChan <- tidalAPIResult{apiURL: api, err: fmt.Errorf("returned PREVIEW instead of FULL"), duration: time.Since(reqStart)}
|
||||
return
|
||||
}
|
||||
|
||||
info := TidalDownloadInfo{
|
||||
URL: "MANIFEST:" + v2Response.Data.Manifest,
|
||||
BitDepth: v2Response.Data.BitDepth,
|
||||
SampleRate: v2Response.Data.SampleRate,
|
||||
}
|
||||
resultChan <- tidalAPIResult{apiURL: api, info: info, err: nil, duration: time.Since(reqStart)}
|
||||
return
|
||||
}
|
||||
|
||||
// Fallback to v1 format (array with OriginalTrackUrl)
|
||||
var v1Responses []struct {
|
||||
OriginalTrackURL string `json:"OriginalTrackUrl"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &v1Responses); err == nil {
|
||||
for _, item := range v1Responses {
|
||||
if item.OriginalTrackURL != "" {
|
||||
info := TidalDownloadInfo{
|
||||
URL: item.OriginalTrackURL,
|
||||
BitDepth: 16,
|
||||
SampleRate: 44100,
|
||||
}
|
||||
resultChan <- tidalAPIResult{apiURL: api, info: info, err: nil, duration: time.Since(reqStart)}
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
resultChan <- tidalAPIResult{apiURL: api, err: fmt.Errorf("no download URL or manifest in response"), duration: time.Since(reqStart)}
|
||||
}(apiURL)
|
||||
}
|
||||
|
||||
// Collect results - return first success
|
||||
var errors []string
|
||||
var firstSuccess *tidalAPIResult
|
||||
|
||||
for i := 0; i < len(apis); i++ {
|
||||
result := <-resultChan
|
||||
if result.err == nil && firstSuccess == nil {
|
||||
// First success - use this one
|
||||
firstSuccess = &result
|
||||
GoLog("[Tidal] [Parallel] ✓ Got response from %s (%d-bit/%dHz) in %v\n",
|
||||
result.apiURL, result.info.BitDepth, result.info.SampleRate, result.duration)
|
||||
|
||||
// Don't return immediately - drain remaining results to avoid goroutine leaks
|
||||
go func(remaining int) {
|
||||
for j := 0; j < remaining; j++ {
|
||||
<-resultChan
|
||||
}
|
||||
}(len(apis) - i - 1)
|
||||
|
||||
GoLog("[Tidal] [Parallel] Total time: %v (first success)\n", time.Since(startTime))
|
||||
return firstSuccess.apiURL, firstSuccess.info, nil
|
||||
} else if result.err != nil {
|
||||
errMsg := result.err.Error()
|
||||
if len(errMsg) > 50 {
|
||||
errMsg = errMsg[:50] + "..."
|
||||
}
|
||||
errors = append(errors, fmt.Sprintf("%s: %s", result.apiURL, errMsg))
|
||||
}
|
||||
}
|
||||
|
||||
GoLog("[Tidal] [Parallel] All %d APIs failed in %v\n", len(apis), time.Since(startTime))
|
||||
return "", TidalDownloadInfo{}, fmt.Errorf("all %d Tidal APIs failed. Errors: %v", len(apis), errors)
|
||||
}
|
||||
|
||||
// getDownloadURLSequential requests download URL from APIs sequentially (fallback)
|
||||
// Returns the first successful result (supports both v1 and v2 API formats)
|
||||
@@ -744,14 +859,16 @@ func getDownloadURLSequential(apis []string, trackID int64, quality string) (str
|
||||
return "", TidalDownloadInfo{}, fmt.Errorf("all %d Tidal APIs failed. Errors: %v", len(apis), errors)
|
||||
}
|
||||
|
||||
// GetDownloadURL gets download URL for a track - tries APIs sequentially
|
||||
// GetDownloadURL gets download URL for a track - tries ALL APIs in parallel
|
||||
// "Siapa cepat dia dapat" - first successful response wins
|
||||
func (t *TidalDownloader) GetDownloadURL(trackID int64, quality string) (TidalDownloadInfo, error) {
|
||||
apis := t.GetAvailableAPIs()
|
||||
if len(apis) == 0 {
|
||||
return TidalDownloadInfo{}, fmt.Errorf("no API URL configured")
|
||||
}
|
||||
|
||||
_, info, err := getDownloadURLSequential(apis, trackID, quality)
|
||||
// Use parallel approach - request from all APIs simultaneously
|
||||
_, info, err := getDownloadURLParallel(apis, trackID, quality)
|
||||
if err != nil {
|
||||
return TidalDownloadInfo{}, fmt.Errorf("failed to get download URL: %w", err)
|
||||
}
|
||||
|
||||
@@ -256,6 +256,10 @@ import Gobackend // Import Go framework
|
||||
GobackendSetSpotifyAPICredentials(clientId, clientSecret)
|
||||
return nil
|
||||
|
||||
case "hasSpotifyCredentials":
|
||||
let hasCredentials = GobackendCheckSpotifyCredentials()
|
||||
return hasCredentials
|
||||
|
||||
// Log methods
|
||||
case "getLogs":
|
||||
let response = GobackendGetLogs()
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/// App version and info constants
|
||||
/// Update version here only - all other files will reference this
|
||||
class AppInfo {
|
||||
static const String version = '3.0.0-alpha.1';
|
||||
static const String buildNumber = '50';
|
||||
static const String version = '3.0.0-alpha.3';
|
||||
static const String buildNumber = '52';
|
||||
static const String fullVersion = '$version+$buildNumber';
|
||||
|
||||
|
||||
|
||||
+34
-3
@@ -1,7 +1,10 @@
|
||||
import 'dart:io';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:spotiflac_android/app.dart';
|
||||
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
||||
import 'package:spotiflac_android/providers/extension_provider.dart';
|
||||
import 'package:spotiflac_android/services/notification_service.dart';
|
||||
import 'package:spotiflac_android/services/share_intent_service.dart';
|
||||
|
||||
@@ -24,14 +27,42 @@ void main() async {
|
||||
}
|
||||
|
||||
/// Widget to eagerly initialize providers that need to load data on startup
|
||||
class _EagerInitialization extends ConsumerWidget {
|
||||
class _EagerInitialization extends ConsumerStatefulWidget {
|
||||
const _EagerInitialization({required this.child});
|
||||
final Widget child;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
ConsumerState<_EagerInitialization> createState() => _EagerInitializationState();
|
||||
}
|
||||
|
||||
class _EagerInitializationState extends ConsumerState<_EagerInitialization> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_initializeExtensions();
|
||||
}
|
||||
|
||||
Future<void> _initializeExtensions() async {
|
||||
try {
|
||||
final appDir = await getApplicationDocumentsDirectory();
|
||||
final extensionsDir = '${appDir.path}/extensions';
|
||||
final dataDir = '${appDir.path}/extension_data';
|
||||
|
||||
// Create directories if needed
|
||||
await Directory(extensionsDir).create(recursive: true);
|
||||
await Directory(dataDir).create(recursive: true);
|
||||
|
||||
// Initialize extension system
|
||||
await ref.read(extensionProvider.notifier).initialize(extensionsDir, dataDir);
|
||||
} catch (e) {
|
||||
debugPrint('Failed to initialize extensions: $e');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Eagerly initialize download history provider to load from storage
|
||||
ref.watch(downloadHistoryProvider);
|
||||
return child;
|
||||
return widget.child;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ enum DownloadErrorType {
|
||||
notFound, // Track not found on any service
|
||||
rateLimit, // Rate limited by service
|
||||
network, // Network/connection error
|
||||
permission, // File/folder permission error
|
||||
}
|
||||
|
||||
@JsonSerializable()
|
||||
@@ -88,6 +89,8 @@ class DownloadItem {
|
||||
return 'Rate limit reached, try again later';
|
||||
case DownloadErrorType.network:
|
||||
return 'Connection failed, check your internet';
|
||||
case DownloadErrorType.permission:
|
||||
return 'Cannot write to folder, check storage permission';
|
||||
default:
|
||||
return error ?? 'An error occurred';
|
||||
}
|
||||
|
||||
@@ -51,4 +51,5 @@ const _$DownloadErrorTypeEnumMap = {
|
||||
DownloadErrorType.notFound: 'notFound',
|
||||
DownloadErrorType.rateLimit: 'rateLimit',
|
||||
DownloadErrorType.network: 'network',
|
||||
DownloadErrorType.permission: 'permission',
|
||||
};
|
||||
|
||||
@@ -18,6 +18,7 @@ class AppSettings {
|
||||
final bool hasSearchedBefore; // Hide helper text after first search
|
||||
final String folderOrganization; // none, artist, album, artist_album
|
||||
final String historyViewMode; // list, grid
|
||||
final String historyFilterMode; // all, albums, singles
|
||||
final bool askQualityBeforeDownload; // Show quality picker before each download
|
||||
final String spotifyClientId; // Custom Spotify client ID (empty = use default)
|
||||
final String spotifyClientSecret; // Custom Spotify client secret (empty = use default)
|
||||
@@ -26,6 +27,7 @@ class AppSettings {
|
||||
final bool enableLogging; // Enable detailed logging for debugging
|
||||
final bool useExtensionProviders; // Use extension providers for downloads when available
|
||||
final String? searchProvider; // null/empty = default (Deezer/Spotify), otherwise extension ID
|
||||
final bool separateSingles; // Separate singles/EPs into their own folder
|
||||
|
||||
const AppSettings({
|
||||
this.defaultService = 'tidal',
|
||||
@@ -42,6 +44,7 @@ class AppSettings {
|
||||
this.hasSearchedBefore = false, // Default: show helper text
|
||||
this.folderOrganization = 'none', // Default: no folder organization
|
||||
this.historyViewMode = 'grid', // Default: grid view
|
||||
this.historyFilterMode = 'all', // Default: show all
|
||||
this.askQualityBeforeDownload = true, // Default: ask quality before download
|
||||
this.spotifyClientId = '', // Default: use built-in credentials
|
||||
this.spotifyClientSecret = '', // Default: use built-in credentials
|
||||
@@ -50,6 +53,7 @@ class AppSettings {
|
||||
this.enableLogging = false, // Default: disabled for performance
|
||||
this.useExtensionProviders = true, // Default: use extensions when available
|
||||
this.searchProvider, // Default: null (use Deezer/Spotify)
|
||||
this.separateSingles = false, // Default: disabled
|
||||
});
|
||||
|
||||
AppSettings copyWith({
|
||||
@@ -67,6 +71,7 @@ class AppSettings {
|
||||
bool? hasSearchedBefore,
|
||||
String? folderOrganization,
|
||||
String? historyViewMode,
|
||||
String? historyFilterMode,
|
||||
bool? askQualityBeforeDownload,
|
||||
String? spotifyClientId,
|
||||
String? spotifyClientSecret,
|
||||
@@ -75,6 +80,7 @@ class AppSettings {
|
||||
bool? enableLogging,
|
||||
bool? useExtensionProviders,
|
||||
String? searchProvider,
|
||||
bool? separateSingles,
|
||||
}) {
|
||||
return AppSettings(
|
||||
defaultService: defaultService ?? this.defaultService,
|
||||
@@ -91,6 +97,7 @@ class AppSettings {
|
||||
hasSearchedBefore: hasSearchedBefore ?? this.hasSearchedBefore,
|
||||
folderOrganization: folderOrganization ?? this.folderOrganization,
|
||||
historyViewMode: historyViewMode ?? this.historyViewMode,
|
||||
historyFilterMode: historyFilterMode ?? this.historyFilterMode,
|
||||
askQualityBeforeDownload: askQualityBeforeDownload ?? this.askQualityBeforeDownload,
|
||||
spotifyClientId: spotifyClientId ?? this.spotifyClientId,
|
||||
spotifyClientSecret: spotifyClientSecret ?? this.spotifyClientSecret,
|
||||
@@ -99,6 +106,7 @@ class AppSettings {
|
||||
enableLogging: enableLogging ?? this.enableLogging,
|
||||
useExtensionProviders: useExtensionProviders ?? this.useExtensionProviders,
|
||||
searchProvider: searchProvider ?? this.searchProvider,
|
||||
separateSingles: separateSingles ?? this.separateSingles,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
|
||||
hasSearchedBefore: json['hasSearchedBefore'] as bool? ?? false,
|
||||
folderOrganization: json['folderOrganization'] as String? ?? 'none',
|
||||
historyViewMode: json['historyViewMode'] as String? ?? 'grid',
|
||||
historyFilterMode: json['historyFilterMode'] as String? ?? 'all',
|
||||
askQualityBeforeDownload: json['askQualityBeforeDownload'] as bool? ?? true,
|
||||
spotifyClientId: json['spotifyClientId'] as String? ?? '',
|
||||
spotifyClientSecret: json['spotifyClientSecret'] as String? ?? '',
|
||||
@@ -30,6 +31,7 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
|
||||
enableLogging: json['enableLogging'] as bool? ?? false,
|
||||
useExtensionProviders: json['useExtensionProviders'] as bool? ?? true,
|
||||
searchProvider: json['searchProvider'] as String?,
|
||||
separateSingles: json['separateSingles'] as bool? ?? false,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$AppSettingsToJson(AppSettings instance) =>
|
||||
@@ -48,6 +50,7 @@ Map<String, dynamic> _$AppSettingsToJson(AppSettings instance) =>
|
||||
'hasSearchedBefore': instance.hasSearchedBefore,
|
||||
'folderOrganization': instance.folderOrganization,
|
||||
'historyViewMode': instance.historyViewMode,
|
||||
'historyFilterMode': instance.historyFilterMode,
|
||||
'askQualityBeforeDownload': instance.askQualityBeforeDownload,
|
||||
'spotifyClientId': instance.spotifyClientId,
|
||||
'spotifyClientSecret': instance.spotifyClientSecret,
|
||||
@@ -56,4 +59,5 @@ Map<String, dynamic> _$AppSettingsToJson(AppSettings instance) =>
|
||||
'enableLogging': instance.enableLogging,
|
||||
'useExtensionProviders': instance.useExtensionProviders,
|
||||
'searchProvider': instance.searchProvider,
|
||||
'separateSingles': instance.separateSingles,
|
||||
};
|
||||
|
||||
@@ -19,6 +19,7 @@ class Track {
|
||||
final String? deezerId;
|
||||
final ServiceAvailability? availability;
|
||||
final String? source; // Extension ID that provided this track (null for built-in sources)
|
||||
final String? albumType; // album, single, ep, compilation (from metadata API)
|
||||
|
||||
const Track({
|
||||
required this.id,
|
||||
@@ -35,8 +36,12 @@ class Track {
|
||||
this.deezerId,
|
||||
this.availability,
|
||||
this.source,
|
||||
this.albumType,
|
||||
});
|
||||
|
||||
/// Check if this track is a single (based on album_type metadata)
|
||||
bool get isSingle => albumType == 'single' || albumType == 'ep';
|
||||
|
||||
factory Track.fromJson(Map<String, dynamic> json) => _$TrackFromJson(json);
|
||||
Map<String, dynamic> toJson() => _$TrackToJson(this);
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ Track _$TrackFromJson(Map<String, dynamic> json) => Track(
|
||||
json['availability'] as Map<String, dynamic>,
|
||||
),
|
||||
source: json['source'] as String?,
|
||||
albumType: json['albumType'] as String?,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$TrackToJson(Track instance) => <String, dynamic>{
|
||||
@@ -42,6 +43,7 @@ Map<String, dynamic> _$TrackToJson(Track instance) => <String, dynamic>{
|
||||
'deezerId': instance.deezerId,
|
||||
'availability': instance.availability,
|
||||
'source': instance.source,
|
||||
'albumType': instance.albumType,
|
||||
};
|
||||
|
||||
ServiceAvailability _$ServiceAvailabilityFromJson(Map<String, dynamic> json) =>
|
||||
|
||||
@@ -156,8 +156,18 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
|
||||
final items = jsonList
|
||||
.map((e) => DownloadHistoryItem.fromJson(e as Map<String, dynamic>))
|
||||
.toList();
|
||||
state = state.copyWith(items: items);
|
||||
_historyLog.i('Loaded ${items.length} items from storage');
|
||||
|
||||
// Deduplicate existing history on load
|
||||
final deduplicatedItems = _deduplicateHistory(items);
|
||||
|
||||
state = state.copyWith(items: deduplicatedItems);
|
||||
_historyLog.i('Loaded ${deduplicatedItems.length} items from storage (original: ${items.length})');
|
||||
|
||||
// Save if duplicates were removed
|
||||
if (deduplicatedItems.length < items.length) {
|
||||
_historyLog.i('Removed ${items.length - deduplicatedItems.length} duplicate entries');
|
||||
await _saveToStorage();
|
||||
}
|
||||
} else {
|
||||
_historyLog.d('No history found in storage');
|
||||
}
|
||||
@@ -166,6 +176,46 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Deduplicate history items by spotifyId, deezerId, or ISRC
|
||||
/// Keeps the most recent entry (first occurrence since list is sorted by date desc)
|
||||
List<DownloadHistoryItem> _deduplicateHistory(List<DownloadHistoryItem> items) {
|
||||
final seen = <String, int>{}; // key -> index of first occurrence
|
||||
final result = <DownloadHistoryItem>[];
|
||||
|
||||
for (int i = 0; i < items.length; i++) {
|
||||
final item = items[i];
|
||||
String? key;
|
||||
|
||||
// Generate unique key based on available identifiers
|
||||
if (item.spotifyId != null && item.spotifyId!.isNotEmpty) {
|
||||
// Extract numeric ID for deezer: prefixed IDs
|
||||
if (item.spotifyId!.startsWith('deezer:')) {
|
||||
key = 'deezer:${item.spotifyId!.substring(7)}';
|
||||
} else {
|
||||
key = 'spotify:${item.spotifyId}';
|
||||
}
|
||||
} else if (item.isrc != null && item.isrc!.isNotEmpty) {
|
||||
key = 'isrc:${item.isrc}';
|
||||
}
|
||||
|
||||
if (key != null) {
|
||||
if (!seen.containsKey(key)) {
|
||||
// First occurrence - keep it (most recent since list is sorted by date desc)
|
||||
seen[key] = result.length;
|
||||
result.add(item);
|
||||
} else {
|
||||
// Duplicate found - skip (keep the first/most recent one)
|
||||
_historyLog.d('Skipping duplicate: ${item.trackName} (key: $key)');
|
||||
}
|
||||
} else {
|
||||
// No identifier - keep it (can't deduplicate)
|
||||
result.add(item);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
Future<void> _saveToStorage() async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
@@ -183,7 +233,48 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
|
||||
}
|
||||
|
||||
void addToHistory(DownloadHistoryItem item) {
|
||||
state = state.copyWith(items: [item, ...state.items]);
|
||||
// Check if track already exists in history (by spotifyId, deezerId, or ISRC)
|
||||
final existingIndex = state.items.indexWhere((existing) {
|
||||
// Match by spotifyId (primary identifier - includes deezer:xxx format)
|
||||
if (item.spotifyId != null &&
|
||||
item.spotifyId!.isNotEmpty &&
|
||||
existing.spotifyId == item.spotifyId) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Match Deezer tracks: extract numeric ID from "deezer:123456" format
|
||||
if (item.spotifyId != null && item.spotifyId!.startsWith('deezer:') &&
|
||||
existing.spotifyId != null && existing.spotifyId!.startsWith('deezer:')) {
|
||||
final itemDeezerId = item.spotifyId!.substring(7); // Remove "deezer:" prefix
|
||||
final existingDeezerId = existing.spotifyId!.substring(7);
|
||||
if (itemDeezerId == existingDeezerId) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: match by ISRC if spotifyId not available
|
||||
if (item.isrc != null &&
|
||||
item.isrc!.isNotEmpty &&
|
||||
existing.isrc == item.isrc) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
if (existingIndex >= 0) {
|
||||
// Replace existing entry (update with new download info)
|
||||
final updatedItems = [...state.items];
|
||||
updatedItems[existingIndex] = item;
|
||||
// Move to top of list (most recent)
|
||||
updatedItems.removeAt(existingIndex);
|
||||
updatedItems.insert(0, item);
|
||||
state = state.copyWith(items: updatedItems);
|
||||
_historyLog.d('Updated existing history entry: ${item.trackName}');
|
||||
} else {
|
||||
// Add new entry
|
||||
state = state.copyWith(items: [item, ...state.items]);
|
||||
_historyLog.d('Added new history entry: ${item.trackName}');
|
||||
}
|
||||
_saveToStorage();
|
||||
}
|
||||
|
||||
@@ -577,35 +668,55 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
state = state.copyWith(outputDir: dir);
|
||||
}
|
||||
|
||||
/// Build output directory based on folder organization setting
|
||||
Future<String> _buildOutputDir(Track track, String folderOrganization) async {
|
||||
/// Build output directory based on folder organization setting and separateSingles
|
||||
Future<String> _buildOutputDir(Track track, String folderOrganization, {bool separateSingles = false}) async {
|
||||
String baseDir = state.outputDir;
|
||||
|
||||
if (folderOrganization == 'none') {
|
||||
return baseDir;
|
||||
// If separateSingles is enabled, use Albums/Singles structure
|
||||
if (separateSingles) {
|
||||
final isSingle = track.isSingle;
|
||||
|
||||
if (isSingle) {
|
||||
// Singles go to Singles folder (flat structure)
|
||||
final singlesPath = '$baseDir${Platform.pathSeparator}Singles';
|
||||
final dir = Directory(singlesPath);
|
||||
if (!await dir.exists()) {
|
||||
await dir.create(recursive: true);
|
||||
_log.d('Created Singles folder: $singlesPath');
|
||||
}
|
||||
return singlesPath;
|
||||
} else {
|
||||
// Albums go to Albums/Artist/Album structure
|
||||
final artistName = _sanitizeFolderName(track.albumArtist ?? track.artistName);
|
||||
final albumName = _sanitizeFolderName(track.albumName);
|
||||
final albumPath = '$baseDir${Platform.pathSeparator}Albums${Platform.pathSeparator}$artistName${Platform.pathSeparator}$albumName';
|
||||
final dir = Directory(albumPath);
|
||||
if (!await dir.exists()) {
|
||||
await dir.create(recursive: true);
|
||||
_log.d('Created Album folder: $albumPath');
|
||||
}
|
||||
return albumPath;
|
||||
}
|
||||
}
|
||||
|
||||
// Sanitize folder names (remove invalid characters)
|
||||
String sanitize(String name) {
|
||||
return name
|
||||
.replaceAll(RegExp(r'[<>:"/\\|?*]'), '_')
|
||||
.replaceAll(RegExp(r'\.+$'), '') // Remove trailing dots
|
||||
.trim();
|
||||
// Original folder organization logic (when separateSingles is disabled)
|
||||
if (folderOrganization == 'none') {
|
||||
return baseDir;
|
||||
}
|
||||
|
||||
String subPath = '';
|
||||
switch (folderOrganization) {
|
||||
case 'artist':
|
||||
final artistName = sanitize(track.albumArtist ?? track.artistName);
|
||||
final artistName = _sanitizeFolderName(track.albumArtist ?? track.artistName);
|
||||
subPath = artistName;
|
||||
break;
|
||||
case 'album':
|
||||
final albumName = sanitize(track.albumName);
|
||||
final albumName = _sanitizeFolderName(track.albumName);
|
||||
subPath = albumName;
|
||||
break;
|
||||
case 'artist_album':
|
||||
final artistName = sanitize(track.albumArtist ?? track.artistName);
|
||||
final albumName = sanitize(track.albumName);
|
||||
final artistName = _sanitizeFolderName(track.albumArtist ?? track.artistName);
|
||||
final albumName = _sanitizeFolderName(track.albumName);
|
||||
subPath = '$artistName${Platform.pathSeparator}$albumName';
|
||||
break;
|
||||
}
|
||||
@@ -623,6 +734,14 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
return baseDir;
|
||||
}
|
||||
|
||||
/// Sanitize folder names (remove invalid characters)
|
||||
String _sanitizeFolderName(String name) {
|
||||
return name
|
||||
.replaceAll(RegExp(r'[<>:"/\\|?*]'), '_')
|
||||
.replaceAll(RegExp(r'\.+$'), '') // Remove trailing dots
|
||||
.trim();
|
||||
}
|
||||
|
||||
void updateSettings(AppSettings settings) {
|
||||
state = state.copyWith(
|
||||
outputDir: settings.downloadDirectory.isNotEmpty
|
||||
@@ -1326,6 +1445,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
final outputDir = await _buildOutputDir(
|
||||
trackToDownload,
|
||||
settings.folderOrganization,
|
||||
separateSingles: settings.separateSingles,
|
||||
);
|
||||
|
||||
// Use quality override if set, otherwise use default from settings
|
||||
@@ -1672,6 +1792,9 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
case 'network':
|
||||
errorType = DownloadErrorType.network;
|
||||
break;
|
||||
case 'permission':
|
||||
errorType = DownloadErrorType.permission;
|
||||
break;
|
||||
default:
|
||||
errorType = DownloadErrorType.unknown;
|
||||
}
|
||||
|
||||
@@ -520,22 +520,34 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
|
||||
await PlatformBridge.setExtensionEnabled(extensionId, enabled);
|
||||
_log.d('Set extension $extensionId enabled: $enabled');
|
||||
|
||||
// Get extension info before updating state
|
||||
final ext = state.extensions.where((e) => e.id == extensionId).firstOrNull;
|
||||
|
||||
// Update local state
|
||||
final extensions = state.extensions.map((ext) {
|
||||
if (ext.id == extensionId) {
|
||||
return ext.copyWith(enabled: enabled);
|
||||
final extensions = state.extensions.map((e) {
|
||||
if (e.id == extensionId) {
|
||||
return e.copyWith(enabled: enabled);
|
||||
}
|
||||
return ext;
|
||||
return e;
|
||||
}).toList();
|
||||
|
||||
state = state.copyWith(extensions: extensions);
|
||||
|
||||
// If disabling an extension that is the current search provider, clear it
|
||||
if (!enabled) {
|
||||
// If disabling an extension, reset related settings
|
||||
if (!enabled && ext != null) {
|
||||
final settings = ref.read(settingsProvider);
|
||||
|
||||
// If this extension was the search provider, clear it and reset to Deezer
|
||||
if (settings.searchProvider == extensionId) {
|
||||
ref.read(settingsProvider.notifier).setSearchProvider(null);
|
||||
_log.d('Cleared search provider because extension $extensionId was disabled');
|
||||
ref.read(settingsProvider.notifier).setMetadataSource('deezer');
|
||||
_log.d('Cleared search provider and reset to Deezer because extension $extensionId was disabled');
|
||||
}
|
||||
|
||||
// If this extension was the default download service, reset to Tidal
|
||||
if (ext.hasDownloadProvider && settings.defaultService == extensionId) {
|
||||
ref.read(settingsProvider.notifier).setDefaultService('tidal');
|
||||
_log.d('Reset default service to Tidal because extension $extensionId was disabled');
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
|
||||
@@ -60,18 +60,16 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
||||
|
||||
/// Apply current Spotify credentials to Go backend
|
||||
Future<void> _applySpotifyCredentials() async {
|
||||
// Only apply custom credentials if enabled and both fields are set
|
||||
if (state.useCustomSpotifyCredentials &&
|
||||
state.spotifyClientId.isNotEmpty &&
|
||||
// Only apply if both fields are set
|
||||
if (state.spotifyClientId.isNotEmpty &&
|
||||
state.spotifyClientSecret.isNotEmpty) {
|
||||
await PlatformBridge.setSpotifyCredentials(
|
||||
state.spotifyClientId,
|
||||
state.spotifyClientSecret,
|
||||
);
|
||||
} else {
|
||||
// Clear to use default
|
||||
await PlatformBridge.setSpotifyCredentials('', '');
|
||||
}
|
||||
// Note: If credentials are empty, Spotify API will return error
|
||||
// User should use Deezer as metadata source instead
|
||||
}
|
||||
|
||||
void setDefaultService(String service) {
|
||||
@@ -148,6 +146,11 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
||||
_saveSettings();
|
||||
}
|
||||
|
||||
void setHistoryFilterMode(String mode) {
|
||||
state = state.copyWith(historyFilterMode: mode);
|
||||
_saveSettings();
|
||||
}
|
||||
|
||||
void setAskQualityBeforeDownload(bool enabled) {
|
||||
state = state.copyWith(askQualityBeforeDownload: enabled);
|
||||
_saveSettings();
|
||||
@@ -208,6 +211,11 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
||||
state = state.copyWith(useExtensionProviders: enabled);
|
||||
_saveSettings();
|
||||
}
|
||||
|
||||
void setSeparateSingles(bool enabled) {
|
||||
state = state.copyWith(separateSingles: enabled);
|
||||
_saveSettings();
|
||||
}
|
||||
}
|
||||
|
||||
final settingsProvider = NotifierProvider<SettingsNotifier, AppSettings>(
|
||||
|
||||
@@ -466,6 +466,7 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
discNumber: data['disc_number'] as int?,
|
||||
releaseDate: data['release_date']?.toString(),
|
||||
source: source ?? data['source']?.toString() ?? data['provider_id']?.toString(),
|
||||
albumType: data['album_type']?.toString(),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,573 @@
|
||||
import 'dart:io';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:open_filex/open_filex.dart';
|
||||
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
||||
import 'package:spotiflac_android/screens/track_metadata_screen.dart';
|
||||
|
||||
/// Screen to display downloaded tracks from a specific album
|
||||
class DownloadedAlbumScreen extends ConsumerStatefulWidget {
|
||||
final String albumName;
|
||||
final String artistName;
|
||||
final String? coverUrl;
|
||||
|
||||
const DownloadedAlbumScreen({
|
||||
super.key,
|
||||
required this.albumName,
|
||||
required this.artistName,
|
||||
this.coverUrl,
|
||||
});
|
||||
|
||||
@override
|
||||
ConsumerState<DownloadedAlbumScreen> createState() => _DownloadedAlbumScreenState();
|
||||
}
|
||||
|
||||
class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
||||
// Multi-select state
|
||||
bool _isSelectionMode = false;
|
||||
final Set<String> _selectedIds = {};
|
||||
|
||||
/// Get tracks for this album from history provider (reactive)
|
||||
List<DownloadHistoryItem> _getAlbumTracks(List<DownloadHistoryItem> allItems) {
|
||||
return allItems.where((item) {
|
||||
final itemKey = '${item.albumName}|${item.albumArtist ?? item.artistName}';
|
||||
final albumKey = '${widget.albumName}|${widget.artistName}';
|
||||
return itemKey == albumKey;
|
||||
}).toList()
|
||||
..sort((a, b) {
|
||||
final aNum = a.trackNumber ?? 999;
|
||||
final bNum = b.trackNumber ?? 999;
|
||||
if (aNum != bNum) return aNum.compareTo(bNum);
|
||||
return a.trackName.compareTo(b.trackName);
|
||||
});
|
||||
}
|
||||
|
||||
void _enterSelectionMode(String itemId) {
|
||||
HapticFeedback.mediumImpact();
|
||||
setState(() {
|
||||
_isSelectionMode = true;
|
||||
_selectedIds.add(itemId);
|
||||
});
|
||||
}
|
||||
|
||||
void _exitSelectionMode() {
|
||||
setState(() {
|
||||
_isSelectionMode = false;
|
||||
_selectedIds.clear();
|
||||
});
|
||||
}
|
||||
|
||||
void _toggleSelection(String itemId) {
|
||||
setState(() {
|
||||
if (_selectedIds.contains(itemId)) {
|
||||
_selectedIds.remove(itemId);
|
||||
if (_selectedIds.isEmpty) {
|
||||
_isSelectionMode = false;
|
||||
}
|
||||
} else {
|
||||
_selectedIds.add(itemId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _selectAll(List<DownloadHistoryItem> tracks) {
|
||||
setState(() {
|
||||
_selectedIds.addAll(tracks.map((e) => e.id));
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _deleteSelected(List<DownloadHistoryItem> currentTracks) async {
|
||||
final count = _selectedIds.length;
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('Delete Selected'),
|
||||
content: Text('Delete $count ${count == 1 ? 'track' : 'tracks'} from this album?\n\nThis will also delete the files from storage.'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, false),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.pop(ctx, true),
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
child: const Text('Delete'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (confirmed == true && mounted) {
|
||||
final historyNotifier = ref.read(downloadHistoryProvider.notifier);
|
||||
final idsToDelete = _selectedIds.toList();
|
||||
|
||||
int deletedCount = 0;
|
||||
for (final id in idsToDelete) {
|
||||
final item = currentTracks.where((e) => e.id == id).firstOrNull;
|
||||
if (item != null) {
|
||||
try {
|
||||
final file = File(item.filePath);
|
||||
if (await file.exists()) {
|
||||
await file.delete();
|
||||
}
|
||||
} catch (_) {}
|
||||
historyNotifier.removeFromHistory(id);
|
||||
deletedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
_exitSelectionMode();
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Deleted $deletedCount ${deletedCount == 1 ? 'track' : 'tracks'}')),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _openFile(String filePath) async {
|
||||
try {
|
||||
await OpenFilex.open(filePath);
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Cannot open file: $e')),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _navigateToMetadataScreen(DownloadHistoryItem item) {
|
||||
Navigator.push(context, PageRouteBuilder(
|
||||
transitionDuration: const Duration(milliseconds: 300),
|
||||
reverseTransitionDuration: const Duration(milliseconds: 250),
|
||||
pageBuilder: (context, animation, secondaryAnimation) => TrackMetadataScreen(item: item),
|
||||
transitionsBuilder: (context, animation, secondaryAnimation, child) => FadeTransition(opacity: animation, child: child),
|
||||
));
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final bottomPadding = MediaQuery.of(context).padding.bottom;
|
||||
|
||||
// Watch history and get tracks for this album (reactive!)
|
||||
final allHistoryItems = ref.watch(downloadHistoryProvider.select((s) => s.items));
|
||||
final tracks = _getAlbumTracks(allHistoryItems);
|
||||
|
||||
// Auto-pop if album has less than 2 tracks (no longer an "album")
|
||||
if (tracks.length < 2) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) Navigator.pop(context);
|
||||
});
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
// Clean up selected IDs that no longer exist
|
||||
final validIds = tracks.map((t) => t.id).toSet();
|
||||
_selectedIds.removeWhere((id) => !validIds.contains(id));
|
||||
if (_selectedIds.isEmpty && _isSelectionMode) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) setState(() => _isSelectionMode = false);
|
||||
});
|
||||
}
|
||||
|
||||
return PopScope(
|
||||
canPop: !_isSelectionMode,
|
||||
onPopInvokedWithResult: (didPop, result) {
|
||||
if (!didPop && _isSelectionMode) {
|
||||
_exitSelectionMode();
|
||||
}
|
||||
},
|
||||
child: Scaffold(
|
||||
body: Stack(
|
||||
children: [
|
||||
CustomScrollView(
|
||||
slivers: [
|
||||
_buildAppBar(context, colorScheme),
|
||||
_buildInfoCard(context, colorScheme, tracks),
|
||||
_buildTrackListHeader(context, colorScheme, tracks),
|
||||
_buildTrackList(context, colorScheme, tracks),
|
||||
SliverToBoxAdapter(child: SizedBox(height: _isSelectionMode ? 120 : 32)),
|
||||
],
|
||||
),
|
||||
|
||||
// Bottom Selection Action Bar
|
||||
AnimatedPositioned(
|
||||
duration: const Duration(milliseconds: 250),
|
||||
curve: Curves.easeOutCubic,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: _isSelectionMode ? 0 : -(200 + bottomPadding),
|
||||
child: _buildSelectionBottomBar(context, colorScheme, tracks, bottomPadding),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAppBar(BuildContext context, ColorScheme colorScheme) {
|
||||
return SliverAppBar(
|
||||
expandedHeight: 280,
|
||||
pinned: true,
|
||||
stretch: true,
|
||||
backgroundColor: colorScheme.surface,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
flexibleSpace: FlexibleSpaceBar(
|
||||
background: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
if (widget.coverUrl != null)
|
||||
CachedNetworkImage(
|
||||
imageUrl: widget.coverUrl!,
|
||||
fit: BoxFit.cover,
|
||||
color: Colors.black.withValues(alpha: 0.5),
|
||||
colorBlendMode: BlendMode.darken,
|
||||
memCacheWidth: 600,
|
||||
),
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
Colors.transparent,
|
||||
colorScheme.surface.withValues(alpha: 0.8),
|
||||
colorScheme.surface,
|
||||
],
|
||||
stops: const [0.0, 0.7, 1.0],
|
||||
),
|
||||
),
|
||||
),
|
||||
Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(top: 60),
|
||||
child: Container(
|
||||
width: 140,
|
||||
height: 140,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.3),
|
||||
blurRadius: 20,
|
||||
offset: const Offset(0, 10),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: widget.coverUrl != null
|
||||
? CachedNetworkImage(imageUrl: widget.coverUrl!, fit: BoxFit.cover, memCacheWidth: 280)
|
||||
: Container(
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
child: Icon(Icons.album, size: 48, color: colorScheme.onSurfaceVariant),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
stretchModes: const [StretchMode.zoomBackground, StretchMode.blurBackground],
|
||||
),
|
||||
leading: IconButton(
|
||||
icon: Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(color: colorScheme.surface.withValues(alpha: 0.8), shape: BoxShape.circle),
|
||||
child: Icon(Icons.arrow_back, color: colorScheme.onSurface),
|
||||
),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildInfoCard(BuildContext context, ColorScheme colorScheme, List<DownloadHistoryItem> tracks) {
|
||||
return SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Card(
|
||||
elevation: 0,
|
||||
color: colorScheme.surfaceContainerLow,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
widget.albumName,
|
||||
style: Theme.of(context).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.bold, color: colorScheme.onSurface),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
widget.artistName,
|
||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(color: colorScheme.onSurfaceVariant),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
decoration: BoxDecoration(color: colorScheme.primaryContainer, borderRadius: BorderRadius.circular(20)),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.download_done, size: 14, color: colorScheme.onPrimaryContainer),
|
||||
const SizedBox(width: 4),
|
||||
Text('${tracks.length} downloaded', style: TextStyle(color: colorScheme.onPrimaryContainer, fontWeight: FontWeight.w600, fontSize: 12)),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
if (_getCommonQuality(tracks) != null)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: _getCommonQuality(tracks)!.startsWith('24')
|
||||
? colorScheme.tertiaryContainer
|
||||
: colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Text(
|
||||
_getCommonQuality(tracks)!,
|
||||
style: TextStyle(
|
||||
color: _getCommonQuality(tracks)!.startsWith('24')
|
||||
? colorScheme.onTertiaryContainer
|
||||
: colorScheme.onSurfaceVariant,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String? _getCommonQuality(List<DownloadHistoryItem> tracks) {
|
||||
if (tracks.isEmpty) return null;
|
||||
final firstQuality = tracks.first.quality;
|
||||
if (firstQuality == null) return null;
|
||||
for (final track in tracks) {
|
||||
if (track.quality != firstQuality) return null;
|
||||
}
|
||||
return firstQuality;
|
||||
}
|
||||
|
||||
Widget _buildTrackListHeader(BuildContext context, ColorScheme colorScheme, List<DownloadHistoryItem> tracks) {
|
||||
return SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 8, 20, 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.queue_music, size: 20, color: colorScheme.primary),
|
||||
const SizedBox(width: 8),
|
||||
Text('Tracks', style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600, color: colorScheme.onSurface)),
|
||||
const Spacer(),
|
||||
if (!_isSelectionMode)
|
||||
TextButton.icon(
|
||||
onPressed: tracks.isNotEmpty ? () => _enterSelectionMode(tracks.first.id) : null,
|
||||
icon: const Icon(Icons.checklist, size: 18),
|
||||
label: const Text('Select'),
|
||||
style: TextButton.styleFrom(visualDensity: VisualDensity.compact),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTrackList(BuildContext context, ColorScheme colorScheme, List<DownloadHistoryItem> tracks) {
|
||||
return SliverList(
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
(context, index) {
|
||||
final track = tracks[index];
|
||||
return KeyedSubtree(
|
||||
key: ValueKey(track.id),
|
||||
child: _buildTrackItem(context, colorScheme, track),
|
||||
);
|
||||
},
|
||||
childCount: tracks.length,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTrackItem(BuildContext context, ColorScheme colorScheme, DownloadHistoryItem track) {
|
||||
final isSelected = _selectedIds.contains(track.id);
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
child: Card(
|
||||
elevation: 0,
|
||||
color: isSelected ? colorScheme.primaryContainer.withValues(alpha: 0.3) : Colors.transparent,
|
||||
margin: const EdgeInsets.symmetric(vertical: 2),
|
||||
child: ListTile(
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
onTap: _isSelectionMode
|
||||
? () => _toggleSelection(track.id)
|
||||
: () => _navigateToMetadataScreen(track),
|
||||
onLongPress: _isSelectionMode ? null : () => _enterSelectionMode(track.id),
|
||||
leading: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (_isSelectionMode) ...[
|
||||
Container(
|
||||
width: 24,
|
||||
height: 24,
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected ? colorScheme.primary : Colors.transparent,
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: isSelected ? colorScheme.primary : colorScheme.outline, width: 2),
|
||||
),
|
||||
child: isSelected
|
||||
? Icon(Icons.check, color: colorScheme.onPrimary, size: 16)
|
||||
: null,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
],
|
||||
SizedBox(
|
||||
width: 24,
|
||||
child: Text(
|
||||
track.trackNumber?.toString() ?? '-',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
title: Text(
|
||||
track.trackName,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w500),
|
||||
),
|
||||
subtitle: Text(
|
||||
track.artistName,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(color: colorScheme.onSurfaceVariant),
|
||||
),
|
||||
trailing: _isSelectionMode ? null : IconButton(
|
||||
onPressed: () => _openFile(track.filePath),
|
||||
icon: Icon(Icons.play_arrow, color: colorScheme.primary),
|
||||
style: IconButton.styleFrom(
|
||||
backgroundColor: colorScheme.primaryContainer.withValues(alpha: 0.3),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSelectionBottomBar(BuildContext context, ColorScheme colorScheme, List<DownloadHistoryItem> tracks, double bottomPadding) {
|
||||
final selectedCount = _selectedIds.length;
|
||||
final allSelected = selectedCount == tracks.length && tracks.isNotEmpty;
|
||||
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceContainerHigh,
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(28)),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.15),
|
||||
blurRadius: 12,
|
||||
offset: const Offset(0, -4),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: SafeArea(
|
||||
top: false,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.fromLTRB(16, 16, 16, bottomPadding > 0 ? 8 : 16),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: 32,
|
||||
height: 4,
|
||||
margin: const EdgeInsets.only(bottom: 16),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.outlineVariant,
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
IconButton.filledTonal(
|
||||
onPressed: _exitSelectionMode,
|
||||
icon: const Icon(Icons.close),
|
||||
style: IconButton.styleFrom(
|
||||
backgroundColor: colorScheme.surfaceContainerHighest,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'$selectedCount selected',
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
Text(
|
||||
allSelected ? 'All tracks selected' : 'Tap tracks to select',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
TextButton.icon(
|
||||
onPressed: () {
|
||||
if (allSelected) {
|
||||
_exitSelectionMode();
|
||||
} else {
|
||||
_selectAll(tracks);
|
||||
}
|
||||
},
|
||||
icon: Icon(allSelected ? Icons.deselect : Icons.select_all, size: 20),
|
||||
label: Text(allSelected ? 'Deselect' : 'Select All'),
|
||||
style: TextButton.styleFrom(foregroundColor: colorScheme.primary),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: FilledButton.icon(
|
||||
onPressed: selectedCount > 0 ? () => _deleteSelected(tracks) : null,
|
||||
icon: const Icon(Icons.delete_outline),
|
||||
label: Text(
|
||||
selectedCount > 0
|
||||
? 'Delete $selectedCount ${selectedCount == 1 ? 'track' : 'tracks'}'
|
||||
: 'Select tracks to delete',
|
||||
),
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: selectedCount > 0 ? colorScheme.error : colorScheme.surfaceContainerHighest,
|
||||
foregroundColor: selectedCount > 0 ? colorScheme.onError : colorScheme.onSurfaceVariant,
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -320,6 +320,10 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
||||
final error = ref.watch(trackProvider.select((s) => s.error));
|
||||
final hasSearchedBefore = ref.watch(settingsProvider.select((s) => s.hasSearchedBefore));
|
||||
|
||||
// Watch extension state to update search hint when extensions load/change
|
||||
ref.watch(extensionProvider.select((s) => s.isInitialized));
|
||||
ref.watch(extensionProvider.select((s) => s.extensions));
|
||||
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final hasResults = _isTyping || tracks.isNotEmpty || (searchArtists != null && searchArtists.isNotEmpty) || isLoading;
|
||||
final screenHeight = MediaQuery.of(context).size.height;
|
||||
@@ -775,9 +779,14 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
||||
String _getSearchHint() {
|
||||
final settings = ref.read(settingsProvider);
|
||||
final searchProvider = settings.searchProvider;
|
||||
final extState = ref.read(extensionProvider);
|
||||
|
||||
// If extension system not initialized yet, show default hint
|
||||
if (!extState.isInitialized) {
|
||||
return 'Paste Spotify URL or search...';
|
||||
}
|
||||
|
||||
if (searchProvider != null && searchProvider.isNotEmpty) {
|
||||
final extState = ref.read(extensionProvider);
|
||||
final ext = extState.extensions.where((e) => e.id == searchProvider).firstOrNull;
|
||||
// Only show extension placeholder if extension exists AND is enabled
|
||||
if (ext != null && ext.enabled) {
|
||||
|
||||
+978
-398
File diff suppressed because it is too large
Load Diff
@@ -4,6 +4,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||
import 'package:spotiflac_android/providers/extension_provider.dart';
|
||||
import 'package:spotiflac_android/widgets/settings_group.dart';
|
||||
|
||||
class DownloadSettingsPage extends ConsumerWidget {
|
||||
@@ -184,19 +185,31 @@ class DownloadSettingsPage extends ConsumerWidget {
|
||||
: settings.downloadDirectory,
|
||||
onTap: () => _pickDirectory(context, ref),
|
||||
),
|
||||
SettingsItem(
|
||||
icon: Icons.create_new_folder_outlined,
|
||||
title: 'Folder Organization',
|
||||
subtitle: _getFolderOrganizationLabel(
|
||||
settings.folderOrganization,
|
||||
),
|
||||
onTap: () => _showFolderOrganizationPicker(
|
||||
context,
|
||||
ref,
|
||||
settings.folderOrganization,
|
||||
),
|
||||
showDivider: false,
|
||||
SettingsSwitchItem(
|
||||
icon: Icons.library_music_outlined,
|
||||
title: 'Separate Singles Folder',
|
||||
subtitle: settings.separateSingles
|
||||
? 'Albums/ and Singles/ folders'
|
||||
: 'All files in same structure',
|
||||
value: settings.separateSingles,
|
||||
onChanged: (value) => ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setSeparateSingles(value),
|
||||
),
|
||||
if (!settings.separateSingles)
|
||||
SettingsItem(
|
||||
icon: Icons.create_new_folder_outlined,
|
||||
title: 'Folder Organization',
|
||||
subtitle: _getFolderOrganizationLabel(
|
||||
settings.folderOrganization,
|
||||
),
|
||||
onTap: () => _showFolderOrganizationPicker(
|
||||
context,
|
||||
ref,
|
||||
settings.folderOrganization,
|
||||
),
|
||||
showDivider: false,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -546,9 +559,7 @@ class DownloadSettingsPage extends ConsumerWidget {
|
||||
example: 'SpotiFLAC/Track.flac',
|
||||
isSelected: current == 'none',
|
||||
onTap: () {
|
||||
ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setFolderOrganization('none');
|
||||
ref.read(settingsProvider.notifier).setFolderOrganization('none');
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
@@ -558,9 +569,7 @@ class DownloadSettingsPage extends ConsumerWidget {
|
||||
example: 'SpotiFLAC/Artist Name/Track.flac',
|
||||
isSelected: current == 'artist',
|
||||
onTap: () {
|
||||
ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setFolderOrganization('artist');
|
||||
ref.read(settingsProvider.notifier).setFolderOrganization('artist');
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
@@ -570,9 +579,7 @@ class DownloadSettingsPage extends ConsumerWidget {
|
||||
example: 'SpotiFLAC/Album Name/Track.flac',
|
||||
isSelected: current == 'album',
|
||||
onTap: () {
|
||||
ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setFolderOrganization('album');
|
||||
ref.read(settingsProvider.notifier).setFolderOrganization('album');
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
@@ -582,9 +589,7 @@ class DownloadSettingsPage extends ConsumerWidget {
|
||||
example: 'SpotiFLAC/Artist/Album/Track.flac',
|
||||
isSelected: current == 'artist_album',
|
||||
onTap: () {
|
||||
ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setFolderOrganization('artist_album');
|
||||
ref.read(settingsProvider.notifier).setFolderOrganization('artist_album');
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
@@ -596,7 +601,7 @@ class DownloadSettingsPage extends ConsumerWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _ServiceSelector extends StatelessWidget {
|
||||
class _ServiceSelector extends ConsumerWidget {
|
||||
final String currentService;
|
||||
final ValueChanged<String> onChanged;
|
||||
const _ServiceSelector({
|
||||
@@ -605,31 +610,75 @@ class _ServiceSelector extends StatelessWidget {
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final extState = ref.watch(extensionProvider);
|
||||
|
||||
// Get enabled extension download providers
|
||||
final extensionProviders = extState.extensions
|
||||
.where((e) => e.enabled && e.hasDownloadProvider)
|
||||
.toList();
|
||||
|
||||
// Check if current service is an extension that's now disabled
|
||||
final isExtensionService = !['tidal', 'qobuz', 'amazon'].contains(currentService);
|
||||
final isCurrentExtensionEnabled = isExtensionService
|
||||
? extensionProviders.any((e) => e.id == currentService)
|
||||
: true;
|
||||
|
||||
// If current extension is disabled, show it as not selected
|
||||
final effectiveService = isCurrentExtensionEnabled ? currentService : '';
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Row(
|
||||
child: Column(
|
||||
children: [
|
||||
_ServiceChip(
|
||||
icon: Icons.music_note,
|
||||
label: 'Tidal',
|
||||
isSelected: currentService == 'tidal',
|
||||
onTap: () => onChanged('tidal'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_ServiceChip(
|
||||
icon: Icons.album,
|
||||
label: 'Qobuz',
|
||||
isSelected: currentService == 'qobuz',
|
||||
onTap: () => onChanged('qobuz'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_ServiceChip(
|
||||
icon: Icons.shopping_bag,
|
||||
label: 'Amazon',
|
||||
isSelected: currentService == 'amazon',
|
||||
onTap: () => onChanged('amazon'),
|
||||
Row(
|
||||
children: [
|
||||
_ServiceChip(
|
||||
icon: Icons.music_note,
|
||||
label: 'Tidal',
|
||||
isSelected: effectiveService == 'tidal',
|
||||
onTap: () => onChanged('tidal'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_ServiceChip(
|
||||
icon: Icons.album,
|
||||
label: 'Qobuz',
|
||||
isSelected: effectiveService == 'qobuz',
|
||||
onTap: () => onChanged('qobuz'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_ServiceChip(
|
||||
icon: Icons.shopping_bag,
|
||||
label: 'Amazon',
|
||||
isSelected: effectiveService == 'amazon',
|
||||
onTap: () => onChanged('amazon'),
|
||||
),
|
||||
],
|
||||
),
|
||||
// Show extension download providers if any
|
||||
if (extensionProviders.isNotEmpty) ...[
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
for (int i = 0; i < extensionProviders.length; i++) ...[
|
||||
if (i > 0) const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: _ServiceChip(
|
||||
icon: Icons.extension,
|
||||
label: extensionProviders[i].displayName,
|
||||
isSelected: effectiveService == extensionProviders[i].id,
|
||||
onTap: () => onChanged(extensionProviders[i].id),
|
||||
),
|
||||
),
|
||||
],
|
||||
// Fill remaining space if less than 3 extensions
|
||||
for (int i = extensionProviders.length; i < 3; i++) ...[
|
||||
const SizedBox(width: 8),
|
||||
const Expanded(child: SizedBox()),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -76,38 +76,50 @@ class OptionsSettingsPage extends ConsumerWidget {
|
||||
.setMetadataSource(v),
|
||||
),
|
||||
if (settings.metadataSource == 'spotify') ...[
|
||||
SettingsSwitchItem(
|
||||
icon: Icons.toggle_on,
|
||||
title: 'Use Custom Credentials',
|
||||
subtitle: settings.useCustomSpotifyCredentials
|
||||
? 'Using your credentials'
|
||||
: 'Using default credentials',
|
||||
value: settings.useCustomSpotifyCredentials,
|
||||
onChanged: (v) {
|
||||
ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setUseCustomSpotifyCredentials(v);
|
||||
if (v && settings.spotifyClientId.isEmpty) {
|
||||
_showSpotifyCredentialsDialog(context, ref, settings);
|
||||
}
|
||||
},
|
||||
showDivider: true,
|
||||
),
|
||||
// Info card about Spotify credentials requirement
|
||||
if (settings.spotifyClientId.isEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
|
||||
child: Card(
|
||||
color: Theme.of(context).colorScheme.errorContainer,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.warning_amber_rounded,
|
||||
color: Theme.of(context).colorScheme.onErrorContainer,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Spotify requires your own API credentials. Get them free from developer.spotify.com',
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.onErrorContainer,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
SettingsItem(
|
||||
icon: Icons.key,
|
||||
title: 'Set Credentials',
|
||||
title: 'Spotify Credentials',
|
||||
subtitle: settings.spotifyClientId.isNotEmpty
|
||||
? 'Client ID: ${settings.spotifyClientId.length > 8 ? '${settings.spotifyClientId.substring(0, 8)}...' : settings.spotifyClientId}'
|
||||
: 'Not configured',
|
||||
: 'Required - tap to configure',
|
||||
onTap: () =>
|
||||
_showSpotifyCredentialsDialog(context, ref, settings),
|
||||
trailing: Icon(
|
||||
settings.spotifyClientId.isNotEmpty
|
||||
? Icons.edit
|
||||
: Icons.add,
|
||||
? Icons.check_circle
|
||||
: Icons.error_outline,
|
||||
color: settings.spotifyClientId.isNotEmpty
|
||||
? Theme.of(context).colorScheme.onSurfaceVariant
|
||||
: Theme.of(context).colorScheme.primary,
|
||||
? Theme.of(context).colorScheme.primary
|
||||
: Theme.of(context).colorScheme.error,
|
||||
size: 20,
|
||||
),
|
||||
showDivider: false,
|
||||
@@ -782,14 +794,18 @@ class _MetadataSourceSelector extends ConsumerWidget {
|
||||
final settings = ref.watch(settingsProvider);
|
||||
final extState = ref.watch(extensionProvider);
|
||||
|
||||
// Check if extension search provider is active
|
||||
final hasExtensionSearch = settings.searchProvider != null &&
|
||||
settings.searchProvider!.isNotEmpty;
|
||||
// Check if extension search provider is active AND enabled
|
||||
Extension? activeExtension;
|
||||
if (settings.searchProvider != null && settings.searchProvider!.isNotEmpty) {
|
||||
activeExtension = extState.extensions
|
||||
.where((e) => e.id == settings.searchProvider && e.enabled)
|
||||
.firstOrNull;
|
||||
}
|
||||
final hasExtensionSearch = activeExtension != null;
|
||||
|
||||
String? extensionName;
|
||||
if (hasExtensionSearch) {
|
||||
final ext = extState.extensions.where((e) => e.id == settings.searchProvider).firstOrNull;
|
||||
extensionName = ext?.displayName ?? settings.searchProvider;
|
||||
extensionName = activeExtension.displayName;
|
||||
}
|
||||
|
||||
return Padding(
|
||||
@@ -820,6 +836,8 @@ class _MetadataSourceSelector extends ConsumerWidget {
|
||||
_SourceChip(
|
||||
icon: Icons.graphic_eq,
|
||||
label: 'Deezer',
|
||||
badge: 'Free',
|
||||
badgeColor: colorScheme.tertiary,
|
||||
// Not selected if extension is active
|
||||
isSelected: currentSource == 'deezer' && !hasExtensionSearch,
|
||||
onTap: () {
|
||||
@@ -834,6 +852,8 @@ class _MetadataSourceSelector extends ConsumerWidget {
|
||||
_SourceChip(
|
||||
icon: Icons.music_note,
|
||||
label: 'Spotify',
|
||||
badge: 'API Key',
|
||||
badgeColor: colorScheme.secondary,
|
||||
// Not selected if extension is active
|
||||
isSelected: currentSource == 'spotify' && !hasExtensionSearch,
|
||||
onTap: () {
|
||||
@@ -878,12 +898,16 @@ class _SourceChip extends StatelessWidget {
|
||||
final String label;
|
||||
final bool isSelected;
|
||||
final VoidCallback? onTap;
|
||||
final String? badge;
|
||||
final Color? badgeColor;
|
||||
|
||||
const _SourceChip({
|
||||
required this.icon,
|
||||
required this.label,
|
||||
required this.isSelected,
|
||||
this.onTap,
|
||||
this.badge,
|
||||
this.badgeColor,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -929,6 +953,24 @@ class _SourceChip extends StatelessWidget {
|
||||
: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
if (badge != null) ...[
|
||||
const SizedBox(height: 4),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: (badgeColor ?? colorScheme.tertiary).withValues(alpha: 0.2),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Text(
|
||||
badge!,
|
||||
style: TextStyle(
|
||||
fontSize: 9,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: badgeColor ?? colorScheme.tertiary,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -66,24 +66,38 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
||||
}
|
||||
} else if (Platform.isAndroid) {
|
||||
// Check storage permission
|
||||
PermissionStatus storageStatus;
|
||||
bool storageGranted = false;
|
||||
|
||||
if (_androidSdkVersion >= 33) {
|
||||
storageStatus = await Permission.audio.status;
|
||||
// Android 13+: Need BOTH MANAGE_EXTERNAL_STORAGE AND READ_MEDIA_AUDIO
|
||||
final manageStatus = await Permission.manageExternalStorage.status;
|
||||
final audioStatus = await Permission.audio.status;
|
||||
debugPrint('[Permission] Android 13+ check: MANAGE_EXTERNAL_STORAGE=$manageStatus, READ_MEDIA_AUDIO=$audioStatus');
|
||||
storageGranted = manageStatus.isGranted && audioStatus.isGranted;
|
||||
} else if (_androidSdkVersion >= 30) {
|
||||
storageStatus = await Permission.manageExternalStorage.status;
|
||||
// Android 11-12: Need MANAGE_EXTERNAL_STORAGE only
|
||||
final manageStatus = await Permission.manageExternalStorage.status;
|
||||
debugPrint('[Permission] Android 11-12 check: MANAGE_EXTERNAL_STORAGE=$manageStatus');
|
||||
storageGranted = manageStatus.isGranted;
|
||||
} else {
|
||||
storageStatus = await Permission.storage.status;
|
||||
// Android 10 and below: Use legacy storage permission
|
||||
final storageStatus = await Permission.storage.status;
|
||||
debugPrint('[Permission] Android 10- check: STORAGE=$storageStatus');
|
||||
storageGranted = storageStatus.isGranted;
|
||||
}
|
||||
|
||||
debugPrint('[Permission] Final storageGranted=$storageGranted');
|
||||
|
||||
// Check notification permission (Android 13+)
|
||||
PermissionStatus notificationStatus = PermissionStatus.granted;
|
||||
if (_androidSdkVersion >= 33) {
|
||||
notificationStatus = await Permission.notification.status;
|
||||
debugPrint('[Permission] Notification=$notificationStatus');
|
||||
}
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_storagePermissionGranted = storageStatus.isGranted;
|
||||
_storagePermissionGranted = storageGranted;
|
||||
_notificationPermissionGranted = notificationStatus.isGranted;
|
||||
});
|
||||
}
|
||||
@@ -97,17 +111,57 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
||||
if (Platform.isIOS) {
|
||||
setState(() => _storagePermissionGranted = true);
|
||||
} else if (Platform.isAndroid) {
|
||||
PermissionStatus status;
|
||||
bool allGranted = false;
|
||||
|
||||
if (_androidSdkVersion >= 33) {
|
||||
// Android 13+: Use audio permission
|
||||
status = await Permission.audio.request();
|
||||
// Android 13+: Need BOTH MANAGE_EXTERNAL_STORAGE AND READ_MEDIA_AUDIO
|
||||
|
||||
// First check/request MANAGE_EXTERNAL_STORAGE
|
||||
var manageStatus = await Permission.manageExternalStorage.status;
|
||||
if (!manageStatus.isGranted) {
|
||||
if (mounted) {
|
||||
final shouldOpen = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Storage Access Required'),
|
||||
content: const Text(
|
||||
'SpotiFLAC needs "All files access" permission to save music files to your chosen folder.\n\n'
|
||||
'Please enable "Allow access to manage all files" in the next screen.',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
child: const Text('Open Settings'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (shouldOpen == true) {
|
||||
await Permission.manageExternalStorage.request();
|
||||
// Re-check after returning from settings
|
||||
await Future.delayed(const Duration(milliseconds: 500));
|
||||
manageStatus = await Permission.manageExternalStorage.status;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Then request READ_MEDIA_AUDIO (this shows a dialog)
|
||||
var audioStatus = await Permission.audio.status;
|
||||
if (!audioStatus.isGranted && manageStatus.isGranted) {
|
||||
audioStatus = await Permission.audio.request();
|
||||
}
|
||||
|
||||
allGranted = manageStatus.isGranted && audioStatus.isGranted;
|
||||
|
||||
} else if (_androidSdkVersion >= 30) {
|
||||
// Android 11-12: Need MANAGE_EXTERNAL_STORAGE
|
||||
// This opens system settings, not a dialog
|
||||
status = await Permission.manageExternalStorage.status;
|
||||
if (!status.isGranted) {
|
||||
// Show explanation dialog first
|
||||
// Android 11-12: Need MANAGE_EXTERNAL_STORAGE only
|
||||
var manageStatus = await Permission.manageExternalStorage.status;
|
||||
if (!manageStatus.isGranted) {
|
||||
if (mounted) {
|
||||
final shouldOpen = await showDialog<bool>(
|
||||
context: context,
|
||||
@@ -131,23 +185,33 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
||||
);
|
||||
|
||||
if (shouldOpen == true) {
|
||||
status = await Permission.manageExternalStorage.request();
|
||||
await Permission.manageExternalStorage.request();
|
||||
// Re-check after returning from settings
|
||||
await Future.delayed(const Duration(milliseconds: 500));
|
||||
manageStatus = await Permission.manageExternalStorage.status;
|
||||
}
|
||||
}
|
||||
}
|
||||
allGranted = manageStatus.isGranted;
|
||||
|
||||
} else {
|
||||
// Android 10 and below: Use legacy storage permission
|
||||
status = await Permission.storage.request();
|
||||
final status = await Permission.storage.request();
|
||||
allGranted = status.isGranted;
|
||||
|
||||
if (status.isPermanentlyDenied) {
|
||||
_showPermissionDeniedDialog('Storage');
|
||||
setState(() => _isLoading = false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (status.isGranted) {
|
||||
if (allGranted) {
|
||||
setState(() => _storagePermissionGranted = true);
|
||||
} else if (status.isPermanentlyDenied) {
|
||||
_showPermissionDeniedDialog('Storage');
|
||||
} else {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Permission denied. Please grant permission to continue.')),
|
||||
const SnackBar(content: Text('Permission denied. Please grant all permissions to continue.')),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -380,11 +444,10 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
||||
_clientIdController.text.trim(),
|
||||
_clientSecretController.text.trim(),
|
||||
);
|
||||
ref.read(settingsProvider.notifier).setUseCustomSpotifyCredentials(true);
|
||||
// Set search source to Spotify when using custom credentials
|
||||
// Set search source to Spotify when credentials are provided
|
||||
ref.read(settingsProvider.notifier).setMetadataSource('spotify');
|
||||
} else {
|
||||
// Use Deezer as default search source
|
||||
// Use Deezer as default search source (free, no credentials required)
|
||||
ref.read(settingsProvider.notifier).setMetadataSource('deezer');
|
||||
}
|
||||
|
||||
|
||||
@@ -331,7 +331,6 @@ class PlatformBridge {
|
||||
}
|
||||
|
||||
/// Set custom Spotify API credentials
|
||||
/// Pass empty strings to use default credentials
|
||||
static Future<void> setSpotifyCredentials(String clientId, String clientSecret) async {
|
||||
await _channel.invokeMethod('setSpotifyCredentials', {
|
||||
'client_id': clientId,
|
||||
@@ -339,6 +338,13 @@ class PlatformBridge {
|
||||
});
|
||||
}
|
||||
|
||||
/// Check if Spotify credentials are configured
|
||||
/// Returns true if credentials are available (custom or env vars)
|
||||
static Future<bool> hasSpotifyCredentials() async {
|
||||
final result = await _channel.invokeMethod('hasSpotifyCredentials');
|
||||
return result as bool;
|
||||
}
|
||||
|
||||
/// Pre-warm track ID cache for album/playlist tracks
|
||||
/// This runs in background and returns immediately
|
||||
/// Speeds up subsequent downloads by caching ISRC → Track ID mappings
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
name: spotiflac_android
|
||||
description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music
|
||||
publish_to: "none"
|
||||
version: 3.0.0-alpha.1+50
|
||||
version: 3.0.0-alpha.3+52
|
||||
|
||||
environment:
|
||||
sdk: ^3.10.0
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
name: spotiflac_android
|
||||
description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music
|
||||
publish_to: "none"
|
||||
version: 3.0.0-alpha.1+50
|
||||
version: 3.0.0-alpha.2+51
|
||||
|
||||
environment:
|
||||
sdk: ^3.10.0
|
||||
|
||||
Reference in New Issue
Block a user