mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-07-03 03:15:51 +02:00
Compare commits
26 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c7e1ffd926 | |||
| 729ab01a5f | |||
| 0a16be4395 | |||
| 47cdb5564a | |||
| f7d5a24d17 | |||
| 8daff4d0a4 | |||
| a38d66fd41 | |||
| 0cab01780d | |||
| 4afc14dee8 | |||
| 523b1edc44 | |||
| 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
|
||||
|
||||
+162
@@ -1,5 +1,141 @@
|
||||
# Changelog
|
||||
|
||||
## [3.0.0-alpha.4] - Upcoming
|
||||
|
||||
### Added
|
||||
|
||||
- **Extension Store**: Browse and install extensions directly from the app
|
||||
- New "Store" tab in bottom navigation
|
||||
- Browse extensions by category (Metadata, Download, Utility, Lyrics, Integration)
|
||||
- Search extensions by name, description, or tags
|
||||
- One-tap install and update
|
||||
- Offline cache for browsing without internet
|
||||
- Extensions hosted at github.com/zarzet/SpotiFLAC-Extension
|
||||
|
||||
- **Custom URL Handler for Extensions**: Extensions can now register custom URL patterns
|
||||
- Handle URLs from YouTube Music, SoundCloud, Bandcamp, etc.
|
||||
- Manifest config: `urlHandler: { enabled: true, patterns: ["music.youtube.com"] }`
|
||||
- Implement `handleURL(url)` function in extension to parse and return track metadata
|
||||
- SpotiFLAC automatically routes matching URLs to the appropriate extension
|
||||
- Supports share intents and paste from clipboard
|
||||
|
||||
- **Artist URL Handler Support**: Extensions can now return artist data from URL handlers
|
||||
- Added `type: "artist"` handling in track_provider.dart
|
||||
- Navigate to artist screen with albums list from extension
|
||||
|
||||
- **HMAC-SHA1 Utility**: New `utils.hmacSHA1(key, message)` function for extensions
|
||||
- Enables TOTP generation and other cryptographic operations
|
||||
- Returns byte array for flexible use
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Extension Store Refresh**: Store tab now properly refreshes after uninstalling an extension
|
||||
- "Installed" badge correctly updates to "Install" button
|
||||
|
||||
### Documentation
|
||||
|
||||
- Updated `docs/EXTENSION_DEVELOPMENT.md`:
|
||||
- Added Custom URL Handler section with examples
|
||||
- Added `handleURL` function documentation
|
||||
- Added URL pattern examples for YouTube, SoundCloud, Bandcamp
|
||||
- Added `utils.hmacSHA1` documentation with TOTP example
|
||||
|
||||
### Extensions
|
||||
|
||||
- **Spotify Web Extension** (example): New extension for Spotify metadata via web API
|
||||
- Supports personalized playlists (Daily Mix, Discover Weekly, Release Radar, etc.)
|
||||
- Search, album, playlist, track, and artist fetching
|
||||
- Available in Extension Store (3.0.0-alpha.4)
|
||||
|
||||
---
|
||||
|
||||
## [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 +229,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) {
|
||||
@@ -545,6 +551,27 @@ class MainActivity: FlutterActivity() {
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
// Extension URL Handler API
|
||||
"handleURLWithExtension" -> {
|
||||
val url = call.argument<String>("url") ?: ""
|
||||
val response = withContext(Dispatchers.IO) {
|
||||
Gobackend.handleURLWithExtensionJSON(url)
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
"findURLHandler" -> {
|
||||
val url = call.argument<String>("url") ?: ""
|
||||
val response = withContext(Dispatchers.IO) {
|
||||
Gobackend.findURLHandlerJSON(url)
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
"getURLHandlers" -> {
|
||||
val response = withContext(Dispatchers.IO) {
|
||||
Gobackend.getURLHandlersJSON()
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
// Extension Post-Processing API
|
||||
"runPostProcessing" -> {
|
||||
val filePath = call.argument<String>("file_path") ?: ""
|
||||
@@ -560,6 +587,49 @@ class MainActivity: FlutterActivity() {
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
// Extension Store
|
||||
"initExtensionStore" -> {
|
||||
val cacheDir = call.argument<String>("cache_dir") ?: ""
|
||||
withContext(Dispatchers.IO) {
|
||||
Gobackend.initExtensionStoreJSON(cacheDir)
|
||||
}
|
||||
result.success(null)
|
||||
}
|
||||
"getStoreExtensions" -> {
|
||||
val forceRefresh = call.argument<Boolean>("force_refresh") ?: false
|
||||
val response = withContext(Dispatchers.IO) {
|
||||
Gobackend.getStoreExtensionsJSON(forceRefresh)
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
"searchStoreExtensions" -> {
|
||||
val query = call.argument<String>("query") ?: ""
|
||||
val category = call.argument<String>("category") ?: ""
|
||||
val response = withContext(Dispatchers.IO) {
|
||||
Gobackend.searchStoreExtensionsJSON(query, category)
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
"getStoreCategories" -> {
|
||||
val response = withContext(Dispatchers.IO) {
|
||||
Gobackend.getStoreCategoriesJSON()
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
"downloadStoreExtension" -> {
|
||||
val extensionId = call.argument<String>("extension_id") ?: ""
|
||||
val destDir = call.argument<String>("dest_dir") ?: ""
|
||||
val response = withContext(Dispatchers.IO) {
|
||||
Gobackend.downloadStoreExtensionJSON(extensionId, destDir)
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
"clearStoreCache" -> {
|
||||
withContext(Dispatchers.IO) {
|
||||
Gobackend.clearStoreCacheJSON()
|
||||
}
|
||||
result.success(null)
|
||||
}
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
+268
-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") ||
|
||||
@@ -1481,6 +1506,135 @@ func GetSearchProvidersJSON() (string, error) {
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
// ==================== EXTENSION URL HANDLER ====================
|
||||
|
||||
// HandleURLWithExtensionJSON tries to handle a URL with any matching extension
|
||||
// Returns JSON with type, tracks, album info, etc.
|
||||
func HandleURLWithExtensionJSON(url string) (string, error) {
|
||||
manager := GetExtensionManager()
|
||||
resultWithID, err := manager.HandleURLWithExtension(url)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
result := resultWithID.Result
|
||||
extensionID := resultWithID.ExtensionID
|
||||
|
||||
// Check if result is nil (handler found but returned error)
|
||||
if result == nil {
|
||||
return "", fmt.Errorf("extension %s failed to handle URL", extensionID)
|
||||
}
|
||||
|
||||
// Build response
|
||||
response := map[string]interface{}{
|
||||
"type": result.Type,
|
||||
"extension_id": extensionID,
|
||||
"name": result.Name,
|
||||
"cover_url": result.CoverURL,
|
||||
}
|
||||
|
||||
// Add track if single track
|
||||
if result.Track != nil {
|
||||
response["track"] = map[string]interface{}{
|
||||
"id": result.Track.ID,
|
||||
"name": result.Track.Name,
|
||||
"artists": result.Track.Artists,
|
||||
"album_name": result.Track.AlbumName,
|
||||
"album_artist": result.Track.AlbumArtist,
|
||||
"duration_ms": result.Track.DurationMS,
|
||||
"images": result.Track.ResolvedCoverURL(),
|
||||
"release_date": result.Track.ReleaseDate,
|
||||
"track_number": result.Track.TrackNumber,
|
||||
"disc_number": result.Track.DiscNumber,
|
||||
"isrc": result.Track.ISRC,
|
||||
"provider_id": result.Track.ProviderID,
|
||||
}
|
||||
}
|
||||
|
||||
// Add tracks if multiple
|
||||
if len(result.Tracks) > 0 {
|
||||
tracks := make([]map[string]interface{}, len(result.Tracks))
|
||||
for i, track := range result.Tracks {
|
||||
tracks[i] = map[string]interface{}{
|
||||
"id": track.ID,
|
||||
"name": track.Name,
|
||||
"artists": track.Artists,
|
||||
"album_name": track.AlbumName,
|
||||
"album_artist": track.AlbumArtist,
|
||||
"duration_ms": track.DurationMS,
|
||||
"images": track.ResolvedCoverURL(),
|
||||
"release_date": track.ReleaseDate,
|
||||
"track_number": track.TrackNumber,
|
||||
"disc_number": track.DiscNumber,
|
||||
"isrc": track.ISRC,
|
||||
"provider_id": track.ProviderID,
|
||||
}
|
||||
}
|
||||
response["tracks"] = tracks
|
||||
}
|
||||
|
||||
// Add album info if present
|
||||
if result.Album != nil {
|
||||
response["album"] = map[string]interface{}{
|
||||
"id": result.Album.ID,
|
||||
"name": result.Album.Name,
|
||||
"artists": result.Album.Artists,
|
||||
"cover_url": result.Album.CoverURL,
|
||||
"release_date": result.Album.ReleaseDate,
|
||||
"total_tracks": result.Album.TotalTracks,
|
||||
}
|
||||
}
|
||||
|
||||
// Add artist info if present
|
||||
if result.Artist != nil {
|
||||
response["artist"] = map[string]interface{}{
|
||||
"id": result.Artist.ID,
|
||||
"name": result.Artist.Name,
|
||||
"image_url": result.Artist.ImageURL,
|
||||
}
|
||||
}
|
||||
|
||||
jsonBytes, err := json.Marshal(response)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
// FindURLHandlerJSON finds an extension that can handle the given URL
|
||||
// Returns extension ID or empty string if none found
|
||||
func FindURLHandlerJSON(url string) string {
|
||||
manager := GetExtensionManager()
|
||||
handler := manager.FindURLHandler(url)
|
||||
if handler == nil {
|
||||
return ""
|
||||
}
|
||||
return handler.extension.ID
|
||||
}
|
||||
|
||||
// GetURLHandlersJSON returns all extensions that handle custom URLs
|
||||
func GetURLHandlersJSON() (string, error) {
|
||||
manager := GetExtensionManager()
|
||||
handlers := manager.GetURLHandlers()
|
||||
|
||||
result := make([]map[string]interface{}, 0, len(handlers))
|
||||
for _, h := range handlers {
|
||||
result = append(result, map[string]interface{}{
|
||||
"id": h.extension.ID,
|
||||
"display_name": h.extension.Manifest.DisplayName,
|
||||
"patterns": h.extension.Manifest.URLHandler.Patterns,
|
||||
})
|
||||
}
|
||||
|
||||
jsonBytes, err := json.Marshal(result)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
// ==================== EXTENSION POST-PROCESSING ====================
|
||||
|
||||
// RunPostProcessingJSON runs post-processing hooks on a file
|
||||
@@ -1538,3 +1692,100 @@ func GetPostProcessingProvidersJSON() (string, error) {
|
||||
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
// ==================== EXTENSION STORE ====================
|
||||
|
||||
// InitExtensionStoreJSON initializes the extension store with cache directory
|
||||
func InitExtensionStoreJSON(cacheDir string) error {
|
||||
InitExtensionStore(cacheDir)
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetStoreExtensionsJSON returns all extensions from the store with installation status
|
||||
func GetStoreExtensionsJSON(forceRefresh bool) (string, error) {
|
||||
store := GetExtensionStore()
|
||||
if store == nil {
|
||||
return "", fmt.Errorf("extension store not initialized")
|
||||
}
|
||||
|
||||
// Force refresh if requested
|
||||
if forceRefresh {
|
||||
store.FetchRegistry(true)
|
||||
}
|
||||
|
||||
extensions, err := store.GetExtensionsWithStatus()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
jsonBytes, err := json.Marshal(extensions)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
// SearchStoreExtensionsJSON searches extensions in the store
|
||||
func SearchStoreExtensionsJSON(query, category string) (string, error) {
|
||||
store := GetExtensionStore()
|
||||
if store == nil {
|
||||
return "", fmt.Errorf("extension store not initialized")
|
||||
}
|
||||
|
||||
extensions, err := store.SearchExtensions(query, category)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
jsonBytes, err := json.Marshal(extensions)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
// GetStoreCategoriesJSON returns all available categories
|
||||
func GetStoreCategoriesJSON() (string, error) {
|
||||
store := GetExtensionStore()
|
||||
if store == nil {
|
||||
return "", fmt.Errorf("extension store not initialized")
|
||||
}
|
||||
|
||||
categories := store.GetCategories()
|
||||
jsonBytes, err := json.Marshal(categories)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
// DownloadStoreExtensionJSON downloads an extension from the store
|
||||
// Returns the path to the downloaded file
|
||||
func DownloadStoreExtensionJSON(extensionID, destDir string) (string, error) {
|
||||
store := GetExtensionStore()
|
||||
if store == nil {
|
||||
return "", fmt.Errorf("extension store not initialized")
|
||||
}
|
||||
|
||||
destPath := fmt.Sprintf("%s/%s.spotiflac-ext", destDir, extensionID)
|
||||
err := store.DownloadExtension(extensionID, destPath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return destPath, nil
|
||||
}
|
||||
|
||||
// ClearStoreCacheJSON clears the store cache
|
||||
func ClearStoreCacheJSON() error {
|
||||
store := GetExtensionStore()
|
||||
if store == nil {
|
||||
return fmt.Errorf("extension store not initialized")
|
||||
}
|
||||
|
||||
store.ClearCache()
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -231,7 +231,7 @@ func (m *ExtensionManager) LoadExtensionFromFile(filePath string) (*LoadedExtens
|
||||
ext := &LoadedExtension{
|
||||
ID: manifest.Name,
|
||||
Manifest: manifest,
|
||||
Enabled: true,
|
||||
Enabled: false, // New extensions start disabled
|
||||
DataDir: extDataDir,
|
||||
SourceDir: extDir,
|
||||
}
|
||||
@@ -459,7 +459,7 @@ func (m *ExtensionManager) loadExtensionFromDirectory(dirPath string) (*LoadedEx
|
||||
ext := &LoadedExtension{
|
||||
ID: manifest.Name,
|
||||
Manifest: manifest,
|
||||
Enabled: true,
|
||||
Enabled: false, // Will be restored from settings store
|
||||
DataDir: extDataDir,
|
||||
SourceDir: dirPath,
|
||||
}
|
||||
@@ -583,9 +583,10 @@ func (m *ExtensionManager) UpgradeExtension(filePath string) (*LoadedExtension,
|
||||
|
||||
GoLog("[Extension] Upgrading %s from v%s to v%s\n", newManifest.DisplayName, existing.Manifest.Version, newManifest.Version)
|
||||
|
||||
// Save data directory path (we want to preserve it)
|
||||
// Save data directory path and enabled state (we want to preserve them)
|
||||
extDataDir := existing.DataDir
|
||||
extDir := existing.SourceDir
|
||||
wasEnabled := existing.Enabled
|
||||
|
||||
// Cleanup and unload existing extension
|
||||
m.CleanupExtension(existing.ID)
|
||||
@@ -633,11 +634,11 @@ func (m *ExtensionManager) UpgradeExtension(filePath string) (*LoadedExtension,
|
||||
}
|
||||
}
|
||||
|
||||
// Create new loaded extension (reusing data directory)
|
||||
// Create new loaded extension (reusing data directory, preserving enabled state)
|
||||
ext := &LoadedExtension{
|
||||
ID: newManifest.Name,
|
||||
Manifest: newManifest,
|
||||
Enabled: true,
|
||||
Enabled: wasEnabled, // Preserve enabled state from before upgrade
|
||||
DataDir: extDataDir,
|
||||
SourceDir: extDir,
|
||||
}
|
||||
|
||||
@@ -74,6 +74,12 @@ type SearchBehaviorConfig struct {
|
||||
ThumbnailHeight int `json:"thumbnailHeight,omitempty"` // Custom thumbnail height in pixels
|
||||
}
|
||||
|
||||
// URLHandlerConfig defines custom URL handling for an extension
|
||||
type URLHandlerConfig struct {
|
||||
Enabled bool `json:"enabled"` // Whether extension handles URLs
|
||||
Patterns []string `json:"patterns,omitempty"` // URL patterns to match (e.g., "music.youtube.com", "soundcloud.com")
|
||||
}
|
||||
|
||||
// TrackMatchingConfig defines custom track matching behavior
|
||||
type TrackMatchingConfig struct {
|
||||
CustomMatching bool `json:"customMatching"` // Whether extension handles matching
|
||||
@@ -113,6 +119,7 @@ type ExtensionManifest struct {
|
||||
SkipMetadataEnrichment bool `json:"skipMetadataEnrichment,omitempty"` // If true, don't enrich metadata from Deezer/Spotify
|
||||
SkipBuiltInFallback bool `json:"skipBuiltInFallback,omitempty"` // If true, don't fallback to built-in providers (tidal/qobuz/amazon)
|
||||
SearchBehavior *SearchBehaviorConfig `json:"searchBehavior,omitempty"` // Custom search behavior
|
||||
URLHandler *URLHandlerConfig `json:"urlHandler,omitempty"` // Custom URL handling
|
||||
TrackMatching *TrackMatchingConfig `json:"trackMatching,omitempty"` // Custom track matching
|
||||
PostProcessing *PostProcessingConfig `json:"postProcessing,omitempty"` // Post-processing hooks
|
||||
}
|
||||
@@ -270,6 +277,29 @@ func (m *ExtensionManifest) HasPostProcessing() bool {
|
||||
return m.PostProcessing != nil && m.PostProcessing.Enabled
|
||||
}
|
||||
|
||||
// HasURLHandler returns true if extension handles custom URLs
|
||||
func (m *ExtensionManifest) HasURLHandler() bool {
|
||||
return m.URLHandler != nil && m.URLHandler.Enabled && len(m.URLHandler.Patterns) > 0
|
||||
}
|
||||
|
||||
// MatchesURL checks if a URL matches any of the extension's URL patterns
|
||||
func (m *ExtensionManifest) MatchesURL(urlStr string) bool {
|
||||
if !m.HasURLHandler() {
|
||||
return false
|
||||
}
|
||||
|
||||
// Parse URL to get host
|
||||
urlStr = strings.ToLower(strings.TrimSpace(urlStr))
|
||||
for _, pattern := range m.URLHandler.Patterns {
|
||||
pattern = strings.ToLower(strings.TrimSpace(pattern))
|
||||
// Check if URL contains the pattern (host match)
|
||||
if strings.Contains(urlStr, pattern) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// GetPostProcessingHooks returns all post-processing hooks
|
||||
func (m *ExtensionManifest) GetPostProcessingHooks() []PostProcessingHook {
|
||||
if m.PostProcessing == nil {
|
||||
|
||||
@@ -981,6 +981,69 @@ func (p *ExtensionProviderWrapper) CustomSearch(query string, options map[string
|
||||
return tracks, nil
|
||||
}
|
||||
|
||||
// ==================== Custom URL Handler ====================
|
||||
|
||||
// ExtURLHandleResult represents the result of URL handling
|
||||
type ExtURLHandleResult struct {
|
||||
Type string `json:"type"` // "track", "album", "playlist", "artist"
|
||||
Track *ExtTrackMetadata `json:"track,omitempty"` // For single track
|
||||
Tracks []ExtTrackMetadata `json:"tracks,omitempty"` // For album/playlist
|
||||
Album *ExtAlbumMetadata `json:"album,omitempty"` // Album info
|
||||
Artist *ExtArtistMetadata `json:"artist,omitempty"` // Artist info
|
||||
Name string `json:"name,omitempty"` // Playlist/album name
|
||||
CoverURL string `json:"cover_url,omitempty"` // Cover image
|
||||
}
|
||||
|
||||
// HandleURL processes a URL using the extension's URL handler
|
||||
func (p *ExtensionProviderWrapper) HandleURL(url string) (*ExtURLHandleResult, error) {
|
||||
if !p.extension.Manifest.HasURLHandler() {
|
||||
return nil, fmt.Errorf("extension '%s' does not support URL handling", p.extension.ID)
|
||||
}
|
||||
|
||||
if !p.extension.Enabled {
|
||||
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
||||
}
|
||||
|
||||
script := fmt.Sprintf(`
|
||||
(function() {
|
||||
if (typeof extension !== 'undefined' && typeof extension.handleUrl === 'function') {
|
||||
return extension.handleUrl(%q);
|
||||
}
|
||||
return null;
|
||||
})()
|
||||
`, url)
|
||||
|
||||
result, err := p.vm.RunString(script)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("handleUrl failed: %w", err)
|
||||
}
|
||||
|
||||
if result == nil || goja.IsUndefined(result) || goja.IsNull(result) {
|
||||
return nil, fmt.Errorf("handleUrl returned null - URL not recognized")
|
||||
}
|
||||
|
||||
exported := result.Export()
|
||||
jsonBytes, err := json.Marshal(exported)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal result: %w", err)
|
||||
}
|
||||
|
||||
var handleResult ExtURLHandleResult
|
||||
if err := json.Unmarshal(jsonBytes, &handleResult); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse URL handle result: %w", err)
|
||||
}
|
||||
|
||||
// Set provider ID on tracks
|
||||
if handleResult.Track != nil {
|
||||
handleResult.Track.ProviderID = p.extension.ID
|
||||
}
|
||||
for i := range handleResult.Tracks {
|
||||
handleResult.Tracks[i].ProviderID = p.extension.ID
|
||||
}
|
||||
|
||||
return &handleResult, nil
|
||||
}
|
||||
|
||||
// ==================== Custom Track Matching ====================
|
||||
|
||||
// MatchTrackResult represents the result of custom track matching
|
||||
@@ -1120,6 +1183,61 @@ func (m *ExtensionManager) GetSearchProviders() []*ExtensionProviderWrapper {
|
||||
return providers
|
||||
}
|
||||
|
||||
// GetURLHandlers returns all extensions that handle custom URLs
|
||||
func (m *ExtensionManager) GetURLHandlers() []*ExtensionProviderWrapper {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
var providers []*ExtensionProviderWrapper
|
||||
for _, ext := range m.extensions {
|
||||
if ext.Enabled && ext.Manifest.HasURLHandler() && ext.Error == "" {
|
||||
providers = append(providers, NewExtensionProviderWrapper(ext))
|
||||
}
|
||||
}
|
||||
return providers
|
||||
}
|
||||
|
||||
// FindURLHandler finds an extension that can handle the given URL
|
||||
func (m *ExtensionManager) FindURLHandler(url string) *ExtensionProviderWrapper {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
for _, ext := range m.extensions {
|
||||
if ext.Enabled && ext.Manifest.MatchesURL(url) && ext.Error == "" {
|
||||
return NewExtensionProviderWrapper(ext)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ExtURLHandleResultWithExtID wraps ExtURLHandleResult with extension ID for gomobile compatibility
|
||||
type ExtURLHandleResultWithExtID struct {
|
||||
Result *ExtURLHandleResult
|
||||
ExtensionID string
|
||||
}
|
||||
|
||||
// HandleURLWithExtension tries to handle a URL with any matching extension
|
||||
// Returns result with extension ID, or error if no handler found
|
||||
func (m *ExtensionManager) HandleURLWithExtension(url string) (*ExtURLHandleResultWithExtID, error) {
|
||||
handler := m.FindURLHandler(url)
|
||||
if handler == nil {
|
||||
return nil, fmt.Errorf("no extension found to handle URL: %s", url)
|
||||
}
|
||||
|
||||
result, err := handler.HandleURL(url)
|
||||
if err != nil {
|
||||
return &ExtURLHandleResultWithExtID{
|
||||
Result: nil,
|
||||
ExtensionID: handler.extension.ID,
|
||||
}, err
|
||||
}
|
||||
|
||||
return &ExtURLHandleResultWithExtID{
|
||||
Result: result,
|
||||
ExtensionID: handler.extension.ID,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetPostProcessingProviders returns all extensions that provide post-processing
|
||||
func (m *ExtensionManager) GetPostProcessingProviders() []*ExtensionProviderWrapper {
|
||||
m.mu.RLock()
|
||||
|
||||
+67
-1733
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,547 @@
|
||||
// Package gobackend provides Auth API and PKCE support for extension runtime
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/dop251/goja"
|
||||
)
|
||||
|
||||
// ==================== Auth API (OAuth Support) ====================
|
||||
|
||||
// authOpenUrl requests Flutter to open an OAuth URL
|
||||
func (r *ExtensionRuntime) authOpenUrl(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": "auth URL is required",
|
||||
})
|
||||
}
|
||||
|
||||
authURL := call.Arguments[0].String()
|
||||
callbackURL := ""
|
||||
if len(call.Arguments) > 1 && !goja.IsUndefined(call.Arguments[1]) {
|
||||
callbackURL = call.Arguments[1].String()
|
||||
}
|
||||
|
||||
// Store pending auth request for Flutter to pick up
|
||||
pendingAuthRequestsMu.Lock()
|
||||
pendingAuthRequests[r.extensionID] = &PendingAuthRequest{
|
||||
ExtensionID: r.extensionID,
|
||||
AuthURL: authURL,
|
||||
CallbackURL: callbackURL,
|
||||
}
|
||||
pendingAuthRequestsMu.Unlock()
|
||||
|
||||
// Update auth state
|
||||
extensionAuthStateMu.Lock()
|
||||
state, exists := extensionAuthState[r.extensionID]
|
||||
if !exists {
|
||||
state = &ExtensionAuthState{}
|
||||
extensionAuthState[r.extensionID] = state
|
||||
}
|
||||
state.PendingAuthURL = authURL
|
||||
state.AuthCode = "" // Clear any previous auth code
|
||||
extensionAuthStateMu.Unlock()
|
||||
|
||||
GoLog("[Extension:%s] Auth URL requested: %s\n", r.extensionID, authURL)
|
||||
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": true,
|
||||
"message": "Auth URL will be opened by the app",
|
||||
})
|
||||
}
|
||||
|
||||
// authGetCode gets the auth code (set by Flutter after OAuth callback)
|
||||
func (r *ExtensionRuntime) authGetCode(call goja.FunctionCall) goja.Value {
|
||||
extensionAuthStateMu.RLock()
|
||||
defer extensionAuthStateMu.RUnlock()
|
||||
|
||||
state, exists := extensionAuthState[r.extensionID]
|
||||
if !exists || state.AuthCode == "" {
|
||||
return goja.Undefined()
|
||||
}
|
||||
|
||||
return r.vm.ToValue(state.AuthCode)
|
||||
}
|
||||
|
||||
// authSetCode sets auth code and tokens (can be called by extension after token exchange)
|
||||
func (r *ExtensionRuntime) authSetCode(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue(false)
|
||||
}
|
||||
|
||||
// Can accept either just auth code or an object with tokens
|
||||
arg := call.Arguments[0].Export()
|
||||
|
||||
extensionAuthStateMu.Lock()
|
||||
defer extensionAuthStateMu.Unlock()
|
||||
|
||||
state, exists := extensionAuthState[r.extensionID]
|
||||
if !exists {
|
||||
state = &ExtensionAuthState{}
|
||||
extensionAuthState[r.extensionID] = state
|
||||
}
|
||||
|
||||
switch v := arg.(type) {
|
||||
case string:
|
||||
state.AuthCode = v
|
||||
case map[string]interface{}:
|
||||
if code, ok := v["code"].(string); ok {
|
||||
state.AuthCode = code
|
||||
}
|
||||
if accessToken, ok := v["access_token"].(string); ok {
|
||||
state.AccessToken = accessToken
|
||||
state.IsAuthenticated = true
|
||||
}
|
||||
if refreshToken, ok := v["refresh_token"].(string); ok {
|
||||
state.RefreshToken = refreshToken
|
||||
}
|
||||
if expiresIn, ok := v["expires_in"].(float64); ok {
|
||||
state.ExpiresAt = time.Now().Add(time.Duration(expiresIn) * time.Second)
|
||||
}
|
||||
}
|
||||
|
||||
return r.vm.ToValue(true)
|
||||
}
|
||||
|
||||
// authClear clears all auth state for the extension
|
||||
func (r *ExtensionRuntime) authClear(call goja.FunctionCall) goja.Value {
|
||||
extensionAuthStateMu.Lock()
|
||||
delete(extensionAuthState, r.extensionID)
|
||||
extensionAuthStateMu.Unlock()
|
||||
|
||||
pendingAuthRequestsMu.Lock()
|
||||
delete(pendingAuthRequests, r.extensionID)
|
||||
pendingAuthRequestsMu.Unlock()
|
||||
|
||||
GoLog("[Extension:%s] Auth state cleared\n", r.extensionID)
|
||||
return r.vm.ToValue(true)
|
||||
}
|
||||
|
||||
// authIsAuthenticated checks if extension has valid auth
|
||||
func (r *ExtensionRuntime) authIsAuthenticated(call goja.FunctionCall) goja.Value {
|
||||
extensionAuthStateMu.RLock()
|
||||
defer extensionAuthStateMu.RUnlock()
|
||||
|
||||
state, exists := extensionAuthState[r.extensionID]
|
||||
if !exists {
|
||||
return r.vm.ToValue(false)
|
||||
}
|
||||
|
||||
// Check if token is expired
|
||||
if state.IsAuthenticated && !state.ExpiresAt.IsZero() && time.Now().After(state.ExpiresAt) {
|
||||
return r.vm.ToValue(false)
|
||||
}
|
||||
|
||||
return r.vm.ToValue(state.IsAuthenticated)
|
||||
}
|
||||
|
||||
// authGetTokens returns current tokens (for extension to use in API calls)
|
||||
func (r *ExtensionRuntime) authGetTokens(call goja.FunctionCall) goja.Value {
|
||||
extensionAuthStateMu.RLock()
|
||||
defer extensionAuthStateMu.RUnlock()
|
||||
|
||||
state, exists := extensionAuthState[r.extensionID]
|
||||
if !exists {
|
||||
return r.vm.ToValue(map[string]interface{}{})
|
||||
}
|
||||
|
||||
result := map[string]interface{}{
|
||||
"access_token": state.AccessToken,
|
||||
"refresh_token": state.RefreshToken,
|
||||
"is_authenticated": state.IsAuthenticated,
|
||||
}
|
||||
|
||||
if !state.ExpiresAt.IsZero() {
|
||||
result["expires_at"] = state.ExpiresAt.Unix()
|
||||
result["is_expired"] = time.Now().After(state.ExpiresAt)
|
||||
}
|
||||
|
||||
return r.vm.ToValue(result)
|
||||
}
|
||||
|
||||
// ==================== PKCE Support ====================
|
||||
|
||||
// generatePKCEVerifier generates a cryptographically random code verifier
|
||||
// Length should be between 43-128 characters (RFC 7636)
|
||||
func generatePKCEVerifier(length int) (string, error) {
|
||||
if length < 43 {
|
||||
length = 43
|
||||
}
|
||||
if length > 128 {
|
||||
length = 128
|
||||
}
|
||||
|
||||
// Generate random bytes
|
||||
bytes := make([]byte, length)
|
||||
if _, err := rand.Read(bytes); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Use base64url encoding without padding (RFC 7636 compliant)
|
||||
verifier := base64.RawURLEncoding.EncodeToString(bytes)
|
||||
|
||||
// Trim to exact length
|
||||
if len(verifier) > length {
|
||||
verifier = verifier[:length]
|
||||
}
|
||||
|
||||
return verifier, nil
|
||||
}
|
||||
|
||||
// generatePKCEChallenge generates a code challenge from verifier using S256 method
|
||||
func generatePKCEChallenge(verifier string) string {
|
||||
hash := sha256.Sum256([]byte(verifier))
|
||||
// Base64url encode without padding (RFC 7636)
|
||||
return base64.RawURLEncoding.EncodeToString(hash[:])
|
||||
}
|
||||
|
||||
// authGeneratePKCE generates a PKCE code verifier and challenge pair
|
||||
// Returns: { verifier: string, challenge: string, method: "S256" }
|
||||
func (r *ExtensionRuntime) authGeneratePKCE(call goja.FunctionCall) goja.Value {
|
||||
// Default length is 64 characters
|
||||
length := 64
|
||||
if len(call.Arguments) > 0 && !goja.IsUndefined(call.Arguments[0]) {
|
||||
if l, ok := call.Arguments[0].Export().(float64); ok && l >= 43 && l <= 128 {
|
||||
length = int(l)
|
||||
}
|
||||
}
|
||||
|
||||
verifier, err := generatePKCEVerifier(length)
|
||||
if err != nil {
|
||||
GoLog("[Extension:%s] PKCE generation error: %v\n", r.extensionID, err)
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
challenge := generatePKCEChallenge(verifier)
|
||||
|
||||
// Store in auth state for later use
|
||||
extensionAuthStateMu.Lock()
|
||||
state, exists := extensionAuthState[r.extensionID]
|
||||
if !exists {
|
||||
state = &ExtensionAuthState{}
|
||||
extensionAuthState[r.extensionID] = state
|
||||
}
|
||||
state.PKCEVerifier = verifier
|
||||
state.PKCEChallenge = challenge
|
||||
extensionAuthStateMu.Unlock()
|
||||
|
||||
GoLog("[Extension:%s] PKCE generated (verifier length: %d)\n", r.extensionID, len(verifier))
|
||||
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"verifier": verifier,
|
||||
"challenge": challenge,
|
||||
"method": "S256",
|
||||
})
|
||||
}
|
||||
|
||||
// authGetPKCE returns the current PKCE verifier and challenge (if generated)
|
||||
func (r *ExtensionRuntime) authGetPKCE(call goja.FunctionCall) goja.Value {
|
||||
extensionAuthStateMu.RLock()
|
||||
defer extensionAuthStateMu.RUnlock()
|
||||
|
||||
state, exists := extensionAuthState[r.extensionID]
|
||||
if !exists || state.PKCEVerifier == "" {
|
||||
return r.vm.ToValue(map[string]interface{}{})
|
||||
}
|
||||
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"verifier": state.PKCEVerifier,
|
||||
"challenge": state.PKCEChallenge,
|
||||
"method": "S256",
|
||||
})
|
||||
}
|
||||
|
||||
// authStartOAuthWithPKCE is a high-level helper that generates PKCE and opens OAuth URL
|
||||
// config: { authUrl, clientId, redirectUri, scope, extraParams }
|
||||
// Returns: { success, authUrl, pkce: { verifier, challenge } }
|
||||
func (r *ExtensionRuntime) authStartOAuthWithPKCE(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": "config object is required",
|
||||
})
|
||||
}
|
||||
|
||||
configObj := call.Arguments[0].Export()
|
||||
config, ok := configObj.(map[string]interface{})
|
||||
if !ok {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": "config must be an object",
|
||||
})
|
||||
}
|
||||
|
||||
// Required fields
|
||||
authURL, _ := config["authUrl"].(string)
|
||||
clientID, _ := config["clientId"].(string)
|
||||
redirectURI, _ := config["redirectUri"].(string)
|
||||
|
||||
if authURL == "" || clientID == "" || redirectURI == "" {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": "authUrl, clientId, and redirectUri are required",
|
||||
})
|
||||
}
|
||||
|
||||
// Optional fields
|
||||
scope, _ := config["scope"].(string)
|
||||
extraParams, _ := config["extraParams"].(map[string]interface{})
|
||||
|
||||
// Generate PKCE
|
||||
verifier, err := generatePKCEVerifier(64)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": fmt.Sprintf("failed to generate PKCE: %v", err),
|
||||
})
|
||||
}
|
||||
challenge := generatePKCEChallenge(verifier)
|
||||
|
||||
// Store PKCE in auth state
|
||||
extensionAuthStateMu.Lock()
|
||||
state, exists := extensionAuthState[r.extensionID]
|
||||
if !exists {
|
||||
state = &ExtensionAuthState{}
|
||||
extensionAuthState[r.extensionID] = state
|
||||
}
|
||||
state.PKCEVerifier = verifier
|
||||
state.PKCEChallenge = challenge
|
||||
state.AuthCode = "" // Clear any previous auth code
|
||||
extensionAuthStateMu.Unlock()
|
||||
|
||||
// Build OAuth URL with PKCE parameters
|
||||
parsedURL, err := url.Parse(authURL)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": fmt.Sprintf("invalid authUrl: %v", err),
|
||||
})
|
||||
}
|
||||
|
||||
query := parsedURL.Query()
|
||||
query.Set("client_id", clientID)
|
||||
query.Set("redirect_uri", redirectURI)
|
||||
query.Set("response_type", "code")
|
||||
query.Set("code_challenge", challenge)
|
||||
query.Set("code_challenge_method", "S256")
|
||||
|
||||
if scope != "" {
|
||||
query.Set("scope", scope)
|
||||
}
|
||||
|
||||
// Add extra params
|
||||
for k, v := range extraParams {
|
||||
query.Set(k, fmt.Sprintf("%v", v))
|
||||
}
|
||||
|
||||
parsedURL.RawQuery = query.Encode()
|
||||
fullAuthURL := parsedURL.String()
|
||||
|
||||
// Store pending auth request for Flutter
|
||||
pendingAuthRequestsMu.Lock()
|
||||
pendingAuthRequests[r.extensionID] = &PendingAuthRequest{
|
||||
ExtensionID: r.extensionID,
|
||||
AuthURL: fullAuthURL,
|
||||
CallbackURL: redirectURI,
|
||||
}
|
||||
pendingAuthRequestsMu.Unlock()
|
||||
|
||||
GoLog("[Extension:%s] PKCE OAuth started: %s\n", r.extensionID, fullAuthURL)
|
||||
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": true,
|
||||
"authUrl": fullAuthURL,
|
||||
"pkce": map[string]interface{}{
|
||||
"verifier": verifier,
|
||||
"challenge": challenge,
|
||||
"method": "S256",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// authExchangeCodeWithPKCE exchanges auth code for tokens using PKCE
|
||||
// config: { tokenUrl, clientId, redirectUri, code, extraParams }
|
||||
// Uses the stored PKCE verifier automatically
|
||||
func (r *ExtensionRuntime) authExchangeCodeWithPKCE(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": "config object is required",
|
||||
})
|
||||
}
|
||||
|
||||
configObj := call.Arguments[0].Export()
|
||||
config, ok := configObj.(map[string]interface{})
|
||||
if !ok {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": "config must be an object",
|
||||
})
|
||||
}
|
||||
|
||||
// Required fields
|
||||
tokenURL, _ := config["tokenUrl"].(string)
|
||||
clientID, _ := config["clientId"].(string)
|
||||
redirectURI, _ := config["redirectUri"].(string)
|
||||
code, _ := config["code"].(string)
|
||||
|
||||
if tokenURL == "" || clientID == "" || code == "" {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": "tokenUrl, clientId, and code are required",
|
||||
})
|
||||
}
|
||||
|
||||
// Get stored PKCE verifier
|
||||
extensionAuthStateMu.RLock()
|
||||
state, exists := extensionAuthState[r.extensionID]
|
||||
var verifier string
|
||||
if exists {
|
||||
verifier = state.PKCEVerifier
|
||||
}
|
||||
extensionAuthStateMu.RUnlock()
|
||||
|
||||
if verifier == "" {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": "no PKCE verifier found - call generatePKCE or startOAuthWithPKCE first",
|
||||
})
|
||||
}
|
||||
|
||||
// Validate domain
|
||||
if err := r.validateDomain(tokenURL); err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
// Build token request body
|
||||
formData := url.Values{}
|
||||
formData.Set("grant_type", "authorization_code")
|
||||
formData.Set("client_id", clientID)
|
||||
formData.Set("code", code)
|
||||
formData.Set("code_verifier", verifier)
|
||||
if redirectURI != "" {
|
||||
formData.Set("redirect_uri", redirectURI)
|
||||
}
|
||||
|
||||
// Add extra params
|
||||
if extraParams, ok := config["extraParams"].(map[string]interface{}); ok {
|
||||
for k, v := range extraParams {
|
||||
formData.Set(k, fmt.Sprintf("%v", v))
|
||||
}
|
||||
}
|
||||
|
||||
// Make token request
|
||||
req, err := http.NewRequest("POST", tokenURL, strings.NewReader(formData.Encode()))
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("User-Agent", "SpotiFLAC-Extension/1.0")
|
||||
|
||||
resp, err := r.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
// Parse response
|
||||
var tokenResp map[string]interface{}
|
||||
if err := json.Unmarshal(body, &tokenResp); err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": fmt.Sprintf("failed to parse token response: %v", err),
|
||||
"body": string(body),
|
||||
})
|
||||
}
|
||||
|
||||
// Check for error in response
|
||||
if errMsg, ok := tokenResp["error"].(string); ok {
|
||||
errDesc, _ := tokenResp["error_description"].(string)
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": errMsg,
|
||||
"error_description": errDesc,
|
||||
})
|
||||
}
|
||||
|
||||
// Extract tokens
|
||||
accessToken, _ := tokenResp["access_token"].(string)
|
||||
refreshToken, _ := tokenResp["refresh_token"].(string)
|
||||
expiresIn, _ := tokenResp["expires_in"].(float64)
|
||||
|
||||
if accessToken == "" {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": "no access_token in response",
|
||||
"body": string(body),
|
||||
})
|
||||
}
|
||||
|
||||
// Store tokens in auth state
|
||||
extensionAuthStateMu.Lock()
|
||||
state, exists = extensionAuthState[r.extensionID]
|
||||
if !exists {
|
||||
state = &ExtensionAuthState{}
|
||||
extensionAuthState[r.extensionID] = state
|
||||
}
|
||||
state.AccessToken = accessToken
|
||||
state.RefreshToken = refreshToken
|
||||
state.IsAuthenticated = true
|
||||
if expiresIn > 0 {
|
||||
state.ExpiresAt = time.Now().Add(time.Duration(expiresIn) * time.Second)
|
||||
}
|
||||
// Clear PKCE after successful exchange
|
||||
state.PKCEVerifier = ""
|
||||
state.PKCEChallenge = ""
|
||||
extensionAuthStateMu.Unlock()
|
||||
|
||||
GoLog("[Extension:%s] PKCE token exchange successful\n", r.extensionID)
|
||||
|
||||
// Return full token response
|
||||
result := map[string]interface{}{
|
||||
"success": true,
|
||||
"access_token": accessToken,
|
||||
"refresh_token": refreshToken,
|
||||
"token_type": tokenResp["token_type"],
|
||||
}
|
||||
if expiresIn > 0 {
|
||||
result["expires_in"] = expiresIn
|
||||
}
|
||||
// Include any additional fields from response
|
||||
if scope, ok := tokenResp["scope"].(string); ok {
|
||||
result["scope"] = scope
|
||||
}
|
||||
|
||||
return r.vm.ToValue(result)
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
// Package gobackend provides FFmpeg API for extension runtime
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/dop251/goja"
|
||||
)
|
||||
|
||||
// ==================== FFmpeg API (Post-Processing) ====================
|
||||
|
||||
// FFmpegCommand holds a pending FFmpeg command for Flutter to execute
|
||||
type FFmpegCommand struct {
|
||||
ExtensionID string
|
||||
Command string
|
||||
InputPath string
|
||||
OutputPath string
|
||||
Completed bool
|
||||
Success bool
|
||||
Error string
|
||||
Output string
|
||||
}
|
||||
|
||||
// Global FFmpeg command queue
|
||||
var (
|
||||
ffmpegCommands = make(map[string]*FFmpegCommand)
|
||||
ffmpegCommandsMu sync.RWMutex
|
||||
ffmpegCommandID int64
|
||||
)
|
||||
|
||||
// GetPendingFFmpegCommand returns a pending FFmpeg command (called from Flutter)
|
||||
func GetPendingFFmpegCommand(commandID string) *FFmpegCommand {
|
||||
ffmpegCommandsMu.RLock()
|
||||
defer ffmpegCommandsMu.RUnlock()
|
||||
return ffmpegCommands[commandID]
|
||||
}
|
||||
|
||||
// SetFFmpegCommandResult sets the result of an FFmpeg command (called from Flutter)
|
||||
func SetFFmpegCommandResult(commandID string, success bool, output, errorMsg string) {
|
||||
ffmpegCommandsMu.Lock()
|
||||
defer ffmpegCommandsMu.Unlock()
|
||||
if cmd, exists := ffmpegCommands[commandID]; exists {
|
||||
cmd.Completed = true
|
||||
cmd.Success = success
|
||||
cmd.Output = output
|
||||
cmd.Error = errorMsg
|
||||
}
|
||||
}
|
||||
|
||||
// ClearFFmpegCommand removes a completed FFmpeg command
|
||||
func ClearFFmpegCommand(commandID string) {
|
||||
ffmpegCommandsMu.Lock()
|
||||
defer ffmpegCommandsMu.Unlock()
|
||||
delete(ffmpegCommands, commandID)
|
||||
}
|
||||
|
||||
// ffmpegExecute queues an FFmpeg command for execution by Flutter
|
||||
func (r *ExtensionRuntime) ffmpegExecute(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": "command is required",
|
||||
})
|
||||
}
|
||||
|
||||
command := call.Arguments[0].String()
|
||||
|
||||
// Generate unique command ID
|
||||
ffmpegCommandsMu.Lock()
|
||||
ffmpegCommandID++
|
||||
cmdID := fmt.Sprintf("%s_%d", r.extensionID, ffmpegCommandID)
|
||||
ffmpegCommands[cmdID] = &FFmpegCommand{
|
||||
ExtensionID: r.extensionID,
|
||||
Command: command,
|
||||
Completed: false,
|
||||
}
|
||||
ffmpegCommandsMu.Unlock()
|
||||
|
||||
GoLog("[Extension:%s] FFmpeg command queued: %s\n", r.extensionID, cmdID)
|
||||
|
||||
// Wait for completion (with timeout)
|
||||
timeout := 5 * time.Minute
|
||||
start := time.Now()
|
||||
for {
|
||||
ffmpegCommandsMu.RLock()
|
||||
cmd := ffmpegCommands[cmdID]
|
||||
completed := cmd != nil && cmd.Completed
|
||||
ffmpegCommandsMu.RUnlock()
|
||||
|
||||
if completed {
|
||||
ffmpegCommandsMu.RLock()
|
||||
result := map[string]interface{}{
|
||||
"success": cmd.Success,
|
||||
"output": cmd.Output,
|
||||
}
|
||||
if cmd.Error != "" {
|
||||
result["error"] = cmd.Error
|
||||
}
|
||||
ffmpegCommandsMu.RUnlock()
|
||||
|
||||
// Cleanup
|
||||
ClearFFmpegCommand(cmdID)
|
||||
return r.vm.ToValue(result)
|
||||
}
|
||||
|
||||
if time.Since(start) > timeout {
|
||||
ClearFFmpegCommand(cmdID)
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": "FFmpeg command timed out",
|
||||
})
|
||||
}
|
||||
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
// ffmpegGetInfo gets audio file information using FFprobe
|
||||
func (r *ExtensionRuntime) ffmpegGetInfo(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": "file path is required",
|
||||
})
|
||||
}
|
||||
|
||||
filePath := call.Arguments[0].String()
|
||||
|
||||
// Use Go's built-in audio quality function
|
||||
quality, err := GetAudioQuality(filePath)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": true,
|
||||
"bit_depth": quality.BitDepth,
|
||||
"sample_rate": quality.SampleRate,
|
||||
"total_samples": quality.TotalSamples,
|
||||
"duration": float64(quality.TotalSamples) / float64(quality.SampleRate),
|
||||
})
|
||||
}
|
||||
|
||||
// ffmpegConvert is a helper for common conversion operations
|
||||
func (r *ExtensionRuntime) ffmpegConvert(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 2 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": "input and output paths are required",
|
||||
})
|
||||
}
|
||||
|
||||
inputPath := call.Arguments[0].String()
|
||||
outputPath := call.Arguments[1].String()
|
||||
|
||||
// Get options if provided
|
||||
options := map[string]interface{}{}
|
||||
if len(call.Arguments) > 2 && !goja.IsUndefined(call.Arguments[2]) && !goja.IsNull(call.Arguments[2]) {
|
||||
if opts, ok := call.Arguments[2].Export().(map[string]interface{}); ok {
|
||||
options = opts
|
||||
}
|
||||
}
|
||||
|
||||
// Build FFmpeg command
|
||||
var cmdParts []string
|
||||
cmdParts = append(cmdParts, "-i", fmt.Sprintf("%q", inputPath))
|
||||
|
||||
// Audio codec
|
||||
if codec, ok := options["codec"].(string); ok {
|
||||
cmdParts = append(cmdParts, "-c:a", codec)
|
||||
}
|
||||
|
||||
// Bitrate
|
||||
if bitrate, ok := options["bitrate"].(string); ok {
|
||||
cmdParts = append(cmdParts, "-b:a", bitrate)
|
||||
}
|
||||
|
||||
// Sample rate
|
||||
if sampleRate, ok := options["sample_rate"].(float64); ok {
|
||||
cmdParts = append(cmdParts, "-ar", fmt.Sprintf("%d", int(sampleRate)))
|
||||
}
|
||||
|
||||
// Channels
|
||||
if channels, ok := options["channels"].(float64); ok {
|
||||
cmdParts = append(cmdParts, "-ac", fmt.Sprintf("%d", int(channels)))
|
||||
}
|
||||
|
||||
// Overwrite output
|
||||
cmdParts = append(cmdParts, "-y", fmt.Sprintf("%q", outputPath))
|
||||
|
||||
command := strings.Join(cmdParts, " ")
|
||||
|
||||
// Execute via ffmpegExecute
|
||||
execCall := goja.FunctionCall{
|
||||
Arguments: []goja.Value{r.vm.ToValue(command)},
|
||||
}
|
||||
return r.ffmpegExecute(execCall)
|
||||
}
|
||||
@@ -0,0 +1,467 @@
|
||||
// Package gobackend provides File API for extension runtime
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/dop251/goja"
|
||||
)
|
||||
|
||||
// ==================== File API (Sandboxed) ====================
|
||||
|
||||
// validatePath checks if the path is within the extension's data directory
|
||||
// For absolute paths (from download queue), it allows them if they're valid
|
||||
func (r *ExtensionRuntime) validatePath(path string) (string, error) {
|
||||
// Clean and resolve the path
|
||||
cleanPath := filepath.Clean(path)
|
||||
|
||||
// If path is absolute, allow it (for download queue paths)
|
||||
// This is safe because the Go backend controls what paths are passed
|
||||
if filepath.IsAbs(cleanPath) {
|
||||
return cleanPath, nil
|
||||
}
|
||||
|
||||
// For relative paths, join with data directory
|
||||
fullPath := filepath.Join(r.dataDir, cleanPath)
|
||||
|
||||
// Resolve to absolute path
|
||||
absPath, err := filepath.Abs(fullPath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("invalid path: %w", err)
|
||||
}
|
||||
|
||||
// Ensure path is within data directory
|
||||
absDataDir, _ := filepath.Abs(r.dataDir)
|
||||
if !strings.HasPrefix(absPath, absDataDir) {
|
||||
return "", fmt.Errorf("file access denied: path '%s' is outside sandbox", path)
|
||||
}
|
||||
|
||||
return absPath, nil
|
||||
}
|
||||
|
||||
// fileDownload downloads a file from URL to the specified path
|
||||
// Supports progress callback via options.onProgress
|
||||
func (r *ExtensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 2 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": "URL and output path are required",
|
||||
})
|
||||
}
|
||||
|
||||
urlStr := call.Arguments[0].String()
|
||||
outputPath := call.Arguments[1].String()
|
||||
|
||||
// Validate domain
|
||||
if err := r.validateDomain(urlStr); err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
// Validate output path (allows absolute paths for download queue)
|
||||
fullPath, err := r.validatePath(outputPath)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
// Get options if provided
|
||||
var onProgress goja.Callable
|
||||
var headers map[string]string
|
||||
if len(call.Arguments) > 2 && !goja.IsUndefined(call.Arguments[2]) && !goja.IsNull(call.Arguments[2]) {
|
||||
optionsObj := call.Arguments[2].Export()
|
||||
if opts, ok := optionsObj.(map[string]interface{}); ok {
|
||||
// Extract headers
|
||||
if h, ok := opts["headers"].(map[string]interface{}); ok {
|
||||
headers = make(map[string]string)
|
||||
for k, v := range h {
|
||||
headers[k] = fmt.Sprintf("%v", v)
|
||||
}
|
||||
}
|
||||
// Extract onProgress callback
|
||||
if progressVal, ok := opts["onProgress"]; ok {
|
||||
if callable, ok := goja.AssertFunction(r.vm.ToValue(progressVal)); ok {
|
||||
onProgress = callable
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create directory if needed
|
||||
dir := filepath.Dir(fullPath)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": fmt.Sprintf("failed to create directory: %v", err),
|
||||
})
|
||||
}
|
||||
|
||||
// Create HTTP request
|
||||
req, err := http.NewRequest("GET", urlStr, nil)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
// Set headers
|
||||
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")
|
||||
}
|
||||
|
||||
// Download file
|
||||
resp, err := r.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": fmt.Sprintf("HTTP error: %d", resp.StatusCode),
|
||||
})
|
||||
}
|
||||
|
||||
// Create output file
|
||||
out, err := os.Create(fullPath)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": fmt.Sprintf("failed to create file: %v", err),
|
||||
})
|
||||
}
|
||||
defer out.Close()
|
||||
|
||||
// Get content length for progress
|
||||
contentLength := resp.ContentLength
|
||||
|
||||
// Copy content with progress reporting
|
||||
var written int64
|
||||
buf := make([]byte, 32*1024) // 32KB buffer
|
||||
for {
|
||||
nr, er := resp.Body.Read(buf)
|
||||
if nr > 0 {
|
||||
nw, ew := out.Write(buf[0:nr])
|
||||
if nw < 0 || nr < nw {
|
||||
nw = 0
|
||||
if ew == nil {
|
||||
ew = fmt.Errorf("invalid write result")
|
||||
}
|
||||
}
|
||||
written += int64(nw)
|
||||
if ew != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": fmt.Sprintf("failed to write file: %v", ew),
|
||||
})
|
||||
}
|
||||
if nr != nw {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": "short write",
|
||||
})
|
||||
}
|
||||
|
||||
// Report progress
|
||||
if onProgress != nil && contentLength > 0 {
|
||||
_, _ = onProgress(goja.Undefined(), r.vm.ToValue(written), r.vm.ToValue(contentLength))
|
||||
}
|
||||
}
|
||||
if er != nil {
|
||||
if er != io.EOF {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": fmt.Sprintf("failed to read response: %v", er),
|
||||
})
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
GoLog("[Extension:%s] Downloaded %d bytes to %s\n", r.extensionID, written, fullPath)
|
||||
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": true,
|
||||
"path": fullPath,
|
||||
"size": written,
|
||||
})
|
||||
}
|
||||
|
||||
// fileExists checks if a file exists in the sandbox
|
||||
func (r *ExtensionRuntime) fileExists(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue(false)
|
||||
}
|
||||
|
||||
path := call.Arguments[0].String()
|
||||
fullPath, err := r.validatePath(path)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(false)
|
||||
}
|
||||
|
||||
_, err = os.Stat(fullPath)
|
||||
return r.vm.ToValue(err == nil)
|
||||
}
|
||||
|
||||
// fileDelete deletes a file in the sandbox
|
||||
func (r *ExtensionRuntime) fileDelete(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": "path is required",
|
||||
})
|
||||
}
|
||||
|
||||
path := call.Arguments[0].String()
|
||||
fullPath, err := r.validatePath(path)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
if err := os.Remove(fullPath); err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": true,
|
||||
})
|
||||
}
|
||||
|
||||
// fileRead reads a file from the sandbox
|
||||
func (r *ExtensionRuntime) fileRead(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": "path is required",
|
||||
})
|
||||
}
|
||||
|
||||
path := call.Arguments[0].String()
|
||||
fullPath, err := r.validatePath(path)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(fullPath)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": true,
|
||||
"data": string(data),
|
||||
})
|
||||
}
|
||||
|
||||
// fileWrite writes data to a file in the sandbox
|
||||
func (r *ExtensionRuntime) fileWrite(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 2 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": "path and data are required",
|
||||
})
|
||||
}
|
||||
|
||||
path := call.Arguments[0].String()
|
||||
data := call.Arguments[1].String()
|
||||
|
||||
fullPath, err := r.validatePath(path)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
// Create directory if needed
|
||||
dir := filepath.Dir(fullPath)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": fmt.Sprintf("failed to create directory: %v", err),
|
||||
})
|
||||
}
|
||||
|
||||
if err := os.WriteFile(fullPath, []byte(data), 0644); err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": true,
|
||||
"path": fullPath,
|
||||
})
|
||||
}
|
||||
|
||||
// fileCopy copies a file within the sandbox
|
||||
func (r *ExtensionRuntime) fileCopy(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 2 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": "source and destination paths are required",
|
||||
})
|
||||
}
|
||||
|
||||
srcPath := call.Arguments[0].String()
|
||||
dstPath := call.Arguments[1].String()
|
||||
|
||||
fullSrc, err := r.validatePath(srcPath)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
fullDst, err := r.validatePath(dstPath)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
// Read source file
|
||||
data, err := os.ReadFile(fullSrc)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": fmt.Sprintf("failed to read source: %v", err),
|
||||
})
|
||||
}
|
||||
|
||||
// Create destination directory if needed
|
||||
dir := filepath.Dir(fullDst)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": fmt.Sprintf("failed to create directory: %v", err),
|
||||
})
|
||||
}
|
||||
|
||||
// Write to destination
|
||||
if err := os.WriteFile(fullDst, data, 0644); err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": fmt.Sprintf("failed to write destination: %v", err),
|
||||
})
|
||||
}
|
||||
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": true,
|
||||
"path": fullDst,
|
||||
})
|
||||
}
|
||||
|
||||
// fileMove moves/renames a file within the sandbox
|
||||
func (r *ExtensionRuntime) fileMove(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 2 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": "source and destination paths are required",
|
||||
})
|
||||
}
|
||||
|
||||
srcPath := call.Arguments[0].String()
|
||||
dstPath := call.Arguments[1].String()
|
||||
|
||||
fullSrc, err := r.validatePath(srcPath)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
fullDst, err := r.validatePath(dstPath)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
// Create destination directory if needed
|
||||
dir := filepath.Dir(fullDst)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": fmt.Sprintf("failed to create directory: %v", err),
|
||||
})
|
||||
}
|
||||
|
||||
if err := os.Rename(fullSrc, fullDst); err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": fmt.Sprintf("failed to move file: %v", err),
|
||||
})
|
||||
}
|
||||
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": true,
|
||||
"path": fullDst,
|
||||
})
|
||||
}
|
||||
|
||||
// fileGetSize returns the size of a file in bytes
|
||||
func (r *ExtensionRuntime) fileGetSize(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": "path is required",
|
||||
})
|
||||
}
|
||||
|
||||
path := call.Arguments[0].String()
|
||||
fullPath, err := r.validatePath(path)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
info, err := os.Stat(fullPath)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": true,
|
||||
"size": info.Size(),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,499 @@
|
||||
// Package gobackend provides HTTP API for extension runtime
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/dop251/goja"
|
||||
)
|
||||
|
||||
// ==================== HTTP API (Sandboxed) ====================
|
||||
|
||||
// HTTPResponse represents the response from an HTTP request
|
||||
type HTTPResponse struct {
|
||||
StatusCode int `json:"statusCode"`
|
||||
Body string `json:"body"`
|
||||
Headers map[string]string `json:"headers"`
|
||||
}
|
||||
|
||||
// validateDomain checks if the domain is allowed by the extension's permissions
|
||||
func (r *ExtensionRuntime) validateDomain(urlStr string) error {
|
||||
parsed, err := url.Parse(urlStr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid URL: %w", err)
|
||||
}
|
||||
|
||||
domain := parsed.Hostname()
|
||||
if !r.manifest.IsDomainAllowed(domain) {
|
||||
return fmt.Errorf("network access denied: domain '%s' not in allowed list", domain)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// httpGet performs a GET request (sandboxed)
|
||||
func (r *ExtensionRuntime) httpGet(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(),
|
||||
})
|
||||
}
|
||||
|
||||
// Get headers if provided
|
||||
headers := make(map[string]string)
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create request
|
||||
req, err := http.NewRequest("GET", urlStr, nil)
|
||||
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 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)
|
||||
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 (cookies, etc.)
|
||||
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 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,
|
||||
})
|
||||
}
|
||||
|
||||
// httpPost performs a POST request (sandboxed)
|
||||
func (r *ExtensionRuntime) httpPost(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(),
|
||||
})
|
||||
}
|
||||
|
||||
// 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]) {
|
||||
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
|
||||
headers := make(map[string]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
|
||||
req, err := http.NewRequest("POST", urlStr, strings.NewReader(bodyStr))
|
||||
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 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 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)
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
// Package gobackend provides Track Matching API for extension runtime
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/dop251/goja"
|
||||
)
|
||||
|
||||
// ==================== Track Matching API ====================
|
||||
|
||||
// matchingCompareStrings compares two strings with fuzzy matching
|
||||
func (r *ExtensionRuntime) matchingCompareStrings(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 2 {
|
||||
return r.vm.ToValue(0.0)
|
||||
}
|
||||
|
||||
str1 := strings.ToLower(strings.TrimSpace(call.Arguments[0].String()))
|
||||
str2 := strings.ToLower(strings.TrimSpace(call.Arguments[1].String()))
|
||||
|
||||
if str1 == str2 {
|
||||
return r.vm.ToValue(1.0)
|
||||
}
|
||||
|
||||
// Calculate Levenshtein distance-based similarity
|
||||
similarity := calculateStringSimilarity(str1, str2)
|
||||
return r.vm.ToValue(similarity)
|
||||
}
|
||||
|
||||
// matchingCompareDuration compares two durations with tolerance
|
||||
func (r *ExtensionRuntime) matchingCompareDuration(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 2 {
|
||||
return r.vm.ToValue(false)
|
||||
}
|
||||
|
||||
dur1 := int(call.Arguments[0].ToInteger())
|
||||
dur2 := int(call.Arguments[1].ToInteger())
|
||||
|
||||
// Default tolerance: 3 seconds
|
||||
tolerance := 3000 // milliseconds
|
||||
if len(call.Arguments) > 2 && !goja.IsUndefined(call.Arguments[2]) {
|
||||
tolerance = int(call.Arguments[2].ToInteger())
|
||||
}
|
||||
|
||||
diff := dur1 - dur2
|
||||
if diff < 0 {
|
||||
diff = -diff
|
||||
}
|
||||
|
||||
return r.vm.ToValue(diff <= tolerance)
|
||||
}
|
||||
|
||||
// matchingNormalizeString normalizes a string for comparison
|
||||
func (r *ExtensionRuntime) matchingNormalizeString(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue("")
|
||||
}
|
||||
|
||||
str := call.Arguments[0].String()
|
||||
normalized := normalizeStringForMatching(str)
|
||||
return r.vm.ToValue(normalized)
|
||||
}
|
||||
|
||||
// calculateStringSimilarity calculates similarity between two strings (0-1)
|
||||
func calculateStringSimilarity(s1, s2 string) float64 {
|
||||
if len(s1) == 0 && len(s2) == 0 {
|
||||
return 1.0
|
||||
}
|
||||
if len(s1) == 0 || len(s2) == 0 {
|
||||
return 0.0
|
||||
}
|
||||
|
||||
// Use Levenshtein distance
|
||||
distance := levenshteinDistance(s1, s2)
|
||||
maxLen := len(s1)
|
||||
if len(s2) > maxLen {
|
||||
maxLen = len(s2)
|
||||
}
|
||||
|
||||
return 1.0 - float64(distance)/float64(maxLen)
|
||||
}
|
||||
|
||||
// levenshteinDistance calculates the Levenshtein distance between two strings
|
||||
func levenshteinDistance(s1, s2 string) int {
|
||||
if len(s1) == 0 {
|
||||
return len(s2)
|
||||
}
|
||||
if len(s2) == 0 {
|
||||
return len(s1)
|
||||
}
|
||||
|
||||
// Create matrix
|
||||
matrix := make([][]int, len(s1)+1)
|
||||
for i := range matrix {
|
||||
matrix[i] = make([]int, len(s2)+1)
|
||||
matrix[i][0] = i
|
||||
}
|
||||
for j := range matrix[0] {
|
||||
matrix[0][j] = j
|
||||
}
|
||||
|
||||
// Fill matrix
|
||||
for i := 1; i <= len(s1); i++ {
|
||||
for j := 1; j <= len(s2); j++ {
|
||||
cost := 1
|
||||
if s1[i-1] == s2[j-1] {
|
||||
cost = 0
|
||||
}
|
||||
matrix[i][j] = min(
|
||||
matrix[i-1][j]+1, // deletion
|
||||
matrix[i][j-1]+1, // insertion
|
||||
matrix[i-1][j-1]+cost, // substitution
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return matrix[len(s1)][len(s2)]
|
||||
}
|
||||
|
||||
// normalizeStringForMatching normalizes a string for comparison
|
||||
func normalizeStringForMatching(s string) string {
|
||||
// Convert to lowercase
|
||||
s = strings.ToLower(s)
|
||||
|
||||
// Remove common suffixes/prefixes
|
||||
suffixes := []string{
|
||||
" (remastered)", " (remaster)", " - remastered", " - remaster",
|
||||
" (deluxe)", " (deluxe edition)", " - deluxe", " - deluxe edition",
|
||||
" (explicit)", " (clean)", " [explicit]", " [clean]",
|
||||
" (album version)", " (single version)", " (radio edit)",
|
||||
" (feat.", " (ft.", " feat.", " ft.",
|
||||
}
|
||||
for _, suffix := range suffixes {
|
||||
if idx := strings.Index(s, suffix); idx != -1 {
|
||||
s = s[:idx]
|
||||
}
|
||||
}
|
||||
|
||||
// Remove special characters
|
||||
var result strings.Builder
|
||||
for _, r := range s {
|
||||
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == ' ' {
|
||||
result.WriteRune(r)
|
||||
}
|
||||
}
|
||||
|
||||
// Collapse multiple spaces
|
||||
s = strings.Join(strings.Fields(result.String()), " ")
|
||||
|
||||
return strings.TrimSpace(s)
|
||||
}
|
||||
@@ -0,0 +1,488 @@
|
||||
// Package gobackend provides Browser-like Polyfills for extension runtime
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/dop251/goja"
|
||||
)
|
||||
|
||||
// ==================== 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)
|
||||
}
|
||||
@@ -0,0 +1,339 @@
|
||||
// Package gobackend provides Storage and Credentials API for extension runtime
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/dop251/goja"
|
||||
)
|
||||
|
||||
// ==================== Storage API ====================
|
||||
|
||||
// getStoragePath returns the path to the extension's storage file
|
||||
func (r *ExtensionRuntime) getStoragePath() string {
|
||||
return filepath.Join(r.dataDir, "storage.json")
|
||||
}
|
||||
|
||||
// loadStorage loads the storage data from disk
|
||||
func (r *ExtensionRuntime) loadStorage() (map[string]interface{}, error) {
|
||||
storagePath := r.getStoragePath()
|
||||
data, err := os.ReadFile(storagePath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return make(map[string]interface{}), nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var storage map[string]interface{}
|
||||
if err := json.Unmarshal(data, &storage); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return storage, nil
|
||||
}
|
||||
|
||||
// saveStorage saves the storage data to disk
|
||||
func (r *ExtensionRuntime) saveStorage(storage map[string]interface{}) error {
|
||||
storagePath := r.getStoragePath()
|
||||
data, err := json.MarshalIndent(storage, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return os.WriteFile(storagePath, data, 0644)
|
||||
}
|
||||
|
||||
// storageGet retrieves a value from storage
|
||||
func (r *ExtensionRuntime) storageGet(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return goja.Undefined()
|
||||
}
|
||||
|
||||
key := call.Arguments[0].String()
|
||||
|
||||
storage, err := r.loadStorage()
|
||||
if err != nil {
|
||||
GoLog("[Extension:%s] Storage load error: %v\n", r.extensionID, err)
|
||||
return goja.Undefined()
|
||||
}
|
||||
|
||||
value, exists := storage[key]
|
||||
if !exists {
|
||||
// Return default value if provided
|
||||
if len(call.Arguments) > 1 {
|
||||
return call.Arguments[1]
|
||||
}
|
||||
return goja.Undefined()
|
||||
}
|
||||
|
||||
return r.vm.ToValue(value)
|
||||
}
|
||||
|
||||
// storageSet stores a value in storage
|
||||
func (r *ExtensionRuntime) storageSet(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 2 {
|
||||
return r.vm.ToValue(false)
|
||||
}
|
||||
|
||||
key := call.Arguments[0].String()
|
||||
value := call.Arguments[1].Export()
|
||||
|
||||
storage, err := r.loadStorage()
|
||||
if err != nil {
|
||||
GoLog("[Extension:%s] Storage load error: %v\n", r.extensionID, err)
|
||||
return r.vm.ToValue(false)
|
||||
}
|
||||
|
||||
storage[key] = value
|
||||
|
||||
if err := r.saveStorage(storage); err != nil {
|
||||
GoLog("[Extension:%s] Storage save error: %v\n", r.extensionID, err)
|
||||
return r.vm.ToValue(false)
|
||||
}
|
||||
|
||||
return r.vm.ToValue(true)
|
||||
}
|
||||
|
||||
// storageRemove removes a value from storage
|
||||
func (r *ExtensionRuntime) storageRemove(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue(false)
|
||||
}
|
||||
|
||||
key := call.Arguments[0].String()
|
||||
|
||||
storage, err := r.loadStorage()
|
||||
if err != nil {
|
||||
GoLog("[Extension:%s] Storage load error: %v\n", r.extensionID, err)
|
||||
return r.vm.ToValue(false)
|
||||
}
|
||||
|
||||
delete(storage, key)
|
||||
|
||||
if err := r.saveStorage(storage); err != nil {
|
||||
GoLog("[Extension:%s] Storage save error: %v\n", r.extensionID, err)
|
||||
return r.vm.ToValue(false)
|
||||
}
|
||||
|
||||
return r.vm.ToValue(true)
|
||||
}
|
||||
|
||||
// ==================== Credentials API (Encrypted Storage) ====================
|
||||
|
||||
// getCredentialsPath returns the path to the extension's encrypted credentials file
|
||||
func (r *ExtensionRuntime) getCredentialsPath() string {
|
||||
return filepath.Join(r.dataDir, ".credentials.enc")
|
||||
}
|
||||
|
||||
// getEncryptionKey derives an encryption key from extension ID
|
||||
func (r *ExtensionRuntime) getEncryptionKey() []byte {
|
||||
// Use SHA256 of extension ID + salt as encryption key
|
||||
salt := "spotiflac-ext-cred-v1"
|
||||
hash := sha256.Sum256([]byte(r.extensionID + salt))
|
||||
return hash[:]
|
||||
}
|
||||
|
||||
// loadCredentials loads and decrypts credentials from disk
|
||||
func (r *ExtensionRuntime) loadCredentials() (map[string]interface{}, error) {
|
||||
credPath := r.getCredentialsPath()
|
||||
data, err := os.ReadFile(credPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return make(map[string]interface{}), nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Decrypt the data
|
||||
key := r.getEncryptionKey()
|
||||
decrypted, err := decryptAES(data, key)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to decrypt credentials: %w", err)
|
||||
}
|
||||
|
||||
var creds map[string]interface{}
|
||||
if err := json.Unmarshal(decrypted, &creds); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return creds, nil
|
||||
}
|
||||
|
||||
// saveCredentials encrypts and saves credentials to disk
|
||||
func (r *ExtensionRuntime) saveCredentials(creds map[string]interface{}) error {
|
||||
data, err := json.Marshal(creds)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Encrypt the data
|
||||
key := r.getEncryptionKey()
|
||||
encrypted, err := encryptAES(data, key)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to encrypt credentials: %w", err)
|
||||
}
|
||||
|
||||
credPath := r.getCredentialsPath()
|
||||
return os.WriteFile(credPath, encrypted, 0600) // Restrictive permissions
|
||||
}
|
||||
|
||||
// credentialsStore stores an encrypted credential
|
||||
func (r *ExtensionRuntime) credentialsStore(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 2 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": "key and value are required",
|
||||
})
|
||||
}
|
||||
|
||||
key := call.Arguments[0].String()
|
||||
value := call.Arguments[1].Export()
|
||||
|
||||
creds, err := r.loadCredentials()
|
||||
if err != nil {
|
||||
GoLog("[Extension:%s] Credentials load error: %v\n", r.extensionID, err)
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
creds[key] = value
|
||||
|
||||
if err := r.saveCredentials(creds); err != nil {
|
||||
GoLog("[Extension:%s] Credentials save error: %v\n", r.extensionID, err)
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": true,
|
||||
})
|
||||
}
|
||||
|
||||
// credentialsGet retrieves a decrypted credential
|
||||
func (r *ExtensionRuntime) credentialsGet(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return goja.Undefined()
|
||||
}
|
||||
|
||||
key := call.Arguments[0].String()
|
||||
|
||||
creds, err := r.loadCredentials()
|
||||
if err != nil {
|
||||
GoLog("[Extension:%s] Credentials load error: %v\n", r.extensionID, err)
|
||||
return goja.Undefined()
|
||||
}
|
||||
|
||||
value, exists := creds[key]
|
||||
if !exists {
|
||||
// Return default value if provided
|
||||
if len(call.Arguments) > 1 {
|
||||
return call.Arguments[1]
|
||||
}
|
||||
return goja.Undefined()
|
||||
}
|
||||
|
||||
return r.vm.ToValue(value)
|
||||
}
|
||||
|
||||
// credentialsRemove removes a credential
|
||||
func (r *ExtensionRuntime) credentialsRemove(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue(false)
|
||||
}
|
||||
|
||||
key := call.Arguments[0].String()
|
||||
|
||||
creds, err := r.loadCredentials()
|
||||
if err != nil {
|
||||
GoLog("[Extension:%s] Credentials load error: %v\n", r.extensionID, err)
|
||||
return r.vm.ToValue(false)
|
||||
}
|
||||
|
||||
delete(creds, key)
|
||||
|
||||
if err := r.saveCredentials(creds); err != nil {
|
||||
GoLog("[Extension:%s] Credentials save error: %v\n", r.extensionID, err)
|
||||
return r.vm.ToValue(false)
|
||||
}
|
||||
|
||||
return r.vm.ToValue(true)
|
||||
}
|
||||
|
||||
// credentialsHas checks if a credential exists
|
||||
func (r *ExtensionRuntime) credentialsHas(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue(false)
|
||||
}
|
||||
|
||||
key := call.Arguments[0].String()
|
||||
|
||||
creds, err := r.loadCredentials()
|
||||
if err != nil {
|
||||
return r.vm.ToValue(false)
|
||||
}
|
||||
|
||||
_, exists := creds[key]
|
||||
return r.vm.ToValue(exists)
|
||||
}
|
||||
|
||||
// ==================== Crypto Utilities ====================
|
||||
|
||||
// encryptAES encrypts data using AES-GCM
|
||||
func encryptAES(plaintext []byte, key []byte) ([]byte, error) {
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
nonce := make([]byte, gcm.NonceSize())
|
||||
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ciphertext := gcm.Seal(nonce, nonce, plaintext, nil)
|
||||
return ciphertext, nil
|
||||
}
|
||||
|
||||
// decryptAES decrypts data using AES-GCM
|
||||
func decryptAES(ciphertext []byte, key []byte) ([]byte, error) {
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
nonceSize := gcm.NonceSize()
|
||||
if len(ciphertext) < nonceSize {
|
||||
return nil, fmt.Errorf("ciphertext too short")
|
||||
}
|
||||
|
||||
nonce, ciphertext := ciphertext[:nonceSize], ciphertext[nonceSize:]
|
||||
plaintext, err := gcm.Open(nil, nonce, ciphertext, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return plaintext, nil
|
||||
}
|
||||
@@ -0,0 +1,372 @@
|
||||
// Package gobackend provides Utility functions for extension runtime
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/md5"
|
||||
"crypto/rand"
|
||||
"crypto/sha1"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/dop251/goja"
|
||||
)
|
||||
|
||||
// ==================== Utility Functions ====================
|
||||
|
||||
// base64Encode encodes a string to base64
|
||||
func (r *ExtensionRuntime) base64Encode(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)))
|
||||
}
|
||||
|
||||
// base64Decode decodes a base64 string
|
||||
func (r *ExtensionRuntime) base64Decode(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 {
|
||||
return r.vm.ToValue("")
|
||||
}
|
||||
return r.vm.ToValue(string(decoded))
|
||||
}
|
||||
|
||||
// md5Hash computes MD5 hash of a string
|
||||
func (r *ExtensionRuntime) md5Hash(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue("")
|
||||
}
|
||||
input := call.Arguments[0].String()
|
||||
hash := md5.Sum([]byte(input))
|
||||
return r.vm.ToValue(hex.EncodeToString(hash[:]))
|
||||
}
|
||||
|
||||
// sha256Hash computes SHA256 hash of a string
|
||||
func (r *ExtensionRuntime) sha256Hash(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue("")
|
||||
}
|
||||
input := call.Arguments[0].String()
|
||||
hash := sha256.Sum256([]byte(input))
|
||||
return r.vm.ToValue(hex.EncodeToString(hash[:]))
|
||||
}
|
||||
|
||||
// hmacSHA256 computes HMAC-SHA256 of a message with a key
|
||||
func (r *ExtensionRuntime) hmacSHA256(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 2 {
|
||||
return r.vm.ToValue("")
|
||||
}
|
||||
message := call.Arguments[0].String()
|
||||
key := call.Arguments[1].String()
|
||||
|
||||
mac := hmac.New(sha256.New, []byte(key))
|
||||
mac.Write([]byte(message))
|
||||
return r.vm.ToValue(hex.EncodeToString(mac.Sum(nil)))
|
||||
}
|
||||
|
||||
// hmacSHA256Base64 computes HMAC-SHA256 and returns base64 encoded result
|
||||
func (r *ExtensionRuntime) hmacSHA256Base64(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 2 {
|
||||
return r.vm.ToValue("")
|
||||
}
|
||||
message := call.Arguments[0].String()
|
||||
key := call.Arguments[1].String()
|
||||
|
||||
mac := hmac.New(sha256.New, []byte(key))
|
||||
mac.Write([]byte(message))
|
||||
return r.vm.ToValue(base64.StdEncoding.EncodeToString(mac.Sum(nil)))
|
||||
}
|
||||
|
||||
// hmacSHA1 computes HMAC-SHA1 of a message with a key (for TOTP)
|
||||
// Arguments: message (string or array of bytes), key (string or array of bytes)
|
||||
// Returns: array of bytes (for TOTP dynamic truncation)
|
||||
func (r *ExtensionRuntime) hmacSHA1(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 2 {
|
||||
return r.vm.ToValue([]byte{})
|
||||
}
|
||||
|
||||
// Get key - can be string or array of bytes
|
||||
var keyBytes []byte
|
||||
keyArg := call.Arguments[0].Export()
|
||||
switch k := keyArg.(type) {
|
||||
case string:
|
||||
keyBytes = []byte(k)
|
||||
case []interface{}:
|
||||
keyBytes = make([]byte, len(k))
|
||||
for i, v := range k {
|
||||
if num, ok := v.(int64); ok {
|
||||
keyBytes[i] = byte(num)
|
||||
} else if num, ok := v.(float64); ok {
|
||||
keyBytes[i] = byte(int(num))
|
||||
}
|
||||
}
|
||||
default:
|
||||
return r.vm.ToValue([]byte{})
|
||||
}
|
||||
|
||||
// Get message - can be string or array of bytes
|
||||
var msgBytes []byte
|
||||
msgArg := call.Arguments[1].Export()
|
||||
switch m := msgArg.(type) {
|
||||
case string:
|
||||
msgBytes = []byte(m)
|
||||
case []interface{}:
|
||||
msgBytes = make([]byte, len(m))
|
||||
for i, v := range m {
|
||||
if num, ok := v.(int64); ok {
|
||||
msgBytes[i] = byte(num)
|
||||
} else if num, ok := v.(float64); ok {
|
||||
msgBytes[i] = byte(int(num))
|
||||
}
|
||||
}
|
||||
default:
|
||||
return r.vm.ToValue([]byte{})
|
||||
}
|
||||
|
||||
mac := hmac.New(sha1.New, keyBytes)
|
||||
mac.Write(msgBytes)
|
||||
result := mac.Sum(nil)
|
||||
|
||||
// Convert to array of numbers for JavaScript
|
||||
jsArray := make([]interface{}, len(result))
|
||||
for i, b := range result {
|
||||
jsArray[i] = int(b)
|
||||
}
|
||||
return r.vm.ToValue(jsArray)
|
||||
}
|
||||
|
||||
// parseJSON parses a JSON string
|
||||
func (r *ExtensionRuntime) parseJSON(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return goja.Undefined()
|
||||
}
|
||||
input := call.Arguments[0].String()
|
||||
|
||||
var result interface{}
|
||||
if err := json.Unmarshal([]byte(input), &result); err != nil {
|
||||
GoLog("[Extension:%s] JSON parse error: %v\n", r.extensionID, err)
|
||||
return goja.Undefined()
|
||||
}
|
||||
|
||||
return r.vm.ToValue(result)
|
||||
}
|
||||
|
||||
// stringifyJSON converts a value to JSON string
|
||||
func (r *ExtensionRuntime) stringifyJSON(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue("")
|
||||
}
|
||||
input := call.Arguments[0].Export()
|
||||
|
||||
data, err := json.Marshal(input)
|
||||
if err != nil {
|
||||
GoLog("[Extension:%s] JSON stringify error: %v\n", r.extensionID, err)
|
||||
return r.vm.ToValue("")
|
||||
}
|
||||
|
||||
return r.vm.ToValue(string(data))
|
||||
}
|
||||
|
||||
// ==================== Crypto Utilities for Extensions ====================
|
||||
|
||||
// cryptoEncrypt encrypts a string using AES-GCM (for extension use)
|
||||
func (r *ExtensionRuntime) cryptoEncrypt(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 2 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": "plaintext and key are required",
|
||||
})
|
||||
}
|
||||
|
||||
plaintext := call.Arguments[0].String()
|
||||
keyStr := call.Arguments[1].String()
|
||||
|
||||
// Derive 32-byte key from provided key string
|
||||
keyHash := sha256.Sum256([]byte(keyStr))
|
||||
|
||||
encrypted, err := encryptAES([]byte(plaintext), keyHash[:])
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": true,
|
||||
"data": base64.StdEncoding.EncodeToString(encrypted),
|
||||
})
|
||||
}
|
||||
|
||||
// cryptoDecrypt decrypts a string using AES-GCM (for extension use)
|
||||
func (r *ExtensionRuntime) cryptoDecrypt(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 2 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": "ciphertext and key are required",
|
||||
})
|
||||
}
|
||||
|
||||
ciphertextB64 := call.Arguments[0].String()
|
||||
keyStr := call.Arguments[1].String()
|
||||
|
||||
ciphertext, err := base64.StdEncoding.DecodeString(ciphertextB64)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": "invalid base64 ciphertext",
|
||||
})
|
||||
}
|
||||
|
||||
// Derive 32-byte key from provided key string
|
||||
keyHash := sha256.Sum256([]byte(keyStr))
|
||||
|
||||
decrypted, err := decryptAES(ciphertext, keyHash[:])
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": true,
|
||||
"data": string(decrypted),
|
||||
})
|
||||
}
|
||||
|
||||
// cryptoGenerateKey generates a random encryption key
|
||||
func (r *ExtensionRuntime) cryptoGenerateKey(call goja.FunctionCall) goja.Value {
|
||||
length := 32 // Default 256-bit key
|
||||
if len(call.Arguments) > 0 && !goja.IsUndefined(call.Arguments[0]) {
|
||||
if l, ok := call.Arguments[0].Export().(float64); ok {
|
||||
length = int(l)
|
||||
}
|
||||
}
|
||||
|
||||
key := make([]byte, length)
|
||||
if _, err := rand.Read(key); err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": true,
|
||||
"key": base64.StdEncoding.EncodeToString(key),
|
||||
"hex": hex.EncodeToString(key),
|
||||
})
|
||||
}
|
||||
|
||||
// ==================== Logging Functions ====================
|
||||
|
||||
func (r *ExtensionRuntime) logDebug(call goja.FunctionCall) goja.Value {
|
||||
msg := r.formatLogArgs(call.Arguments)
|
||||
GoLog("[Extension:%s:DEBUG] %s\n", r.extensionID, msg)
|
||||
return goja.Undefined()
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) logInfo(call goja.FunctionCall) goja.Value {
|
||||
msg := r.formatLogArgs(call.Arguments)
|
||||
GoLog("[Extension:%s:INFO] %s\n", r.extensionID, msg)
|
||||
return goja.Undefined()
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) logWarn(call goja.FunctionCall) goja.Value {
|
||||
msg := r.formatLogArgs(call.Arguments)
|
||||
GoLog("[Extension:%s:WARN] %s\n", r.extensionID, msg)
|
||||
return goja.Undefined()
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) logError(call goja.FunctionCall) goja.Value {
|
||||
msg := r.formatLogArgs(call.Arguments)
|
||||
GoLog("[Extension:%s:ERROR] %s\n", r.extensionID, msg)
|
||||
return goja.Undefined()
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) formatLogArgs(args []goja.Value) string {
|
||||
parts := make([]string, len(args))
|
||||
for i, arg := range args {
|
||||
parts[i] = fmt.Sprintf("%v", arg.Export())
|
||||
}
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
|
||||
// ==================== Go Backend Wrappers ====================
|
||||
|
||||
func (r *ExtensionRuntime) sanitizeFilenameWrapper(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue("")
|
||||
}
|
||||
input := call.Arguments[0].String()
|
||||
return r.vm.ToValue(sanitizeFilename(input))
|
||||
}
|
||||
|
||||
// RegisterGoBackendAPIs adds more Go backend functions to the VM
|
||||
func (r *ExtensionRuntime) RegisterGoBackendAPIs(vm *goja.Runtime) {
|
||||
gobackendObj := vm.Get("gobackend")
|
||||
if gobackendObj == nil || goja.IsUndefined(gobackendObj) {
|
||||
gobackendObj = vm.NewObject()
|
||||
vm.Set("gobackend", gobackendObj)
|
||||
}
|
||||
|
||||
obj := gobackendObj.(*goja.Object)
|
||||
|
||||
// Expose sanitizeFilename
|
||||
obj.Set("sanitizeFilename", func(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return vm.ToValue("")
|
||||
}
|
||||
return vm.ToValue(sanitizeFilename(call.Arguments[0].String()))
|
||||
})
|
||||
|
||||
// Expose getAudioQuality
|
||||
obj.Set("getAudioQuality", func(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return vm.ToValue(map[string]interface{}{
|
||||
"error": "file path is required",
|
||||
})
|
||||
}
|
||||
|
||||
filePath := call.Arguments[0].String()
|
||||
quality, err := GetAudioQuality(filePath)
|
||||
if err != nil {
|
||||
return vm.ToValue(map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
return vm.ToValue(map[string]interface{}{
|
||||
"bitDepth": quality.BitDepth,
|
||||
"sampleRate": quality.SampleRate,
|
||||
"totalSamples": quality.TotalSamples,
|
||||
})
|
||||
})
|
||||
|
||||
// Expose buildFilename
|
||||
obj.Set("buildFilename", func(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 2 {
|
||||
return vm.ToValue("")
|
||||
}
|
||||
|
||||
template := call.Arguments[0].String()
|
||||
metadataObj := call.Arguments[1].Export()
|
||||
|
||||
metadata, ok := metadataObj.(map[string]interface{})
|
||||
if !ok {
|
||||
return vm.ToValue("")
|
||||
}
|
||||
|
||||
return vm.ToValue(buildFilenameFromTemplate(template, metadata))
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,453 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Extension categories
|
||||
const (
|
||||
CategoryMetadata = "metadata"
|
||||
CategoryDownload = "download"
|
||||
CategoryUtility = "utility"
|
||||
CategoryLyrics = "lyrics"
|
||||
CategoryIntegration = "integration"
|
||||
)
|
||||
|
||||
// StoreExtension represents an extension in the store
|
||||
type StoreExtension struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
DisplayName string `json:"display_name,omitempty"`
|
||||
Version string `json:"version"`
|
||||
Author string `json:"author"`
|
||||
Description string `json:"description"`
|
||||
DownloadURL string `json:"download_url,omitempty"`
|
||||
IconURL string `json:"icon_url,omitempty"`
|
||||
Category string `json:"category"`
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
Downloads int `json:"downloads"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
MinAppVersion string `json:"min_app_version,omitempty"`
|
||||
// Alternative camelCase fields (for flexibility)
|
||||
DisplayNameAlt string `json:"displayName,omitempty"`
|
||||
DownloadURLAlt string `json:"downloadUrl,omitempty"`
|
||||
IconURLAlt string `json:"iconUrl,omitempty"`
|
||||
MinAppVersionAlt string `json:"minAppVersion,omitempty"`
|
||||
}
|
||||
|
||||
// getDisplayName returns display name, falling back to name (private to avoid gomobile conflict)
|
||||
func (e *StoreExtension) getDisplayName() string {
|
||||
if e.DisplayName != "" {
|
||||
return e.DisplayName
|
||||
}
|
||||
if e.DisplayNameAlt != "" {
|
||||
return e.DisplayNameAlt
|
||||
}
|
||||
return e.Name
|
||||
}
|
||||
|
||||
// getDownloadURL returns download URL from either field (private to avoid gomobile conflict)
|
||||
func (e *StoreExtension) getDownloadURL() string {
|
||||
if e.DownloadURL != "" {
|
||||
return e.DownloadURL
|
||||
}
|
||||
return e.DownloadURLAlt
|
||||
}
|
||||
|
||||
// getIconURL returns icon URL from either field (private to avoid gomobile conflict)
|
||||
func (e *StoreExtension) getIconURL() string {
|
||||
if e.IconURL != "" {
|
||||
return e.IconURL
|
||||
}
|
||||
return e.IconURLAlt
|
||||
}
|
||||
|
||||
// getMinAppVersion returns min app version from either field (private to avoid gomobile conflict)
|
||||
func (e *StoreExtension) getMinAppVersion() string {
|
||||
if e.MinAppVersion != "" {
|
||||
return e.MinAppVersion
|
||||
}
|
||||
return e.MinAppVersionAlt
|
||||
}
|
||||
|
||||
// StoreRegistry represents the extension registry
|
||||
type StoreRegistry struct {
|
||||
Version int `json:"version"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
Extensions []StoreExtension `json:"extensions"`
|
||||
}
|
||||
|
||||
// StoreExtensionResponse is the normalized response sent to Flutter
|
||||
type StoreExtensionResponse struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Version string `json:"version"`
|
||||
Author string `json:"author"`
|
||||
Description string `json:"description"`
|
||||
DownloadURL string `json:"download_url"`
|
||||
IconURL string `json:"icon_url,omitempty"`
|
||||
Category string `json:"category"`
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
Downloads int `json:"downloads"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
MinAppVersion string `json:"min_app_version,omitempty"`
|
||||
IsInstalled bool `json:"is_installed"`
|
||||
InstalledVersion string `json:"installed_version,omitempty"`
|
||||
HasUpdate bool `json:"has_update"`
|
||||
}
|
||||
|
||||
// ToResponse converts StoreExtension to normalized response
|
||||
func (e *StoreExtension) ToResponse() StoreExtensionResponse {
|
||||
return StoreExtensionResponse{
|
||||
ID: e.ID,
|
||||
Name: e.Name,
|
||||
DisplayName: e.getDisplayName(),
|
||||
Version: e.Version,
|
||||
Author: e.Author,
|
||||
Description: e.Description,
|
||||
DownloadURL: e.getDownloadURL(),
|
||||
IconURL: e.getIconURL(),
|
||||
Category: e.Category,
|
||||
Tags: e.Tags,
|
||||
Downloads: e.Downloads,
|
||||
UpdatedAt: e.UpdatedAt,
|
||||
MinAppVersion: e.getMinAppVersion(),
|
||||
}
|
||||
}
|
||||
|
||||
// ExtensionStore manages the extension store
|
||||
type ExtensionStore struct {
|
||||
registryURL string
|
||||
cacheDir string
|
||||
cache *StoreRegistry
|
||||
cacheMu sync.RWMutex
|
||||
cacheTime time.Time
|
||||
cacheTTL time.Duration
|
||||
}
|
||||
|
||||
var (
|
||||
extensionStore *ExtensionStore
|
||||
extensionStoreMu sync.Mutex
|
||||
)
|
||||
|
||||
const (
|
||||
defaultRegistryURL = "https://raw.githubusercontent.com/zarzet/SpotiFLAC-Extension/main/registry.json"
|
||||
cacheTTL = 30 * time.Minute
|
||||
cacheFileName = "store_cache.json"
|
||||
)
|
||||
|
||||
// InitExtensionStore initializes the extension store
|
||||
func InitExtensionStore(cacheDir string) *ExtensionStore {
|
||||
extensionStoreMu.Lock()
|
||||
defer extensionStoreMu.Unlock()
|
||||
|
||||
if extensionStore == nil {
|
||||
extensionStore = &ExtensionStore{
|
||||
registryURL: defaultRegistryURL,
|
||||
cacheDir: cacheDir,
|
||||
cacheTTL: cacheTTL,
|
||||
}
|
||||
// Try to load from disk cache
|
||||
extensionStore.loadDiskCache()
|
||||
}
|
||||
return extensionStore
|
||||
}
|
||||
|
||||
// GetExtensionStore returns the singleton store instance
|
||||
func GetExtensionStore() *ExtensionStore {
|
||||
extensionStoreMu.Lock()
|
||||
defer extensionStoreMu.Unlock()
|
||||
return extensionStore
|
||||
}
|
||||
|
||||
// loadDiskCache loads cached registry from disk
|
||||
func (s *ExtensionStore) loadDiskCache() {
|
||||
if s.cacheDir == "" {
|
||||
return
|
||||
}
|
||||
|
||||
cachePath := filepath.Join(s.cacheDir, cacheFileName)
|
||||
data, err := os.ReadFile(cachePath)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
var cacheData struct {
|
||||
Registry StoreRegistry `json:"registry"`
|
||||
CacheTime int64 `json:"cache_time"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(data, &cacheData); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
s.cache = &cacheData.Registry
|
||||
s.cacheTime = time.Unix(cacheData.CacheTime, 0)
|
||||
LogDebug("ExtensionStore", "Loaded %d extensions from disk cache", len(s.cache.Extensions))
|
||||
}
|
||||
|
||||
// saveDiskCache saves registry to disk cache
|
||||
func (s *ExtensionStore) saveDiskCache() {
|
||||
if s.cacheDir == "" || s.cache == nil {
|
||||
return
|
||||
}
|
||||
|
||||
cacheData := struct {
|
||||
Registry StoreRegistry `json:"registry"`
|
||||
CacheTime int64 `json:"cache_time"`
|
||||
}{
|
||||
Registry: *s.cache,
|
||||
CacheTime: s.cacheTime.Unix(),
|
||||
}
|
||||
|
||||
data, err := json.Marshal(cacheData)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
cachePath := filepath.Join(s.cacheDir, cacheFileName)
|
||||
os.WriteFile(cachePath, data, 0644)
|
||||
}
|
||||
|
||||
// FetchRegistry fetches the extension registry from GitHub
|
||||
func (s *ExtensionStore) FetchRegistry(forceRefresh bool) (*StoreRegistry, error) {
|
||||
s.cacheMu.Lock()
|
||||
defer s.cacheMu.Unlock()
|
||||
|
||||
// Return cached if valid and not forcing refresh
|
||||
if !forceRefresh && s.cache != nil && time.Since(s.cacheTime) < s.cacheTTL {
|
||||
LogDebug("ExtensionStore", "Using cached registry (%d extensions)", len(s.cache.Extensions))
|
||||
return s.cache, nil
|
||||
}
|
||||
|
||||
LogInfo("ExtensionStore", "Fetching registry from %s", s.registryURL)
|
||||
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
resp, err := client.Get(s.registryURL)
|
||||
if err != nil {
|
||||
// Return cached data if available on network error
|
||||
if s.cache != nil {
|
||||
LogWarn("ExtensionStore", "Network error, using cached registry: %v", err)
|
||||
return s.cache, nil
|
||||
}
|
||||
return nil, fmt.Errorf("failed to fetch registry: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
if s.cache != nil {
|
||||
LogWarn("ExtensionStore", "HTTP %d, using cached registry", resp.StatusCode)
|
||||
return s.cache, nil
|
||||
}
|
||||
return nil, fmt.Errorf("registry returned HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read registry: %w", err)
|
||||
}
|
||||
|
||||
var registry StoreRegistry
|
||||
if err := json.Unmarshal(body, ®istry); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse registry: %w", err)
|
||||
}
|
||||
|
||||
s.cache = ®istry
|
||||
s.cacheTime = time.Now()
|
||||
s.saveDiskCache()
|
||||
|
||||
LogInfo("ExtensionStore", "Fetched %d extensions from registry", len(registry.Extensions))
|
||||
return ®istry, nil
|
||||
}
|
||||
|
||||
// GetExtensionsWithStatus returns extensions with installation status
|
||||
func (s *ExtensionStore) GetExtensionsWithStatus() ([]StoreExtensionResponse, error) {
|
||||
registry, err := s.FetchRegistry(false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
manager := GetExtensionManager()
|
||||
installed := make(map[string]string) // id -> version
|
||||
|
||||
if manager != nil {
|
||||
for _, ext := range manager.GetAllExtensions() {
|
||||
installed[ext.ID] = ext.Manifest.Version
|
||||
}
|
||||
}
|
||||
|
||||
result := make([]StoreExtensionResponse, len(registry.Extensions))
|
||||
for i, ext := range registry.Extensions {
|
||||
resp := ext.ToResponse()
|
||||
|
||||
if installedVersion, ok := installed[ext.ID]; ok {
|
||||
resp.IsInstalled = true
|
||||
resp.InstalledVersion = installedVersion
|
||||
resp.HasUpdate = compareVersions(ext.Version, installedVersion) > 0
|
||||
}
|
||||
|
||||
result[i] = resp
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// DownloadExtension downloads an extension package to the specified path
|
||||
func (s *ExtensionStore) DownloadExtension(extensionID string, destPath string) error {
|
||||
registry, err := s.FetchRegistry(false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var ext *StoreExtension
|
||||
for _, e := range registry.Extensions {
|
||||
if e.ID == extensionID {
|
||||
ext = &e
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if ext == nil {
|
||||
return fmt.Errorf("extension %s not found in store", extensionID)
|
||||
}
|
||||
|
||||
LogInfo("ExtensionStore", "Downloading %s from %s", ext.getDisplayName(), ext.getDownloadURL())
|
||||
|
||||
client := &http.Client{Timeout: 5 * time.Minute}
|
||||
resp, err := client.Get(ext.getDownloadURL())
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to download: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("download returned HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// Create destination file
|
||||
out, err := os.Create(destPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create file: %w", err)
|
||||
}
|
||||
defer out.Close()
|
||||
|
||||
_, err = io.Copy(out, resp.Body)
|
||||
if err != nil {
|
||||
os.Remove(destPath)
|
||||
return fmt.Errorf("failed to write file: %w", err)
|
||||
}
|
||||
|
||||
LogInfo("ExtensionStore", "Downloaded %s to %s", ext.getDisplayName(), destPath)
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetCategories returns all available categories
|
||||
func (s *ExtensionStore) GetCategories() []string {
|
||||
return []string{
|
||||
CategoryMetadata,
|
||||
CategoryDownload,
|
||||
CategoryUtility,
|
||||
CategoryLyrics,
|
||||
CategoryIntegration,
|
||||
}
|
||||
}
|
||||
|
||||
// SearchExtensions searches extensions by query
|
||||
func (s *ExtensionStore) SearchExtensions(query string, category string) ([]StoreExtensionResponse, error) {
|
||||
extensions, err := s.GetExtensionsWithStatus()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if query == "" && category == "" {
|
||||
return extensions, nil
|
||||
}
|
||||
|
||||
var result []StoreExtensionResponse
|
||||
queryLower := toLower(query)
|
||||
|
||||
for _, ext := range extensions {
|
||||
// Filter by category
|
||||
if category != "" && ext.Category != category {
|
||||
continue
|
||||
}
|
||||
|
||||
// Filter by query
|
||||
if query != "" {
|
||||
if !containsIgnoreCase(ext.Name, queryLower) &&
|
||||
!containsIgnoreCase(ext.DisplayName, queryLower) &&
|
||||
!containsIgnoreCase(ext.Description, queryLower) &&
|
||||
!containsIgnoreCase(ext.Author, queryLower) {
|
||||
// Check tags
|
||||
found := false
|
||||
for _, tag := range ext.Tags {
|
||||
if containsIgnoreCase(tag, queryLower) {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result = append(result, ext)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// ClearCache clears the in-memory and disk cache
|
||||
func (s *ExtensionStore) ClearCache() {
|
||||
s.cacheMu.Lock()
|
||||
defer s.cacheMu.Unlock()
|
||||
|
||||
s.cache = nil
|
||||
s.cacheTime = time.Time{}
|
||||
|
||||
if s.cacheDir != "" {
|
||||
cachePath := filepath.Join(s.cacheDir, cacheFileName)
|
||||
os.Remove(cachePath)
|
||||
}
|
||||
|
||||
LogInfo("ExtensionStore", "Cache cleared")
|
||||
}
|
||||
|
||||
// Helper: case-insensitive contains
|
||||
func containsIgnoreCase(s, substr string) bool {
|
||||
return containsStr(toLower(s), substr)
|
||||
}
|
||||
|
||||
func toLower(s string) string {
|
||||
result := make([]byte, len(s))
|
||||
for i := 0; i < len(s); i++ {
|
||||
c := s[i]
|
||||
if c >= 'A' && c <= 'Z' {
|
||||
c += 'a' - 'A'
|
||||
}
|
||||
result[i] = c
|
||||
}
|
||||
return string(result)
|
||||
}
|
||||
|
||||
func containsStr(s, substr string) bool {
|
||||
return len(substr) == 0 || (len(s) >= len(substr) && findSubstring(s, substr) >= 0)
|
||||
}
|
||||
|
||||
func findSubstring(s, substr string) int {
|
||||
for i := 0; i <= len(s)-len(substr); i++ {
|
||||
if s[i:i+len(substr)] == substr {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
@@ -33,8 +33,8 @@ var (
|
||||
func GetLogBuffer() *LogBuffer {
|
||||
logBufferOnce.Do(func() {
|
||||
globalLogBuffer = &LogBuffer{
|
||||
entries: make([]LogEntry, 0, 500),
|
||||
maxSize: 500,
|
||||
entries: make([]LogEntry, 0, 1000),
|
||||
maxSize: 1000,
|
||||
loggingEnabled: false, // Default: disabled for performance (user can enable in settings)
|
||||
}
|
||||
})
|
||||
|
||||
+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()
|
||||
@@ -480,6 +484,25 @@ import Gobackend // Import Go framework
|
||||
if let error = error { throw error }
|
||||
return response
|
||||
|
||||
// Extension URL Handler API
|
||||
case "handleURLWithExtension":
|
||||
let args = call.arguments as! [String: Any]
|
||||
let url = args["url"] as! String
|
||||
let response = GobackendHandleURLWithExtensionJSON(url, &error)
|
||||
if let error = error { throw error }
|
||||
return response
|
||||
|
||||
case "findURLHandler":
|
||||
let args = call.arguments as! [String: Any]
|
||||
let url = args["url"] as! String
|
||||
let response = GobackendFindURLHandlerJSON(url)
|
||||
return response
|
||||
|
||||
case "getURLHandlers":
|
||||
let response = GobackendGetURLHandlersJSON(&error)
|
||||
if let error = error { throw error }
|
||||
return response
|
||||
|
||||
// Extension Post-Processing API
|
||||
case "runPostProcessing":
|
||||
let args = call.arguments as! [String: Any]
|
||||
|
||||
@@ -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.4';
|
||||
static const String buildNumber = '53';
|
||||
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,8 @@ 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
|
||||
final bool showExtensionStore; // Show Extension Store tab in navigation
|
||||
|
||||
const AppSettings({
|
||||
this.defaultService = 'tidal',
|
||||
@@ -42,6 +45,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 +54,8 @@ 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
|
||||
this.showExtensionStore = true, // Default: show store
|
||||
});
|
||||
|
||||
AppSettings copyWith({
|
||||
@@ -67,6 +73,7 @@ class AppSettings {
|
||||
bool? hasSearchedBefore,
|
||||
String? folderOrganization,
|
||||
String? historyViewMode,
|
||||
String? historyFilterMode,
|
||||
bool? askQualityBeforeDownload,
|
||||
String? spotifyClientId,
|
||||
String? spotifyClientSecret,
|
||||
@@ -75,6 +82,8 @@ class AppSettings {
|
||||
bool? enableLogging,
|
||||
bool? useExtensionProviders,
|
||||
String? searchProvider,
|
||||
bool? separateSingles,
|
||||
bool? showExtensionStore,
|
||||
}) {
|
||||
return AppSettings(
|
||||
defaultService: defaultService ?? this.defaultService,
|
||||
@@ -91,6 +100,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 +109,8 @@ class AppSettings {
|
||||
enableLogging: enableLogging ?? this.enableLogging,
|
||||
useExtensionProviders: useExtensionProviders ?? this.useExtensionProviders,
|
||||
searchProvider: searchProvider ?? this.searchProvider,
|
||||
separateSingles: separateSingles ?? this.separateSingles,
|
||||
showExtensionStore: showExtensionStore ?? this.showExtensionStore,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,8 @@ 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,
|
||||
showExtensionStore: json['showExtensionStore'] as bool? ?? true,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$AppSettingsToJson(AppSettings instance) =>
|
||||
@@ -48,6 +51,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 +60,6 @@ Map<String, dynamic> _$AppSettingsToJson(AppSettings instance) =>
|
||||
'enableLogging': instance.enableLogging,
|
||||
'useExtensionProviders': instance.useExtensionProviders,
|
||||
'searchProvider': instance.searchProvider,
|
||||
'separateSingles': instance.separateSingles,
|
||||
'showExtensionStore': instance.showExtensionStore,
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ class Extension {
|
||||
final bool hasDownloadProvider;
|
||||
final bool skipMetadataEnrichment; // If true, use metadata from extension instead of enriching
|
||||
final SearchBehavior? searchBehavior; // Custom search behavior
|
||||
final URLHandler? urlHandler; // Custom URL handling
|
||||
final TrackMatching? trackMatching; // Custom track matching
|
||||
final PostProcessing? postProcessing; // Post-processing hooks
|
||||
|
||||
@@ -45,6 +46,7 @@ class Extension {
|
||||
this.hasDownloadProvider = false,
|
||||
this.skipMetadataEnrichment = false,
|
||||
this.searchBehavior,
|
||||
this.urlHandler,
|
||||
this.trackMatching,
|
||||
this.postProcessing,
|
||||
});
|
||||
@@ -74,6 +76,9 @@ class Extension {
|
||||
searchBehavior: json['search_behavior'] != null
|
||||
? SearchBehavior.fromJson(json['search_behavior'] as Map<String, dynamic>)
|
||||
: null,
|
||||
urlHandler: json['url_handler'] != null
|
||||
? URLHandler.fromJson(json['url_handler'] as Map<String, dynamic>)
|
||||
: null,
|
||||
trackMatching: json['track_matching'] != null
|
||||
? TrackMatching.fromJson(json['track_matching'] as Map<String, dynamic>)
|
||||
: null,
|
||||
@@ -101,6 +106,7 @@ class Extension {
|
||||
bool? hasDownloadProvider,
|
||||
bool? skipMetadataEnrichment,
|
||||
SearchBehavior? searchBehavior,
|
||||
URLHandler? urlHandler,
|
||||
TrackMatching? trackMatching,
|
||||
PostProcessing? postProcessing,
|
||||
}) {
|
||||
@@ -122,12 +128,14 @@ class Extension {
|
||||
hasDownloadProvider: hasDownloadProvider ?? this.hasDownloadProvider,
|
||||
skipMetadataEnrichment: skipMetadataEnrichment ?? this.skipMetadataEnrichment,
|
||||
searchBehavior: searchBehavior ?? this.searchBehavior,
|
||||
urlHandler: urlHandler ?? this.urlHandler,
|
||||
trackMatching: trackMatching ?? this.trackMatching,
|
||||
postProcessing: postProcessing ?? this.postProcessing,
|
||||
);
|
||||
}
|
||||
|
||||
bool get hasCustomSearch => searchBehavior?.enabled ?? false;
|
||||
bool get hasURLHandler => urlHandler?.enabled ?? false;
|
||||
bool get hasCustomMatching => trackMatching?.customMatching ?? false;
|
||||
bool get hasPostProcessing => postProcessing?.enabled ?? false;
|
||||
}
|
||||
@@ -226,6 +234,36 @@ class PostProcessing {
|
||||
}
|
||||
}
|
||||
|
||||
/// URL handler configuration for custom URL patterns
|
||||
class URLHandler {
|
||||
final bool enabled;
|
||||
final List<String> patterns;
|
||||
|
||||
const URLHandler({
|
||||
required this.enabled,
|
||||
this.patterns = const [],
|
||||
});
|
||||
|
||||
factory URLHandler.fromJson(Map<String, dynamic> json) {
|
||||
return URLHandler(
|
||||
enabled: json['enabled'] as bool? ?? false,
|
||||
patterns: (json['patterns'] as List<dynamic>?)?.cast<String>() ?? [],
|
||||
);
|
||||
}
|
||||
|
||||
/// Check if a URL matches any of the patterns
|
||||
bool matchesURL(String url) {
|
||||
if (!enabled || patterns.isEmpty) return false;
|
||||
final lowerUrl = url.toLowerCase();
|
||||
for (final pattern in patterns) {
|
||||
if (lowerUrl.contains(pattern.toLowerCase())) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// A post-processing hook
|
||||
class PostProcessingHook {
|
||||
final String id;
|
||||
@@ -520,22 +558,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,16 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
||||
state = state.copyWith(useExtensionProviders: enabled);
|
||||
_saveSettings();
|
||||
}
|
||||
|
||||
void setSeparateSingles(bool enabled) {
|
||||
state = state.copyWith(separateSingles: enabled);
|
||||
_saveSettings();
|
||||
}
|
||||
|
||||
void setShowExtensionStore(bool enabled) {
|
||||
state = state.copyWith(showExtensionStore: enabled);
|
||||
_saveSettings();
|
||||
}
|
||||
}
|
||||
|
||||
final settingsProvider = NotifierProvider<SettingsNotifier, AppSettings>(
|
||||
|
||||
@@ -0,0 +1,286 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
import 'package:spotiflac_android/utils/logger.dart';
|
||||
import 'package:spotiflac_android/providers/extension_provider.dart';
|
||||
|
||||
final _log = AppLogger('StoreProvider');
|
||||
|
||||
/// Extension categories
|
||||
class StoreCategory {
|
||||
static const String metadata = 'metadata';
|
||||
static const String download = 'download';
|
||||
static const String utility = 'utility';
|
||||
static const String lyrics = 'lyrics';
|
||||
static const String integration = 'integration';
|
||||
|
||||
static const List<String> all = [metadata, download, utility, lyrics, integration];
|
||||
|
||||
static String getDisplayName(String category) {
|
||||
switch (category) {
|
||||
case metadata:
|
||||
return 'Metadata';
|
||||
case download:
|
||||
return 'Download';
|
||||
case utility:
|
||||
return 'Utility';
|
||||
case lyrics:
|
||||
return 'Lyrics';
|
||||
case integration:
|
||||
return 'Integration';
|
||||
default:
|
||||
return category;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Represents an extension in the store
|
||||
class StoreExtension {
|
||||
final String id;
|
||||
final String name;
|
||||
final String displayName;
|
||||
final String version;
|
||||
final String author;
|
||||
final String description;
|
||||
final String downloadUrl;
|
||||
final String? iconUrl;
|
||||
final String category;
|
||||
final List<String> tags;
|
||||
final int downloads;
|
||||
final String updatedAt;
|
||||
final String? minAppVersion;
|
||||
final bool isInstalled;
|
||||
final String? installedVersion;
|
||||
final bool hasUpdate;
|
||||
|
||||
const StoreExtension({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.displayName,
|
||||
required this.version,
|
||||
required this.author,
|
||||
required this.description,
|
||||
required this.downloadUrl,
|
||||
this.iconUrl,
|
||||
required this.category,
|
||||
this.tags = const [],
|
||||
this.downloads = 0,
|
||||
required this.updatedAt,
|
||||
this.minAppVersion,
|
||||
this.isInstalled = false,
|
||||
this.installedVersion,
|
||||
this.hasUpdate = false,
|
||||
});
|
||||
|
||||
factory StoreExtension.fromJson(Map<String, dynamic> json) {
|
||||
return StoreExtension(
|
||||
id: json['id'] as String? ?? '',
|
||||
name: json['name'] as String? ?? '',
|
||||
displayName: json['display_name'] as String? ?? json['name'] as String? ?? '',
|
||||
version: json['version'] as String? ?? '0.0.0',
|
||||
author: json['author'] as String? ?? 'Unknown',
|
||||
description: json['description'] as String? ?? '',
|
||||
downloadUrl: json['download_url'] as String? ?? '',
|
||||
iconUrl: json['icon_url'] as String?,
|
||||
category: json['category'] as String? ?? 'utility',
|
||||
tags: (json['tags'] as List<dynamic>?)?.cast<String>() ?? [],
|
||||
downloads: json['downloads'] as int? ?? 0,
|
||||
updatedAt: json['updated_at'] as String? ?? '',
|
||||
minAppVersion: json['min_app_version'] as String?,
|
||||
isInstalled: json['is_installed'] as bool? ?? false,
|
||||
installedVersion: json['installed_version'] as String?,
|
||||
hasUpdate: json['has_update'] as bool? ?? false,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// State for extension store
|
||||
class StoreState {
|
||||
final List<StoreExtension> extensions;
|
||||
final String? selectedCategory;
|
||||
final String searchQuery;
|
||||
final bool isLoading;
|
||||
final bool isDownloading;
|
||||
final String? downloadingId;
|
||||
final String? error;
|
||||
final bool isInitialized;
|
||||
|
||||
const StoreState({
|
||||
this.extensions = const [],
|
||||
this.selectedCategory,
|
||||
this.searchQuery = '',
|
||||
this.isLoading = false,
|
||||
this.isDownloading = false,
|
||||
this.downloadingId,
|
||||
this.error,
|
||||
this.isInitialized = false,
|
||||
});
|
||||
|
||||
StoreState copyWith({
|
||||
List<StoreExtension>? extensions,
|
||||
String? selectedCategory,
|
||||
bool clearCategory = false,
|
||||
String? searchQuery,
|
||||
bool? isLoading,
|
||||
bool? isDownloading,
|
||||
String? downloadingId,
|
||||
bool clearDownloadingId = false,
|
||||
String? error,
|
||||
bool clearError = false,
|
||||
bool? isInitialized,
|
||||
}) {
|
||||
return StoreState(
|
||||
extensions: extensions ?? this.extensions,
|
||||
selectedCategory: clearCategory ? null : (selectedCategory ?? this.selectedCategory),
|
||||
searchQuery: searchQuery ?? this.searchQuery,
|
||||
isLoading: isLoading ?? this.isLoading,
|
||||
isDownloading: isDownloading ?? this.isDownloading,
|
||||
downloadingId: clearDownloadingId ? null : (downloadingId ?? this.downloadingId),
|
||||
error: clearError ? null : (error ?? this.error),
|
||||
isInitialized: isInitialized ?? this.isInitialized,
|
||||
);
|
||||
}
|
||||
|
||||
/// Get filtered extensions based on category and search
|
||||
List<StoreExtension> get filteredExtensions {
|
||||
var result = extensions;
|
||||
|
||||
if (selectedCategory != null) {
|
||||
result = result.where((e) => e.category == selectedCategory).toList();
|
||||
}
|
||||
|
||||
if (searchQuery.isNotEmpty) {
|
||||
final query = searchQuery.toLowerCase();
|
||||
result = result.where((e) =>
|
||||
e.name.toLowerCase().contains(query) ||
|
||||
e.displayName.toLowerCase().contains(query) ||
|
||||
e.description.toLowerCase().contains(query) ||
|
||||
e.author.toLowerCase().contains(query) ||
|
||||
e.tags.any((t) => t.toLowerCase().contains(query))
|
||||
).toList();
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
/// Provider for managing extension store
|
||||
class StoreNotifier extends Notifier<StoreState> {
|
||||
@override
|
||||
StoreState build() {
|
||||
return const StoreState();
|
||||
}
|
||||
|
||||
/// Initialize the store
|
||||
Future<void> initialize(String cacheDir) async {
|
||||
if (state.isInitialized) return;
|
||||
|
||||
state = state.copyWith(isLoading: true, clearError: true);
|
||||
|
||||
try {
|
||||
await PlatformBridge.initExtensionStore(cacheDir);
|
||||
await refresh();
|
||||
state = state.copyWith(isInitialized: true, isLoading: false);
|
||||
_log.i('Extension store initialized');
|
||||
} catch (e) {
|
||||
_log.e('Failed to initialize store: $e');
|
||||
state = state.copyWith(isLoading: false, error: e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
/// Refresh extensions from store
|
||||
Future<void> refresh({bool forceRefresh = false}) async {
|
||||
state = state.copyWith(isLoading: true, clearError: true);
|
||||
|
||||
try {
|
||||
final extensions = await PlatformBridge.getStoreExtensions(forceRefresh: forceRefresh);
|
||||
state = state.copyWith(
|
||||
extensions: extensions.map((e) => StoreExtension.fromJson(e)).toList(),
|
||||
isLoading: false,
|
||||
);
|
||||
_log.d('Loaded ${state.extensions.length} extensions from store');
|
||||
} catch (e) {
|
||||
_log.e('Failed to refresh store: $e');
|
||||
state = state.copyWith(isLoading: false, error: e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
/// Set category filter
|
||||
void setCategory(String? category) {
|
||||
if (category == null) {
|
||||
state = state.copyWith(clearCategory: true);
|
||||
} else {
|
||||
state = state.copyWith(selectedCategory: category);
|
||||
}
|
||||
}
|
||||
|
||||
/// Set search query
|
||||
void setSearchQuery(String query) {
|
||||
state = state.copyWith(searchQuery: query);
|
||||
}
|
||||
|
||||
/// Clear search
|
||||
void clearSearch() {
|
||||
state = state.copyWith(searchQuery: '', clearCategory: true);
|
||||
}
|
||||
|
||||
/// Download and install extension
|
||||
Future<bool> installExtension(String extensionId, String tempDir, String extensionsDir) async {
|
||||
state = state.copyWith(isDownloading: true, downloadingId: extensionId, clearError: true);
|
||||
|
||||
try {
|
||||
_log.i('Downloading extension: $extensionId');
|
||||
final downloadPath = await PlatformBridge.downloadStoreExtension(extensionId, tempDir);
|
||||
|
||||
_log.i('Installing extension from: $downloadPath');
|
||||
final extNotifier = ref.read(extensionProvider.notifier);
|
||||
final success = await extNotifier.installExtension(downloadPath);
|
||||
|
||||
if (success) {
|
||||
_log.i('Extension installed: $extensionId');
|
||||
await refresh();
|
||||
}
|
||||
|
||||
state = state.copyWith(isDownloading: false, clearDownloadingId: true);
|
||||
return success;
|
||||
} catch (e) {
|
||||
_log.e('Failed to install extension: $e');
|
||||
state = state.copyWith(isDownloading: false, clearDownloadingId: true, error: e.toString());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Update an installed extension
|
||||
Future<bool> updateExtension(String extensionId, String tempDir) async {
|
||||
state = state.copyWith(isDownloading: true, downloadingId: extensionId, clearError: true);
|
||||
|
||||
try {
|
||||
_log.i('Downloading update for: $extensionId');
|
||||
final downloadPath = await PlatformBridge.downloadStoreExtension(extensionId, tempDir);
|
||||
|
||||
_log.i('Upgrading extension from: $downloadPath');
|
||||
final extNotifier = ref.read(extensionProvider.notifier);
|
||||
final success = await extNotifier.upgradeExtension(downloadPath);
|
||||
|
||||
if (success) {
|
||||
_log.i('Extension updated: $extensionId');
|
||||
await refresh();
|
||||
}
|
||||
|
||||
state = state.copyWith(isDownloading: false, clearDownloadingId: true);
|
||||
return success;
|
||||
} catch (e) {
|
||||
_log.e('Failed to update extension: $e');
|
||||
state = state.copyWith(isDownloading: false, clearDownloadingId: true, error: e.toString());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear error
|
||||
void clearError() {
|
||||
state = state.copyWith(clearError: true);
|
||||
}
|
||||
}
|
||||
|
||||
final storeProvider = NotifierProvider<StoreNotifier, StoreState>(
|
||||
StoreNotifier.new,
|
||||
);
|
||||
@@ -131,6 +131,59 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
state = TrackState(isLoading: true, hasSearchText: state.hasSearchText);
|
||||
|
||||
try {
|
||||
// First, check if any extension can handle this URL
|
||||
final extensionHandler = await PlatformBridge.findURLHandler(url);
|
||||
if (extensionHandler != null) {
|
||||
_log.i('Found extension URL handler: $extensionHandler for URL: $url');
|
||||
final result = await PlatformBridge.handleURLWithExtension(url);
|
||||
if (!_isRequestValid(requestId)) return;
|
||||
|
||||
if (result != null) {
|
||||
final type = result['type'] as String?;
|
||||
final extensionId = result['extension_id'] as String?;
|
||||
|
||||
if (type == 'track' && result['track'] != null) {
|
||||
final trackData = result['track'] as Map<String, dynamic>;
|
||||
final track = _parseSearchTrack(trackData, source: extensionId);
|
||||
state = TrackState(
|
||||
tracks: [track],
|
||||
isLoading: false,
|
||||
coverUrl: track.coverUrl,
|
||||
searchExtensionId: extensionId,
|
||||
);
|
||||
return;
|
||||
} else if ((type == 'album' || type == 'playlist') && result['tracks'] != null) {
|
||||
final trackList = result['tracks'] as List<dynamic>;
|
||||
final tracks = trackList.map((t) => _parseSearchTrack(t as Map<String, dynamic>, source: extensionId)).toList();
|
||||
state = TrackState(
|
||||
tracks: tracks,
|
||||
isLoading: false,
|
||||
albumId: result['album']?['id'] as String?,
|
||||
albumName: result['name'] as String? ?? result['album']?['name'] as String?,
|
||||
playlistName: type == 'playlist' ? result['name'] as String? : null,
|
||||
coverUrl: result['cover_url'] as String?,
|
||||
searchExtensionId: extensionId,
|
||||
);
|
||||
return;
|
||||
} else if (type == 'artist' && result['artist'] != null) {
|
||||
final artistData = result['artist'] as Map<String, dynamic>;
|
||||
final albumsList = artistData['albums'] as List<dynamic>? ?? [];
|
||||
final albums = albumsList.map((a) => _parseArtistAlbum(a as Map<String, dynamic>)).toList();
|
||||
state = TrackState(
|
||||
tracks: [],
|
||||
isLoading: false,
|
||||
artistId: artistData['id'] as String?,
|
||||
artistName: artistData['name'] as String?,
|
||||
coverUrl: artistData['image_url'] as String? ?? artistData['images'] as String?,
|
||||
artistAlbums: albums,
|
||||
searchExtensionId: extensionId,
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// No extension handler found, try Spotify URL parsing
|
||||
final parsed = await PlatformBridge.parseSpotifyUrl(url);
|
||||
if (!_isRequestValid(requestId)) return; // Request cancelled
|
||||
|
||||
@@ -466,6 +519,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) {
|
||||
|
||||
+56
-31
@@ -6,6 +6,7 @@ import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||
import 'package:spotiflac_android/providers/track_provider.dart';
|
||||
import 'package:spotiflac_android/screens/home_tab.dart';
|
||||
import 'package:spotiflac_android/screens/store_tab.dart';
|
||||
import 'package:spotiflac_android/screens/queue_tab.dart';
|
||||
import 'package:spotiflac_android/screens/settings/settings_tab.dart';
|
||||
import 'package:spotiflac_android/services/share_intent_service.dart';
|
||||
@@ -172,6 +173,7 @@ class _MainShellState extends ConsumerState<MainShell> {
|
||||
Widget build(BuildContext context) {
|
||||
final queueState = ref.watch(downloadQueueProvider.select((s) => s.queuedCount));
|
||||
final trackState = ref.watch(trackProvider);
|
||||
final showStore = ref.watch(settingsProvider.select((s) => s.showExtensionStore));
|
||||
|
||||
// Check if keyboard is visible (bottom inset > 0 means keyboard is showing)
|
||||
final isKeyboardVisible = MediaQuery.of(context).viewInsets.bottom > 0;
|
||||
@@ -185,6 +187,57 @@ class _MainShellState extends ConsumerState<MainShell> {
|
||||
!trackState.isLoading &&
|
||||
!isKeyboardVisible;
|
||||
|
||||
// Build tabs and destinations based on settings
|
||||
final tabs = <Widget>[
|
||||
const HomeTab(),
|
||||
const QueueTab(),
|
||||
if (showStore) const StoreTab(),
|
||||
const SettingsTab(),
|
||||
];
|
||||
|
||||
final destinations = <NavigationDestination>[
|
||||
const NavigationDestination(
|
||||
icon: Icon(Icons.home_outlined),
|
||||
selectedIcon: Icon(Icons.home),
|
||||
label: 'Home',
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: Badge(
|
||||
isLabelVisible: queueState > 0,
|
||||
label: Text('$queueState'),
|
||||
child: const Icon(Icons.history_outlined),
|
||||
),
|
||||
selectedIcon: Badge(
|
||||
isLabelVisible: queueState > 0,
|
||||
label: Text('$queueState'),
|
||||
child: const Icon(Icons.history),
|
||||
),
|
||||
label: 'History',
|
||||
),
|
||||
if (showStore)
|
||||
const NavigationDestination(
|
||||
icon: Icon(Icons.store_outlined),
|
||||
selectedIcon: Icon(Icons.store),
|
||||
label: 'Store',
|
||||
),
|
||||
const NavigationDestination(
|
||||
icon: Icon(Icons.settings_outlined),
|
||||
selectedIcon: Icon(Icons.settings),
|
||||
label: 'Settings',
|
||||
),
|
||||
];
|
||||
|
||||
// Clamp current index if tabs changed
|
||||
final maxIndex = tabs.length - 1;
|
||||
if (_currentIndex > maxIndex) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) {
|
||||
setState(() => _currentIndex = maxIndex);
|
||||
_pageController.jumpToPage(maxIndex);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return PopScope(
|
||||
canPop: canPop,
|
||||
onPopInvokedWithResult: (didPop, result) async {
|
||||
@@ -202,44 +255,16 @@ class _MainShellState extends ConsumerState<MainShell> {
|
||||
controller: _pageController,
|
||||
onPageChanged: _onPageChanged,
|
||||
physics: const BouncingScrollPhysics(),
|
||||
children: const [
|
||||
HomeTab(),
|
||||
QueueTab(),
|
||||
SettingsTab(),
|
||||
],
|
||||
children: tabs,
|
||||
),
|
||||
bottomNavigationBar: NavigationBar(
|
||||
selectedIndex: _currentIndex,
|
||||
selectedIndex: _currentIndex.clamp(0, maxIndex),
|
||||
onDestinationSelected: _onNavTap,
|
||||
animationDuration: const Duration(milliseconds: 200),
|
||||
backgroundColor: Theme.of(context).brightness == Brightness.dark
|
||||
? Color.alphaBlend(Colors.white.withValues(alpha: 0.05), Theme.of(context).colorScheme.surface)
|
||||
: Color.alphaBlend(Colors.black.withValues(alpha: 0.03), Theme.of(context).colorScheme.surface),
|
||||
destinations: [
|
||||
const NavigationDestination(
|
||||
icon: Icon(Icons.home_outlined),
|
||||
selectedIcon: Icon(Icons.home),
|
||||
label: 'Home',
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: Badge(
|
||||
isLabelVisible: queueState > 0,
|
||||
label: Text('$queueState'),
|
||||
child: const Icon(Icons.history_outlined),
|
||||
),
|
||||
selectedIcon: Badge(
|
||||
isLabelVisible: queueState > 0,
|
||||
label: Text('$queueState'),
|
||||
child: const Icon(Icons.history),
|
||||
),
|
||||
label: 'History',
|
||||
),
|
||||
const NavigationDestination(
|
||||
icon: Icon(Icons.settings_outlined),
|
||||
selectedIcon: Icon(Icons.settings),
|
||||
label: 'Settings',
|
||||
),
|
||||
],
|
||||
destinations: destinations,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
+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()),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'dart:io';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:spotiflac_android/providers/extension_provider.dart';
|
||||
import 'package:spotiflac_android/providers/store_provider.dart';
|
||||
import 'package:spotiflac_android/widgets/settings_group.dart';
|
||||
|
||||
class ExtensionDetailPage extends ConsumerStatefulWidget {
|
||||
@@ -390,6 +391,8 @@ class _ExtensionDetailPageState extends ConsumerState<ExtensionDetailPage> {
|
||||
.read(extensionProvider.notifier)
|
||||
.removeExtension(widget.extensionId);
|
||||
if (success && mounted) {
|
||||
// Refresh store to update isInstalled status
|
||||
ref.read(storeProvider.notifier).refresh();
|
||||
Navigator.pop(this.context);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
@@ -190,6 +202,15 @@ class OptionsSettingsPage extends ConsumerWidget {
|
||||
SliverToBoxAdapter(
|
||||
child: SettingsGroup(
|
||||
children: [
|
||||
SettingsSwitchItem(
|
||||
icon: Icons.store,
|
||||
title: 'Extension Store',
|
||||
subtitle: 'Show Store tab in navigation',
|
||||
value: settings.showExtensionStore,
|
||||
onChanged: (v) => ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setShowExtensionStore(v),
|
||||
),
|
||||
SettingsSwitchItem(
|
||||
icon: Icons.system_update,
|
||||
title: 'Check for Updates',
|
||||
@@ -782,14 +803,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 +845,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 +861,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 +907,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 +962,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');
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,567 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:spotiflac_android/providers/store_provider.dart';
|
||||
import 'package:spotiflac_android/widgets/settings_group.dart';
|
||||
|
||||
class StoreTab extends ConsumerStatefulWidget {
|
||||
const StoreTab({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<StoreTab> createState() => _StoreTabState();
|
||||
}
|
||||
|
||||
class _StoreTabState extends ConsumerState<StoreTab> {
|
||||
final _searchController = TextEditingController();
|
||||
bool _isInitialized = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _initialize());
|
||||
}
|
||||
|
||||
Future<void> _initialize() async {
|
||||
if (_isInitialized) return;
|
||||
_isInitialized = true;
|
||||
|
||||
final cacheDir = await getApplicationCacheDirectory();
|
||||
await ref.read(storeProvider.notifier).initialize(cacheDir.path);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_searchController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final state = ref.watch(storeProvider);
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final topPadding = MediaQuery.of(context).padding.top;
|
||||
|
||||
return Scaffold(
|
||||
body: RefreshIndicator(
|
||||
onRefresh: () => ref.read(storeProvider.notifier).refresh(forceRefresh: true),
|
||||
child: CustomScrollView(
|
||||
slivers: [
|
||||
// App Bar - consistent with other tabs
|
||||
SliverAppBar(
|
||||
expandedHeight: 120 + topPadding,
|
||||
collapsedHeight: kToolbarHeight,
|
||||
floating: false,
|
||||
pinned: true,
|
||||
backgroundColor: colorScheme.surface,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
automaticallyImplyLeading: false,
|
||||
flexibleSpace: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final maxHeight = 120 + topPadding;
|
||||
final minHeight = kToolbarHeight + topPadding;
|
||||
final expandRatio = ((constraints.maxHeight - minHeight) /
|
||||
(maxHeight - minHeight))
|
||||
.clamp(0.0, 1.0);
|
||||
|
||||
return FlexibleSpaceBar(
|
||||
expandedTitleScale: 1.0,
|
||||
titlePadding: const EdgeInsets.only(left: 24, bottom: 16),
|
||||
title: Text(
|
||||
'Store',
|
||||
style: TextStyle(
|
||||
fontSize: 20 + (14 * expandRatio),
|
||||
fontWeight: FontWeight.bold,
|
||||
color: colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
// Search Bar
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
|
||||
child: TextField(
|
||||
controller: _searchController,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Search extensions...',
|
||||
prefixIcon: const Icon(Icons.search),
|
||||
suffixIcon: _searchController.text.isNotEmpty
|
||||
? IconButton(
|
||||
icon: const Icon(Icons.clear),
|
||||
onPressed: () {
|
||||
_searchController.clear();
|
||||
ref.read(storeProvider.notifier).setSearchQuery('');
|
||||
},
|
||||
)
|
||||
: null,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(28),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
filled: true,
|
||||
fillColor: Theme.of(context).brightness == Brightness.dark
|
||||
? Color.alphaBlend(Colors.white.withValues(alpha: 0.08), colorScheme.surface)
|
||||
: colorScheme.surfaceContainerHighest,
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
),
|
||||
onChanged: (value) {
|
||||
ref.read(storeProvider.notifier).setSearchQuery(value);
|
||||
setState(() {}); // Update suffix icon
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Category Chips
|
||||
SliverToBoxAdapter(
|
||||
child: SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
_CategoryChip(
|
||||
label: 'All',
|
||||
icon: Icons.apps,
|
||||
isSelected: state.selectedCategory == null,
|
||||
onTap: () => ref.read(storeProvider.notifier).setCategory(null),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_CategoryChip(
|
||||
label: 'Metadata',
|
||||
icon: Icons.label_outline,
|
||||
isSelected: state.selectedCategory == StoreCategory.metadata,
|
||||
onTap: () => ref.read(storeProvider.notifier).setCategory(StoreCategory.metadata),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_CategoryChip(
|
||||
label: 'Download',
|
||||
icon: Icons.download_outlined,
|
||||
isSelected: state.selectedCategory == StoreCategory.download,
|
||||
onTap: () => ref.read(storeProvider.notifier).setCategory(StoreCategory.download),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_CategoryChip(
|
||||
label: 'Utility',
|
||||
icon: Icons.build_outlined,
|
||||
isSelected: state.selectedCategory == StoreCategory.utility,
|
||||
onTap: () => ref.read(storeProvider.notifier).setCategory(StoreCategory.utility),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_CategoryChip(
|
||||
label: 'Lyrics',
|
||||
icon: Icons.lyrics_outlined,
|
||||
isSelected: state.selectedCategory == StoreCategory.lyrics,
|
||||
onTap: () => ref.read(storeProvider.notifier).setCategory(StoreCategory.lyrics),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_CategoryChip(
|
||||
label: 'Integration',
|
||||
icon: Icons.link,
|
||||
isSelected: state.selectedCategory == StoreCategory.integration,
|
||||
onTap: () => ref.read(storeProvider.notifier).setCategory(StoreCategory.integration),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Content
|
||||
if (state.isLoading && state.extensions.isEmpty)
|
||||
const SliverFillRemaining(
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
)
|
||||
else if (state.error != null && state.extensions.isEmpty)
|
||||
SliverFillRemaining(
|
||||
child: _buildErrorState(state.error!, colorScheme),
|
||||
)
|
||||
else if (state.filteredExtensions.isEmpty)
|
||||
SliverFillRemaining(
|
||||
child: _buildEmptyState(state, colorScheme),
|
||||
)
|
||||
else ...[
|
||||
// Extensions count
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
|
||||
child: Text(
|
||||
'${state.filteredExtensions.length} ${state.filteredExtensions.length == 1 ? 'extension' : 'extensions'}',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Extensions list in grouped card (like queue_tab)
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: SettingsGroup(
|
||||
children: state.filteredExtensions.asMap().entries.map((entry) {
|
||||
final index = entry.key;
|
||||
final ext = entry.value;
|
||||
return _ExtensionItem(
|
||||
extension: ext,
|
||||
showDivider: index < state.filteredExtensions.length - 1,
|
||||
isDownloading: state.downloadingId == ext.id,
|
||||
onInstall: () => _installExtension(ext),
|
||||
onUpdate: () => _updateExtension(ext),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Bottom padding
|
||||
const SliverToBoxAdapter(child: SizedBox(height: 16)),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildErrorState(String error, ColorScheme colorScheme) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(32),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.error_outline, size: 64, color: colorScheme.error),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Failed to load store',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
error,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
FilledButton.icon(
|
||||
onPressed: () => ref.read(storeProvider.notifier).refresh(forceRefresh: true),
|
||||
icon: const Icon(Icons.refresh),
|
||||
label: const Text('Retry'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildEmptyState(StoreState state, ColorScheme colorScheme) {
|
||||
final hasFilters = state.searchQuery.isNotEmpty || state.selectedCategory != null;
|
||||
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
hasFilters ? Icons.search_off : Icons.extension_off,
|
||||
size: 64,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
hasFilters ? 'No extensions found' : 'No extensions available',
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
if (hasFilters) ...[
|
||||
const SizedBox(height: 8),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
_searchController.clear();
|
||||
ref.read(storeProvider.notifier).clearSearch();
|
||||
},
|
||||
child: const Text('Clear filters'),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _installExtension(StoreExtension ext) async {
|
||||
final tempDir = await getTemporaryDirectory();
|
||||
final appDir = await getApplicationDocumentsDirectory();
|
||||
final extensionsDir = '${appDir.path}/extensions';
|
||||
|
||||
final success = await ref.read(storeProvider.notifier).installExtension(
|
||||
ext.id,
|
||||
tempDir.path,
|
||||
extensionsDir,
|
||||
);
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(success
|
||||
? '${ext.displayName} installed. Enable it in Settings > Extensions'
|
||||
: 'Failed to install ${ext.displayName}'),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _updateExtension(StoreExtension ext) async {
|
||||
final tempDir = await getTemporaryDirectory();
|
||||
|
||||
final success = await ref.read(storeProvider.notifier).updateExtension(
|
||||
ext.id,
|
||||
tempDir.path,
|
||||
);
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(success
|
||||
? '${ext.displayName} updated to v${ext.version}'
|
||||
: 'Failed to update ${ext.displayName}'),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class _CategoryChip extends StatelessWidget {
|
||||
final String label;
|
||||
final IconData icon;
|
||||
final bool isSelected;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _CategoryChip({
|
||||
required this.label,
|
||||
required this.icon,
|
||||
required this.isSelected,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FilterChip(
|
||||
label: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(icon, size: 16),
|
||||
const SizedBox(width: 6),
|
||||
Text(label),
|
||||
],
|
||||
),
|
||||
selected: isSelected,
|
||||
onSelected: (_) => onTap(),
|
||||
showCheckmark: false,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ExtensionItem extends StatelessWidget {
|
||||
final StoreExtension extension;
|
||||
final bool showDivider;
|
||||
final bool isDownloading;
|
||||
final VoidCallback onInstall;
|
||||
final VoidCallback onUpdate;
|
||||
|
||||
const _ExtensionItem({
|
||||
required this.extension,
|
||||
required this.showDivider,
|
||||
required this.isDownloading,
|
||||
required this.onInstall,
|
||||
required this.onUpdate,
|
||||
});
|
||||
|
||||
IconData _getCategoryIcon(String category) {
|
||||
switch (category) {
|
||||
case StoreCategory.metadata:
|
||||
return Icons.label_outline;
|
||||
case StoreCategory.download:
|
||||
return Icons.download_outlined;
|
||||
case StoreCategory.utility:
|
||||
return Icons.build_outlined;
|
||||
case StoreCategory.lyrics:
|
||||
return Icons.lyrics_outlined;
|
||||
case StoreCategory.integration:
|
||||
return Icons.link;
|
||||
default:
|
||||
return Icons.extension;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
// Extension icon - custom or category-based
|
||||
Container(
|
||||
width: 44,
|
||||
height: 44,
|
||||
decoration: BoxDecoration(
|
||||
color: extension.isInstalled
|
||||
? colorScheme.primaryContainer
|
||||
: colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: extension.iconUrl != null && extension.iconUrl!.isNotEmpty
|
||||
? Image.network(
|
||||
extension.iconUrl!,
|
||||
width: 44,
|
||||
height: 44,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (context, error, stackTrace) => Icon(
|
||||
_getCategoryIcon(extension.category),
|
||||
color: extension.isInstalled
|
||||
? colorScheme.onPrimaryContainer
|
||||
: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
loadingBuilder: (context, child, loadingProgress) {
|
||||
if (loadingProgress == null) return child;
|
||||
return Center(
|
||||
child: SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
value: loadingProgress.expectedTotalBytes != null
|
||||
? loadingProgress.cumulativeBytesLoaded /
|
||||
loadingProgress.expectedTotalBytes!
|
||||
: null,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
)
|
||||
: Icon(
|
||||
_getCategoryIcon(extension.category),
|
||||
color: extension.isInstalled
|
||||
? colorScheme.onPrimaryContainer
|
||||
: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
// Extension info
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
extension.displayName,
|
||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
// Version badge
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Text(
|
||||
'v${extension.version}',
|
||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
'by ${extension.author}',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
extension.description,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
// Action button
|
||||
if (isDownloading)
|
||||
const SizedBox(
|
||||
width: 24,
|
||||
height: 24,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
else if (extension.hasUpdate)
|
||||
FilledButton.tonal(
|
||||
onPressed: onUpdate,
|
||||
style: FilledButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
minimumSize: const Size(0, 36),
|
||||
),
|
||||
child: const Text('Update'),
|
||||
)
|
||||
else if (extension.isInstalled)
|
||||
OutlinedButton(
|
||||
onPressed: null,
|
||||
style: OutlinedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
minimumSize: const Size(0, 36),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.check, size: 16, color: colorScheme.outline),
|
||||
const SizedBox(width: 4),
|
||||
Text('Installed', style: TextStyle(color: colorScheme.outline)),
|
||||
],
|
||||
),
|
||||
)
|
||||
else
|
||||
FilledButton(
|
||||
onPressed: onInstall,
|
||||
style: FilledButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
minimumSize: const Size(0, 36),
|
||||
),
|
||||
child: const Text('Install'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (showDivider)
|
||||
Divider(
|
||||
height: 1,
|
||||
thickness: 1,
|
||||
indent: 76,
|
||||
endIndent: 16,
|
||||
color: colorScheme.outlineVariant.withValues(alpha: 0.3),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -747,6 +753,40 @@ class PlatformBridge {
|
||||
return list.map((e) => e as Map<String, dynamic>).toList();
|
||||
}
|
||||
|
||||
// ==================== EXTENSION URL HANDLER ====================
|
||||
|
||||
/// Handle a URL with any matching extension
|
||||
/// Returns null if no extension can handle the URL
|
||||
static Future<Map<String, dynamic>?> handleURLWithExtension(String url) async {
|
||||
try {
|
||||
final result = await _channel.invokeMethod('handleURLWithExtension', {
|
||||
'url': url,
|
||||
});
|
||||
if (result == null || result == '') return null;
|
||||
return jsonDecode(result as String) as Map<String, dynamic>;
|
||||
} catch (e) {
|
||||
// No extension found or error handling URL
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Find an extension that can handle the given URL
|
||||
/// Returns extension ID or null if none found
|
||||
static Future<String?> findURLHandler(String url) async {
|
||||
final result = await _channel.invokeMethod('findURLHandler', {
|
||||
'url': url,
|
||||
});
|
||||
if (result == null || result == '') return null;
|
||||
return result as String;
|
||||
}
|
||||
|
||||
/// Get all extensions that handle custom URLs
|
||||
static Future<List<Map<String, dynamic>>> getURLHandlers() async {
|
||||
final result = await _channel.invokeMethod('getURLHandlers');
|
||||
final list = jsonDecode(result as String) as List<dynamic>;
|
||||
return list.map((e) => e as Map<String, dynamic>).toList();
|
||||
}
|
||||
|
||||
// ==================== EXTENSION POST-PROCESSING ====================
|
||||
|
||||
/// Run post-processing hooks on a file
|
||||
@@ -767,4 +807,56 @@ class PlatformBridge {
|
||||
final list = jsonDecode(result as String) as List<dynamic>;
|
||||
return list.map((e) => e as Map<String, dynamic>).toList();
|
||||
}
|
||||
|
||||
// ==================== EXTENSION STORE ====================
|
||||
|
||||
/// Initialize extension store
|
||||
static Future<void> initExtensionStore(String cacheDir) async {
|
||||
_log.d('initExtensionStore: $cacheDir');
|
||||
await _channel.invokeMethod('initExtensionStore', {'cache_dir': cacheDir});
|
||||
}
|
||||
|
||||
/// Get all extensions from store with installation status
|
||||
static Future<List<Map<String, dynamic>>> getStoreExtensions({bool forceRefresh = false}) async {
|
||||
_log.d('getStoreExtensions (forceRefresh: $forceRefresh)');
|
||||
final result = await _channel.invokeMethod('getStoreExtensions', {
|
||||
'force_refresh': forceRefresh,
|
||||
});
|
||||
final list = jsonDecode(result as String) as List<dynamic>;
|
||||
return list.map((e) => e as Map<String, dynamic>).toList();
|
||||
}
|
||||
|
||||
/// Search extensions in store
|
||||
static Future<List<Map<String, dynamic>>> searchStoreExtensions(String query, {String? category}) async {
|
||||
_log.d('searchStoreExtensions: "$query" (category: $category)');
|
||||
final result = await _channel.invokeMethod('searchStoreExtensions', {
|
||||
'query': query,
|
||||
'category': category ?? '',
|
||||
});
|
||||
final list = jsonDecode(result as String) as List<dynamic>;
|
||||
return list.map((e) => e as Map<String, dynamic>).toList();
|
||||
}
|
||||
|
||||
/// Get store categories
|
||||
static Future<List<String>> getStoreCategories() async {
|
||||
final result = await _channel.invokeMethod('getStoreCategories');
|
||||
final list = jsonDecode(result as String) as List<dynamic>;
|
||||
return list.cast<String>();
|
||||
}
|
||||
|
||||
/// Download extension from store
|
||||
static Future<String> downloadStoreExtension(String extensionId, String destDir) async {
|
||||
_log.i('downloadStoreExtension: $extensionId to $destDir');
|
||||
final result = await _channel.invokeMethod('downloadStoreExtension', {
|
||||
'extension_id': extensionId,
|
||||
'dest_dir': destDir,
|
||||
});
|
||||
return result as String;
|
||||
}
|
||||
|
||||
/// Clear store cache
|
||||
static Future<void> clearStoreCache() async {
|
||||
_log.d('clearStoreCache');
|
||||
await _channel.invokeMethod('clearStoreCache');
|
||||
}
|
||||
}
|
||||
|
||||
+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.4+53
|
||||
|
||||
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.4+53
|
||||
|
||||
environment:
|
||||
sdk: ^3.10.0
|
||||
|
||||
Reference in New Issue
Block a user