From 57cb575483fff06da2ffef43014a28e693de445f Mon Sep 17 00:00:00 2001 From: zarzet Date: Mon, 12 Jan 2026 00:17:52 +0700 Subject: [PATCH 01/35] feat: add extension system with skipBuiltInFallback support - Add extension manager, manifest, runtime, providers, settings - Add extension provider and UI pages (extensions, detail, priority) - Add download service picker widget - Add metadata provider priority page - Add source field to Track model for extension tracking - Add skipBuiltInFallback manifest option to skip built-in providers - Update download queue to use source extension first - Add extension upgrade support without data loss --- .gitignore | 3 + CHANGELOG.md | 93 + README.md | 25 + .../kotlin/com/zarz/spotiflac/MainActivity.kt | 243 +++ go_backend/amazon.go | 2 +- go_backend/exports.go | 522 +++++ go_backend/extension_manager.go | 970 +++++++++ go_backend/extension_manifest.go | 284 +++ go_backend/extension_providers.go | 1183 ++++++++++ go_backend/extension_runtime.go | 1935 +++++++++++++++++ go_backend/extension_settings.go | 221 ++ go_backend/extension_test.go | 219 ++ go_backend/go.mod | 5 + go_backend/go.sum | 14 + go_backend/httputil.go | 111 +- go_backend/lyrics.go | 47 +- go_backend/parallel.go | 4 +- go_backend/progress.go | 15 +- go_backend/qobuz.go | 17 +- go_backend/tidal.go | 215 +- ios/Runner/AppDelegate.swift | 215 ++ lib/constants/app_info.dart | 4 +- lib/models/settings.dart | 8 + lib/models/settings.g.dart | 4 + lib/models/track.dart | 5 + lib/models/track.g.dart | 2 + lib/providers/download_queue_provider.dart | 88 +- lib/providers/extension_provider.dart | 655 ++++++ lib/providers/settings_provider.dart | 10 + lib/providers/track_provider.dart | 108 +- lib/screens/album_screen.dart | 238 +- lib/screens/home_tab.dart | 338 +-- lib/screens/playlist_screen.dart | 235 +- .../settings/download_settings_page.dart | 40 +- .../settings/extension_detail_page.dart | 964 ++++++++ lib/screens/settings/extensions_page.dart | 721 ++++++ .../metadata_provider_priority_page.dart | 366 ++++ .../settings/options_settings_page.dart | 103 +- .../settings/provider_priority_page.dart | 369 ++++ lib/screens/settings/settings_tab.dart | 19 +- lib/services/platform_bridge.dart | 328 +++ lib/widgets/download_service_picker.dart | 483 ++++ lib/widgets/settings_group.dart | 74 +- pubspec.yaml | 2 +- pubspec_ios.yaml | 2 +- 45 files changed, 10491 insertions(+), 1018 deletions(-) create mode 100644 go_backend/extension_manager.go create mode 100644 go_backend/extension_manifest.go create mode 100644 go_backend/extension_providers.go create mode 100644 go_backend/extension_runtime.go create mode 100644 go_backend/extension_settings.go create mode 100644 go_backend/extension_test.go create mode 100644 lib/providers/extension_provider.dart create mode 100644 lib/screens/settings/extension_detail_page.dart create mode 100644 lib/screens/settings/extensions_page.dart create mode 100644 lib/screens/settings/metadata_provider_priority_page.dart create mode 100644 lib/screens/settings/provider_priority_page.dart create mode 100644 lib/widgets/download_service_picker.dart diff --git a/.gitignore b/.gitignore index d0594280..1a452108 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,9 @@ Thumbs.db # Reference folder (development only) referensi/ +# Documentation (development only, published separately) +docs/ + # Old spotiflac_android folder (moved to root) spotiflac_android/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 1e13a7fa..ea2d9cea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,98 @@ # Changelog +## [3.0.0-alpha.1] - 2026-01-11 + +#### Extension System +- **Custom Search Providers**: Extensions can now provide custom search functionality + - YouTube, SoundCloud, and other platforms via extensions + - Custom search placeholder text per extension + - Configurable thumbnail aspect ratios (square, wide, portrait) +- **Extension Upgrade System**: Upgrade extensions without losing data + - Preserves extension settings and cached data during upgrades + - Version comparison prevents downgrades + - Auto-detects upgrades when installing same extension +- **Custom Thumbnail Ratios**: Extensions can specify thumbnail display format + - `"square"` (1:1) - Album art style (default) + - `"wide"` (16:9) - YouTube/video style + - `"portrait"` (2:3) - Poster style + - Custom width/height override available + +### Added + +- **Track Source Tracking**: Tracks now remember which extension provided them + - `Track.source` field stores extension ID + - `TrackState.searchExtensionId` for current search context + - Enables extension-specific UI customization +- **Extension Upgrade API**: New methods for extension management + - `upgradeExtension(filePath)` - Upgrade existing extension + - `checkExtensionUpgrade(filePath)` - Check if file is an upgrade + - `RemoveExtensionByID` - Remove extension by ID +- **iOS Extension Support**: Added missing iOS method handlers + - `upgradeExtension` - Upgrade extension from file + - `checkExtensionUpgrade` - Check upgrade compatibility +- **Extension Documentation**: Comprehensive extension development guide + - Thumbnail ratio customization documentation + - Extension upgrade workflow documentation + - New troubleshooting entries for common issues + +### Changed + +- **Version Bump**: 2.2.7 → 3.0.0-alpha.1 (major version for extension system) +- **Build Number**: 49 → 50 +- **Extension Manager**: Improved upgrade detection in `LoadExtensionFromFile` + - Auto-detects if installing same extension with higher version + - Calls `UpgradeExtension` automatically for seamless upgrades + +### Fixed + +- **Extension `registerExtension`**: Fixed global `extension` variable not being set + - Extensions can now access their own functions via `extension.functionName()` + - Required for `customSearch` and other provider functions +- **Custom Search Empty Results**: Fixed error when extension returns null + - Now returns empty array instead of error + - Prevents crash when no results found +- **Mutex Crash on Upgrade**: Fixed "Unlock of unlocked RWMutex" crash + - Removed `defer m.mu.Unlock()` when manual unlock is used + - Proper lock handling in upgrade flow +- **Duplicate Error Messages**: Fixed extension install errors showing twice + - Added `clearError()` method to extension provider + - Improved PlatformException parsing to remove "null, null" artifacts +- **Extension Images Field**: Fixed thumbnails not showing in search results + - Added `Images` field to `ExtTrackMetadata` struct + - Renamed `GetCoverURL` to `ResolvedCoverURL` (gomobile conflict) + +### Technical + +- **Go Backend Changes**: + - `go_backend/extension_manager.go`: Added `compareVersions()`, `UpgradeExtension()`, `CheckExtensionUpgradeJSON()` + - `go_backend/extension_providers.go`: Added `Images` field, `ResolvedCoverURL()` method + - `go_backend/extension_manifest.go`: Added `ThumbnailRatio`, `ThumbnailWidth`, `ThumbnailHeight` to `SearchBehaviorConfig` + - `go_backend/exports.go`: Added `RemoveExtensionByID`, `UpgradeExtensionFromPath`, `CheckExtensionUpgradeFromPath` +- **Flutter Changes**: + - `lib/models/track.dart`: Added `source` field + - `lib/models/track.g.dart`: Updated for `source` field + - `lib/providers/track_provider.dart`: Added `searchExtensionId`, updated `_parseSearchTrack` with source parameter + - `lib/providers/extension_provider.dart`: Added `SearchBehavior.getThumbnailSize()`, `clearError()` + - `lib/screens/home_tab.dart`: Dynamic thumbnail size based on extension config + - `lib/screens/settings/extensions_page.dart`: Improved error handling + - `lib/services/platform_bridge.dart`: Added `upgradeExtension()`, `checkExtensionUpgrade()`, `removeExtension()` +- **iOS Changes**: + - `ios/Runner/AppDelegate.swift`: Added `upgradeExtension`, `checkExtensionUpgrade` handlers +- **Android Changes**: + - `android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt`: Already had upgrade methods + +### Documentation + +- Updated `docs/EXTENSION_DEVELOPMENT.md`: + - Added thumbnail ratio customization section + - Added extension upgrade documentation + - Added settings fields table with `secret` field + - Added new troubleshooting entries + - Updated table of contents + - Updated changelog + +--- + ## [2.2.7] - 2026-01-11 ### Added diff --git a/README.md b/README.md index 9bcecccd..a77e2ffd 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,31 @@ To use Spotify as your search source without hitting rate limits: 4. Enter your Client ID and Secret 5. Change **Search Source** to Spotify +## Extensions (Alpha) + +> **Alpha Feature**: Extensions are now available in alpha. Some features may be unstable or change in future releases. + +SpotiFLAC supports extensions to add custom metadata and download providers. Extensions are written in JavaScript and run in a secure sandbox. + +### Features +- **Metadata Providers**: Add new sources for track/album/artist search +- **Download Providers**: Add new sources for audio downloads +- **Custom Settings**: Extensions can have user-configurable settings +- **Provider Priority**: Set the order in which providers are tried + +### Installing Extensions +1. Download a `.spotiflac-ext` file +2. Go to **Settings > Extensions** +3. Tap **Install Extension** and select the file +4. Configure extension settings if needed +5. Set provider priority in **Settings > Extensions > Provider Priority** + +### Developing Extensions +Want to create your own extension? Check out the [Extension Development Guide](docs/EXTENSION_DEVELOPMENT.md) for complete documentation. + +### Example Extensions +Sample extensions are available in the [docs/extensions_example](docs/extensions_example) folder: + ## Other project ### [SpotiFLAC (Desktop)](https://github.com/afkarxyz/SpotiFLAC) diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt index fb87483d..46650c87 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt @@ -317,6 +317,249 @@ class MainActivity: FlutterActivity() { } result.success(null) } + // Extension System methods + "initExtensionSystem" -> { + val extensionsDir = call.argument("extensions_dir") ?: "" + val dataDir = call.argument("data_dir") ?: "" + withContext(Dispatchers.IO) { + Gobackend.initExtensionSystem(extensionsDir, dataDir) + } + result.success(null) + } + "loadExtensionsFromDir" -> { + val dirPath = call.argument("dir_path") ?: "" + val response = withContext(Dispatchers.IO) { + Gobackend.loadExtensionsFromDir(dirPath) + } + result.success(response) + } + "loadExtensionFromPath" -> { + val filePath = call.argument("file_path") ?: "" + val response = withContext(Dispatchers.IO) { + Gobackend.loadExtensionFromPath(filePath) + } + result.success(response) + } + "unloadExtension" -> { + val extensionId = call.argument("extension_id") ?: "" + withContext(Dispatchers.IO) { + Gobackend.unloadExtensionByID(extensionId) + } + result.success(null) + } + "removeExtension" -> { + val extensionId = call.argument("extension_id") ?: "" + withContext(Dispatchers.IO) { + Gobackend.removeExtensionByID(extensionId) + } + result.success(null) + } + "upgradeExtension" -> { + val filePath = call.argument("file_path") ?: "" + val response = withContext(Dispatchers.IO) { + Gobackend.upgradeExtensionFromPath(filePath) + } + result.success(response) + } + "checkExtensionUpgrade" -> { + val filePath = call.argument("file_path") ?: "" + val response = withContext(Dispatchers.IO) { + Gobackend.checkExtensionUpgradeFromPath(filePath) + } + result.success(response) + } + "getInstalledExtensions" -> { + val response = withContext(Dispatchers.IO) { + Gobackend.getInstalledExtensions() + } + result.success(response) + } + "setExtensionEnabled" -> { + val extensionId = call.argument("extension_id") ?: "" + val enabled = call.argument("enabled") ?: false + withContext(Dispatchers.IO) { + Gobackend.setExtensionEnabledByID(extensionId, enabled) + } + result.success(null) + } + "setProviderPriority" -> { + val priorityJson = call.argument("priority") ?: "[]" + withContext(Dispatchers.IO) { + Gobackend.setProviderPriorityJSON(priorityJson) + } + result.success(null) + } + "getProviderPriority" -> { + val response = withContext(Dispatchers.IO) { + Gobackend.getProviderPriorityJSON() + } + result.success(response) + } + "setMetadataProviderPriority" -> { + val priorityJson = call.argument("priority") ?: "[]" + withContext(Dispatchers.IO) { + Gobackend.setMetadataProviderPriorityJSON(priorityJson) + } + result.success(null) + } + "getMetadataProviderPriority" -> { + val response = withContext(Dispatchers.IO) { + Gobackend.getMetadataProviderPriorityJSON() + } + result.success(response) + } + "getExtensionSettings" -> { + val extensionId = call.argument("extension_id") ?: "" + val response = withContext(Dispatchers.IO) { + Gobackend.getExtensionSettingsJSON(extensionId) + } + result.success(response) + } + "setExtensionSettings" -> { + val extensionId = call.argument("extension_id") ?: "" + val settingsJson = call.argument("settings") ?: "{}" + withContext(Dispatchers.IO) { + Gobackend.setExtensionSettingsJSON(extensionId, settingsJson) + } + result.success(null) + } + "searchTracksWithExtensions" -> { + val query = call.argument("query") ?: "" + val limit = call.argument("limit") ?: 20 + val response = withContext(Dispatchers.IO) { + Gobackend.searchTracksWithExtensionsJSON(query, limit.toLong()) + } + result.success(response) + } + "downloadWithExtensions" -> { + val requestJson = call.arguments as String + val response = withContext(Dispatchers.IO) { + Gobackend.downloadWithExtensionsJSON(requestJson) + } + result.success(response) + } + "removeExtension" -> { + val extensionId = call.argument("extension_id") ?: "" + withContext(Dispatchers.IO) { + Gobackend.removeExtensionByID(extensionId) + } + result.success(null) + } + "cleanupExtensions" -> { + withContext(Dispatchers.IO) { + Gobackend.cleanupExtensions() + } + result.success(null) + } + // Extension Auth API methods + "getExtensionPendingAuth" -> { + val extensionId = call.argument("extension_id") ?: "" + val response = withContext(Dispatchers.IO) { + Gobackend.getExtensionPendingAuthJSON(extensionId) + } + if (response.isNullOrEmpty()) { + result.success(null) + } else { + result.success(response) + } + } + "setExtensionAuthCode" -> { + val extensionId = call.argument("extension_id") ?: "" + val authCode = call.argument("auth_code") ?: "" + withContext(Dispatchers.IO) { + Gobackend.setExtensionAuthCodeByID(extensionId, authCode) + } + result.success(null) + } + "setExtensionTokens" -> { + val extensionId = call.argument("extension_id") ?: "" + val accessToken = call.argument("access_token") ?: "" + val refreshToken = call.argument("refresh_token") ?: "" + val expiresIn = call.argument("expires_in") ?: 0 + withContext(Dispatchers.IO) { + Gobackend.setExtensionTokensByID(extensionId, accessToken, refreshToken, expiresIn.toLong()) + } + result.success(null) + } + "clearExtensionPendingAuth" -> { + val extensionId = call.argument("extension_id") ?: "" + withContext(Dispatchers.IO) { + Gobackend.clearExtensionPendingAuthByID(extensionId) + } + result.success(null) + } + "isExtensionAuthenticated" -> { + val extensionId = call.argument("extension_id") ?: "" + val isAuth = withContext(Dispatchers.IO) { + Gobackend.isExtensionAuthenticatedByID(extensionId) + } + result.success(isAuth) + } + "getAllPendingAuthRequests" -> { + val response = withContext(Dispatchers.IO) { + Gobackend.getAllPendingAuthRequestsJSON() + } + result.success(response) + } + // Extension FFmpeg API + "getPendingFFmpegCommand" -> { + val commandId = call.argument("command_id") ?: "" + val response = withContext(Dispatchers.IO) { + Gobackend.getPendingFFmpegCommandJSON(commandId) + } + if (response.isNullOrEmpty()) { + result.success(null) + } else { + result.success(response) + } + } + "setFFmpegCommandResult" -> { + val commandId = call.argument("command_id") ?: "" + val success = call.argument("success") ?: false + val output = call.argument("output") ?: "" + val error = call.argument("error") ?: "" + withContext(Dispatchers.IO) { + Gobackend.setFFmpegCommandResultByID(commandId, success, output, error) + } + result.success(null) + } + "getAllPendingFFmpegCommands" -> { + val response = withContext(Dispatchers.IO) { + Gobackend.getAllPendingFFmpegCommandsJSON() + } + result.success(response) + } + // Extension Custom Search API + "customSearchWithExtension" -> { + val extensionId = call.argument("extension_id") ?: "" + val query = call.argument("query") ?: "" + val optionsJson = call.argument("options") ?: "" + val response = withContext(Dispatchers.IO) { + Gobackend.customSearchWithExtensionJSON(extensionId, query, optionsJson) + } + result.success(response) + } + "getSearchProviders" -> { + val response = withContext(Dispatchers.IO) { + Gobackend.getSearchProvidersJSON() + } + result.success(response) + } + // Extension Post-Processing API + "runPostProcessing" -> { + val filePath = call.argument("file_path") ?: "" + val metadataJson = call.argument("metadata") ?: "" + val response = withContext(Dispatchers.IO) { + Gobackend.runPostProcessingJSON(filePath, metadataJson) + } + result.success(response) + } + "getPostProcessingProviders" -> { + val response = withContext(Dispatchers.IO) { + Gobackend.getPostProcessingProvidersJSON() + } + result.success(response) + } else -> result.notImplemented() } } catch (e: Exception) { diff --git a/go_backend/amazon.go b/go_backend/amazon.go index e8db3cb3..35860f5f 100644 --- a/go_backend/amazon.go +++ b/go_backend/amazon.go @@ -173,7 +173,7 @@ func (a *AmazonDownloader) GetAvailableAPIs() []string { // downloadFromDoubleDoubleService downloads a track using DoubleDouble service (same as PC) // This uses submit → poll → download mechanism // Internal function - not exported to gomobile -func (a *AmazonDownloader) downloadFromDoubleDoubleService(amazonURL, outputDir string) (string, string, string, error) { +func (a *AmazonDownloader) downloadFromDoubleDoubleService(amazonURL, _ string) (string, string, string, error) { var lastError error for _, region := range a.regions { diff --git a/go_backend/exports.go b/go_backend/exports.go index 98a9b978..656fa28b 100644 --- a/go_backend/exports.go +++ b/go_backend/exports.go @@ -135,6 +135,7 @@ type DownloadRequest struct { ReleaseDate string `json:"release_date"` ItemID string `json:"item_id"` // Unique ID for progress tracking DurationMS int `json:"duration_ms"` // Expected duration in milliseconds (for verification) + Source string `json:"source"` // Extension ID that provided this track (prioritize this extension) } // DownloadResponse represents the result of a download @@ -152,10 +153,14 @@ type DownloadResponse struct { Title string `json:"title,omitempty"` Artist string `json:"artist,omitempty"` Album string `json:"album,omitempty"` + AlbumArtist string `json:"album_artist,omitempty"` ReleaseDate string `json:"release_date,omitempty"` TrackNumber int `json:"track_number,omitempty"` DiscNumber int `json:"disc_number,omitempty"` ISRC string `json:"isrc,omitempty"` + CoverURL string `json:"cover_url,omitempty"` + // If true, skip metadata enrichment from Deezer/Spotify (extension already provides metadata) + SkipMetadataEnrichment bool `json:"skip_metadata_enrichment,omitempty"` } // DownloadResult is a generic result type for all downloaders @@ -1016,3 +1021,520 @@ func errorResponse(msg string) (string, error) { jsonBytes, _ := json.Marshal(resp) return string(jsonBytes), nil } + +// ==================== EXTENSION SYSTEM ==================== + +// InitExtensionSystem initializes the extension system with directories +func InitExtensionSystem(extensionsDir, dataDir string) error { + manager := GetExtensionManager() + if err := manager.SetDirectories(extensionsDir, dataDir); err != nil { + return err + } + + settingsStore := GetExtensionSettingsStore() + if err := settingsStore.SetDataDir(dataDir); err != nil { + return err + } + + return nil +} + +// LoadExtensionsFromDir loads all extensions from a directory +func LoadExtensionsFromDir(dirPath string) (string, error) { + manager := GetExtensionManager() + loaded, errors := manager.LoadExtensionsFromDirectory(dirPath) + + result := map[string]interface{}{ + "loaded": loaded, + "errors": make([]string, len(errors)), + } + + for i, err := range errors { + result["errors"].([]string)[i] = err.Error() + } + + jsonBytes, err := json.Marshal(result) + if err != nil { + return "", err + } + + return string(jsonBytes), nil +} + +// LoadExtensionFromPath loads a single extension from a .spotiflac-ext file +func LoadExtensionFromPath(filePath string) (string, error) { + manager := GetExtensionManager() + ext, err := manager.LoadExtensionFromFile(filePath) + if err != nil { + return "", err + } + + // Initialize with saved settings + settingsStore := GetExtensionSettingsStore() + settings := settingsStore.GetAll(ext.ID) + if len(settings) > 0 { + manager.InitializeExtension(ext.ID, settings) + } + + result := map[string]interface{}{ + "id": ext.ID, + "name": ext.Manifest.Name, + "display_name": ext.Manifest.DisplayName, + "version": ext.Manifest.Version, + "enabled": ext.Enabled, + } + + jsonBytes, err := json.Marshal(result) + if err != nil { + return "", err + } + + return string(jsonBytes), nil +} + +// UnloadExtensionByID unloads an extension +func UnloadExtensionByID(extensionID string) error { + manager := GetExtensionManager() + return manager.UnloadExtension(extensionID) +} + +// RemoveExtensionByID completely removes an extension (unload + delete files) +func RemoveExtensionByID(extensionID string) error { + manager := GetExtensionManager() + return manager.RemoveExtension(extensionID) +} + +// UpgradeExtensionFromPath upgrades an existing extension from a new package file +func UpgradeExtensionFromPath(filePath string) (string, error) { + manager := GetExtensionManager() + ext, err := manager.UpgradeExtension(filePath) + if err != nil { + return "", err + } + + // Initialize with saved settings + settingsStore := GetExtensionSettingsStore() + settings := settingsStore.GetAll(ext.ID) + if len(settings) > 0 { + manager.InitializeExtension(ext.ID, settings) + } + + // Return extension info as JSON + result := map[string]interface{}{ + "id": ext.ID, + "display_name": ext.Manifest.DisplayName, + "version": ext.Manifest.Version, + "enabled": ext.Enabled, + } + + jsonBytes, err := json.Marshal(result) + if err != nil { + return "", err + } + + return string(jsonBytes), nil +} + +// CheckExtensionUpgradeFromPath checks if a package file is an upgrade for an existing extension +func CheckExtensionUpgradeFromPath(filePath string) (string, error) { + manager := GetExtensionManager() + return manager.CheckExtensionUpgradeJSON(filePath) +} + +// GetInstalledExtensions returns all installed extensions as JSON +func GetInstalledExtensions() (string, error) { + manager := GetExtensionManager() + return manager.GetInstalledExtensionsJSON() +} + +// SetExtensionEnabledByID enables or disables an extension +func SetExtensionEnabledByID(extensionID string, enabled bool) error { + manager := GetExtensionManager() + return manager.SetExtensionEnabled(extensionID, enabled) +} + +// SetProviderPriorityJSON sets the provider priority order from JSON array +func SetProviderPriorityJSON(priorityJSON string) error { + var priority []string + if err := json.Unmarshal([]byte(priorityJSON), &priority); err != nil { + return err + } + + SetProviderPriority(priority) + return nil +} + +// GetProviderPriorityJSON returns the provider priority order as JSON +func GetProviderPriorityJSON() (string, error) { + priority := GetProviderPriority() + jsonBytes, err := json.Marshal(priority) + if err != nil { + return "", err + } + return string(jsonBytes), nil +} + +// SetMetadataProviderPriorityJSON sets the metadata provider priority order from JSON array +func SetMetadataProviderPriorityJSON(priorityJSON string) error { + var priority []string + if err := json.Unmarshal([]byte(priorityJSON), &priority); err != nil { + return err + } + + SetMetadataProviderPriority(priority) + return nil +} + +// GetMetadataProviderPriorityJSON returns the metadata provider priority order as JSON +func GetMetadataProviderPriorityJSON() (string, error) { + priority := GetMetadataProviderPriority() + jsonBytes, err := json.Marshal(priority) + if err != nil { + return "", err + } + return string(jsonBytes), nil +} + +// GetExtensionSettingsJSON returns settings for an extension as JSON +func GetExtensionSettingsJSON(extensionID string) (string, error) { + store := GetExtensionSettingsStore() + settings := store.GetAll(extensionID) + + jsonBytes, err := json.Marshal(settings) + if err != nil { + return "", err + } + + return string(jsonBytes), nil +} + +// SetExtensionSettingsJSON sets settings for an extension from JSON +func SetExtensionSettingsJSON(extensionID, settingsJSON string) error { + var settings map[string]interface{} + if err := json.Unmarshal([]byte(settingsJSON), &settings); err != nil { + return err + } + + store := GetExtensionSettingsStore() + if err := store.SetAll(extensionID, settings); err != nil { + return err + } + + // Re-initialize extension with new settings + manager := GetExtensionManager() + return manager.InitializeExtension(extensionID, settings) +} + +// SearchTracksWithExtensionsJSON searches all extension metadata providers +func SearchTracksWithExtensionsJSON(query string, limit int) (string, error) { + manager := GetExtensionManager() + tracks, err := manager.SearchTracksWithExtensions(query, limit) + if err != nil { + return "", err + } + + jsonBytes, err := json.Marshal(tracks) + if err != nil { + return "", err + } + + return string(jsonBytes), nil +} + +// DownloadWithExtensionsJSON downloads using extension providers with fallback +func DownloadWithExtensionsJSON(requestJSON string) (string, error) { + var req DownloadRequest + if err := json.Unmarshal([]byte(requestJSON), &req); err != nil { + return "", fmt.Errorf("invalid request: %w", err) + } + + result, err := DownloadWithExtensionFallback(req) + if err != nil { + return "", err + } + + jsonBytes, err := json.Marshal(result) + if err != nil { + return "", err + } + + return string(jsonBytes), nil +} + +// CleanupExtensions unloads all extensions gracefully +func CleanupExtensions() { + manager := GetExtensionManager() + manager.UnloadAllExtensions() +} + +// ==================== EXTENSION AUTH API ==================== + +// GetExtensionPendingAuthJSON returns pending auth request for an extension +func GetExtensionPendingAuthJSON(extensionID string) (string, error) { + req := GetPendingAuthRequest(extensionID) + if req == nil { + return "", nil + } + + result := map[string]interface{}{ + "extension_id": req.ExtensionID, + "auth_url": req.AuthURL, + "callback_url": req.CallbackURL, + } + + jsonBytes, err := json.Marshal(result) + if err != nil { + return "", err + } + + return string(jsonBytes), nil +} + +// SetExtensionAuthCodeByID sets auth code for an extension (called from Flutter after OAuth callback) +func SetExtensionAuthCodeByID(extensionID, authCode string) { + SetExtensionAuthCode(extensionID, authCode) +} + +// SetExtensionTokensByID sets tokens for an extension +func SetExtensionTokensByID(extensionID, accessToken, refreshToken string, expiresIn int) { + var expiresAt time.Time + if expiresIn > 0 { + expiresAt = time.Now().Add(time.Duration(expiresIn) * time.Second) + } + SetExtensionTokens(extensionID, accessToken, refreshToken, expiresAt) +} + +// ClearExtensionPendingAuthByID clears pending auth request for an extension +func ClearExtensionPendingAuthByID(extensionID string) { + ClearPendingAuthRequest(extensionID) +} + +// IsExtensionAuthenticatedByID checks if an extension is authenticated +func IsExtensionAuthenticatedByID(extensionID string) bool { + extensionAuthStateMu.RLock() + defer extensionAuthStateMu.RUnlock() + + state, exists := extensionAuthState[extensionID] + if !exists { + return false + } + + // Check if token is expired + if state.IsAuthenticated && !state.ExpiresAt.IsZero() && time.Now().After(state.ExpiresAt) { + return false + } + + return state.IsAuthenticated +} + +// GetAllPendingAuthRequestsJSON returns all pending auth requests +func GetAllPendingAuthRequestsJSON() (string, error) { + pendingAuthRequestsMu.RLock() + defer pendingAuthRequestsMu.RUnlock() + + requests := make([]map[string]interface{}, 0, len(pendingAuthRequests)) + for _, req := range pendingAuthRequests { + requests = append(requests, map[string]interface{}{ + "extension_id": req.ExtensionID, + "auth_url": req.AuthURL, + "callback_url": req.CallbackURL, + }) + } + + jsonBytes, err := json.Marshal(requests) + if err != nil { + return "", err + } + + return string(jsonBytes), nil +} + +// ==================== EXTENSION FFMPEG API ==================== + +// GetPendingFFmpegCommandJSON returns a pending FFmpeg command for Flutter to execute +func GetPendingFFmpegCommandJSON(commandID string) (string, error) { + cmd := GetPendingFFmpegCommand(commandID) + if cmd == nil { + return "", nil + } + + result := map[string]interface{}{ + "command_id": commandID, + "extension_id": cmd.ExtensionID, + "command": cmd.Command, + "input_path": cmd.InputPath, + "output_path": cmd.OutputPath, + } + + jsonBytes, err := json.Marshal(result) + if err != nil { + return "", err + } + + return string(jsonBytes), nil +} + +// SetFFmpegCommandResultByID sets the result of an FFmpeg command +func SetFFmpegCommandResultByID(commandID string, success bool, output, errorMsg string) { + SetFFmpegCommandResult(commandID, success, output, errorMsg) +} + +// GetAllPendingFFmpegCommandsJSON returns all pending FFmpeg commands +func GetAllPendingFFmpegCommandsJSON() (string, error) { + ffmpegCommandsMu.RLock() + defer ffmpegCommandsMu.RUnlock() + + commands := make([]map[string]interface{}, 0) + for cmdID, cmd := range ffmpegCommands { + if !cmd.Completed { + commands = append(commands, map[string]interface{}{ + "command_id": cmdID, + "extension_id": cmd.ExtensionID, + "command": cmd.Command, + }) + } + } + + jsonBytes, err := json.Marshal(commands) + if err != nil { + return "", err + } + + return string(jsonBytes), nil +} + +// ==================== EXTENSION CUSTOM SEARCH ==================== + +// CustomSearchWithExtensionJSON performs custom search using an extension +func CustomSearchWithExtensionJSON(extensionID, query string, optionsJSON string) (string, error) { + manager := GetExtensionManager() + ext, err := manager.GetExtension(extensionID) + if err != nil { + return "", err + } + + if !ext.Manifest.HasCustomSearch() { + return "", fmt.Errorf("extension '%s' does not support custom search", extensionID) + } + + var options map[string]interface{} + if optionsJSON != "" { + if err := json.Unmarshal([]byte(optionsJSON), &options); err != nil { + options = make(map[string]interface{}) + } + } + + provider := NewExtensionProviderWrapper(ext) + tracks, err := provider.CustomSearch(query, options) + if err != nil { + return "", err + } + + // Convert to map format for Flutter, ensuring images field is set + result := make([]map[string]interface{}, len(tracks)) + for i, track := range tracks { + result[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(), // Use helper to get cover URL from either field + "release_date": track.ReleaseDate, + "track_number": track.TrackNumber, + "disc_number": track.DiscNumber, + "isrc": track.ISRC, + "provider_id": track.ProviderID, + } + } + + jsonBytes, err := json.Marshal(result) + if err != nil { + return "", err + } + + return string(jsonBytes), nil +} + +// GetSearchProvidersJSON returns all extensions that provide custom search +func GetSearchProvidersJSON() (string, error) { + manager := GetExtensionManager() + providers := manager.GetSearchProviders() + + result := make([]map[string]interface{}, 0, len(providers)) + for _, p := range providers { + result = append(result, map[string]interface{}{ + "id": p.extension.ID, + "display_name": p.extension.Manifest.DisplayName, + "placeholder": p.extension.Manifest.SearchBehavior.Placeholder, + "primary": p.extension.Manifest.SearchBehavior.Primary, + "icon": p.extension.Manifest.SearchBehavior.Icon, + }) + } + + 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 +func RunPostProcessingJSON(filePath, metadataJSON string) (string, error) { + var metadata map[string]interface{} + if metadataJSON != "" { + if err := json.Unmarshal([]byte(metadataJSON), &metadata); err != nil { + metadata = make(map[string]interface{}) + } + } + + manager := GetExtensionManager() + result, err := manager.RunPostProcessing(filePath, metadata) + if err != nil { + return "", err + } + + jsonBytes, err := json.Marshal(result) + if err != nil { + return "", err + } + + return string(jsonBytes), nil +} + +// GetPostProcessingProvidersJSON returns all extensions that provide post-processing +func GetPostProcessingProvidersJSON() (string, error) { + manager := GetExtensionManager() + providers := manager.GetPostProcessingProviders() + + result := make([]map[string]interface{}, 0, len(providers)) + for _, p := range providers { + hooks := make([]map[string]interface{}, 0) + for _, h := range p.extension.Manifest.GetPostProcessingHooks() { + hooks = append(hooks, map[string]interface{}{ + "id": h.ID, + "name": h.Name, + "description": h.Description, + "default_enabled": h.DefaultEnabled, + "supported_formats": h.SupportedFormats, + }) + } + + result = append(result, map[string]interface{}{ + "id": p.extension.ID, + "display_name": p.extension.Manifest.DisplayName, + "hooks": hooks, + }) + } + + jsonBytes, err := json.Marshal(result) + if err != nil { + return "", err + } + + return string(jsonBytes), nil +} diff --git a/go_backend/extension_manager.go b/go_backend/extension_manager.go new file mode 100644 index 00000000..ade22a35 --- /dev/null +++ b/go_backend/extension_manager.go @@ -0,0 +1,970 @@ +// Package gobackend provides extension management functionality +package gobackend + +import ( + "archive/zip" + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" + "strconv" + "strings" + "sync" + + "github.com/dop251/goja" +) + +// compareVersions compares two semantic version strings +// Returns: -1 if v1 < v2, 0 if v1 == v2, 1 if v1 > v2 +func compareVersions(v1, v2 string) int { + // Parse version parts + parts1 := strings.Split(strings.TrimPrefix(v1, "v"), ".") + parts2 := strings.Split(strings.TrimPrefix(v2, "v"), ".") + + // Pad shorter version with zeros + maxLen := len(parts1) + if len(parts2) > maxLen { + maxLen = len(parts2) + } + + for i := 0; i < maxLen; i++ { + var n1, n2 int + if i < len(parts1) { + n1, _ = strconv.Atoi(parts1[i]) + } + if i < len(parts2) { + n2, _ = strconv.Atoi(parts2[i]) + } + + if n1 < n2 { + return -1 + } + if n1 > n2 { + return 1 + } + } + + return 0 +} + +// LoadedExtension represents an extension that has been loaded into memory +type LoadedExtension struct { + ID string `json:"id"` + Manifest *ExtensionManifest `json:"manifest"` + VM *goja.Runtime `json:"-"` // Goja VM instance (not serialized) + Enabled bool `json:"enabled"` + Error string `json:"error,omitempty"` + DataDir string `json:"data_dir"` // Extension's data directory + SourceDir string `json:"source_dir"` // Where extension files are extracted + IconPath string `json:"icon_path"` // Full path to icon file (if exists) +} + +// ExtensionManager manages all loaded extensions +type ExtensionManager struct { + mu sync.RWMutex + extensions map[string]*LoadedExtension + extensionsDir string // Base directory for extensions + dataDir string // Base directory for extension data +} + +// Global extension manager instance +var ( + globalExtManager *ExtensionManager + globalExtManagerOnce sync.Once +) + +// GetExtensionManager returns the global extension manager instance +func GetExtensionManager() *ExtensionManager { + globalExtManagerOnce.Do(func() { + globalExtManager = &ExtensionManager{ + extensions: make(map[string]*LoadedExtension), + } + }) + return globalExtManager +} + +// SetDirectories sets the extensions and data directories +func (m *ExtensionManager) SetDirectories(extensionsDir, dataDir string) error { + m.mu.Lock() + defer m.mu.Unlock() + + m.extensionsDir = extensionsDir + m.dataDir = dataDir + + // Create directories if they don't exist + if err := os.MkdirAll(extensionsDir, 0755); err != nil { + return fmt.Errorf("failed to create extensions directory: %w", err) + } + if err := os.MkdirAll(dataDir, 0755); err != nil { + return fmt.Errorf("failed to create data directory: %w", err) + } + + return nil +} + +// LoadExtensionFromFile loads an extension from a .spotiflac-ext file +func (m *ExtensionManager) LoadExtensionFromFile(filePath string) (*LoadedExtension, error) { + // Validate file extension + if !strings.HasSuffix(strings.ToLower(filePath), ".spotiflac-ext") { + return nil, fmt.Errorf("Invalid file format. Please select a .spotiflac-ext file") + } + + // Open the zip file + zipReader, err := zip.OpenReader(filePath) + if err != nil { + return nil, fmt.Errorf("Cannot open extension file. The file may be corrupted or not a valid extension package") + } + defer zipReader.Close() + + // Find and read manifest.json + var manifestData []byte + var hasIndexJS bool + for _, file := range zipReader.File { + name := filepath.Base(file.Name) + if name == "manifest.json" { + rc, err := file.Open() + if err != nil { + return nil, fmt.Errorf("failed to open manifest.json: %w", err) + } + manifestData, err = io.ReadAll(rc) + rc.Close() + if err != nil { + return nil, fmt.Errorf("failed to read manifest.json: %w", err) + } + } + if name == "index.js" { + hasIndexJS = true + } + } + + if manifestData == nil { + return nil, fmt.Errorf("Invalid extension package: manifest.json not found") + } + + if !hasIndexJS { + return nil, fmt.Errorf("Invalid extension package: index.js not found") + } + + // Parse and validate manifest + manifest, err := ParseManifest(manifestData) + if err != nil { + return nil, fmt.Errorf("Invalid extension manifest: %w", err) + } + + // Check if extension already loaded - if so, try upgrade (check without holding lock for long) + m.mu.RLock() + existing, exists := m.extensions[manifest.Name] + var existingVersion string + var existingDisplayName string + if exists { + existingVersion = existing.Manifest.Version + existingDisplayName = existing.Manifest.DisplayName + } + m.mu.RUnlock() + + if exists { + // Check if this is an upgrade + versionCompare := compareVersions(manifest.Version, existingVersion) + if versionCompare > 0 { + // This is an upgrade - call UpgradeExtension + return m.UpgradeExtension(filePath) + } else if versionCompare == 0 { + return nil, fmt.Errorf("Extension '%s' v%s is already installed", existingDisplayName, existingVersion) + } else { + return nil, fmt.Errorf("Cannot downgrade '%s' from v%s to v%s", existingDisplayName, existingVersion, manifest.Version) + } + } + + // Now acquire write lock for the rest of the operation + m.mu.Lock() + defer m.mu.Unlock() + + // Double-check extension wasn't added while we were waiting for lock + if _, exists := m.extensions[manifest.Name]; exists { + return nil, fmt.Errorf("Extension '%s' was installed by another process", manifest.DisplayName) + } + + // Create extension directory + extDir := filepath.Join(m.extensionsDir, manifest.Name) + if err := os.MkdirAll(extDir, 0755); err != nil { + return nil, fmt.Errorf("failed to create extension directory: %w", err) + } + + // Extract all files + for _, file := range zipReader.File { + if file.FileInfo().IsDir() { + continue + } + + // Get relative path within the zip + destPath := filepath.Join(extDir, filepath.Base(file.Name)) + + // Create destination file + destFile, err := os.Create(destPath) + if err != nil { + return nil, fmt.Errorf("failed to create file %s: %w", destPath, err) + } + + // Copy content + srcFile, err := file.Open() + if err != nil { + destFile.Close() + return nil, fmt.Errorf("failed to open file in archive: %w", err) + } + + _, err = io.Copy(destFile, srcFile) + srcFile.Close() + destFile.Close() + if err != nil { + return nil, fmt.Errorf("failed to extract file: %w", err) + } + } + + // Create data directory for extension + extDataDir := filepath.Join(m.dataDir, manifest.Name) + if err := os.MkdirAll(extDataDir, 0755); err != nil { + return nil, fmt.Errorf("failed to create extension data directory: %w", err) + } + + // Create loaded extension + ext := &LoadedExtension{ + ID: manifest.Name, + Manifest: manifest, + Enabled: true, + DataDir: extDataDir, + SourceDir: extDir, + } + + // Initialize Goja VM + if err := m.initializeVM(ext); err != nil { + ext.Error = err.Error() + ext.Enabled = false + GoLog("[Extension] Failed to initialize VM for %s: %v\n", manifest.Name, err) + } + + m.extensions[manifest.Name] = ext + GoLog("[Extension] Loaded extension: %s v%s\n", manifest.DisplayName, manifest.Version) + + return ext, nil +} + +// initializeVM creates and initializes the Goja VM for an extension +func (m *ExtensionManager) initializeVM(ext *LoadedExtension) error { + // Create new Goja runtime + vm := goja.New() + ext.VM = vm + + // Read index.js + indexPath := filepath.Join(ext.SourceDir, "index.js") + jsCode, err := os.ReadFile(indexPath) + if err != nil { + return fmt.Errorf("failed to read index.js: %w", err) + } + + // Create extension runtime and register sandboxed APIs + runtime := NewExtensionRuntime(ext) + runtime.RegisterAPIs(vm) + runtime.RegisterGoBackendAPIs(vm) + + // Set up console.log for debugging + console := vm.NewObject() + console.Set("log", func(call goja.FunctionCall) goja.Value { + args := make([]interface{}, len(call.Arguments)) + for i, arg := range call.Arguments { + args[i] = arg.Export() + } + GoLog("[Extension:%s] %v\n", ext.ID, args) + return goja.Undefined() + }) + vm.Set("console", console) + + // Set up registerExtension function + var registeredExtension goja.Value + vm.Set("registerExtension", func(call goja.FunctionCall) goja.Value { + if len(call.Arguments) > 0 { + registeredExtension = call.Arguments[0] + // Also set it as global 'extension' variable for later access + vm.Set("extension", call.Arguments[0]) + } + return goja.Undefined() + }) + + // Run the extension code + _, err = vm.RunString(string(jsCode)) + if err != nil { + return fmt.Errorf("failed to execute extension code: %w", err) + } + + // Verify extension was registered + if registeredExtension == nil || goja.IsUndefined(registeredExtension) { + return fmt.Errorf("extension did not call registerExtension()") + } + + return nil +} + +// UnloadExtension unloads an extension by ID +func (m *ExtensionManager) UnloadExtension(extensionID string) error { + m.mu.Lock() + defer m.mu.Unlock() + + ext, exists := m.extensions[extensionID] + if !exists { + return fmt.Errorf("Extension not found") + } + + // Call cleanup if VM is initialized + if ext.VM != nil { + // Try to call cleanup function + cleanup, err := ext.VM.RunString("typeof extension !== 'undefined' && typeof extension.cleanup === 'function' ? extension.cleanup() : null") + if err != nil { + GoLog("[Extension] Error calling cleanup for %s: %v\n", extensionID, err) + } else if cleanup != nil && !goja.IsUndefined(cleanup) && !goja.IsNull(cleanup) { + GoLog("[Extension] Cleanup called for %s\n", extensionID) + } + } + + // Remove from registry + delete(m.extensions, extensionID) + GoLog("[Extension] Unloaded extension: %s\n", extensionID) + + return nil +} + +// GetExtension returns a loaded extension by ID +// Returns error if extension not found (gomobile compatible) +func (m *ExtensionManager) GetExtension(extensionID string) (*LoadedExtension, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + ext, exists := m.extensions[extensionID] + if !exists { + return nil, fmt.Errorf("Extension not found") + } + return ext, nil +} + +// GetAllExtensions returns all loaded extensions +func (m *ExtensionManager) GetAllExtensions() []*LoadedExtension { + m.mu.RLock() + defer m.mu.RUnlock() + + result := make([]*LoadedExtension, 0, len(m.extensions)) + for _, ext := range m.extensions { + result = append(result, ext) + } + return result +} + +// SetExtensionEnabled enables or disables an extension +func (m *ExtensionManager) SetExtensionEnabled(extensionID string, enabled bool) error { + m.mu.Lock() + defer m.mu.Unlock() + + ext, exists := m.extensions[extensionID] + if !exists { + return fmt.Errorf("Extension not found") + } + + ext.Enabled = enabled + GoLog("[Extension] %s %s\n", extensionID, map[bool]string{true: "enabled", false: "disabled"}[enabled]) + return nil +} + +// LoadExtensionsFromDirectory scans a directory and loads all valid extensions +func (m *ExtensionManager) LoadExtensionsFromDirectory(dirPath string) ([]string, []error) { + var loaded []string + var errors []error + + entries, err := os.ReadDir(dirPath) + if err != nil { + if os.IsNotExist(err) { + return loaded, errors + } + return nil, []error{fmt.Errorf("failed to read extensions directory: %w", err)} + } + + for _, entry := range entries { + if entry.IsDir() { + // Check if it's an extracted extension directory + manifestPath := filepath.Join(dirPath, entry.Name(), "manifest.json") + if _, err := os.Stat(manifestPath); err == nil { + ext, err := m.loadExtensionFromDirectory(filepath.Join(dirPath, entry.Name())) + if err != nil { + GoLog("[Extension] Failed to load %s: %v\n", entry.Name(), err) + errors = append(errors, fmt.Errorf("%s: %w", entry.Name(), err)) + } else { + loaded = append(loaded, ext.ID) + } + } + } else if strings.HasSuffix(strings.ToLower(entry.Name()), ".spotiflac-ext") { + // Load from package file + ext, err := m.LoadExtensionFromFile(filepath.Join(dirPath, entry.Name())) + if err != nil { + GoLog("[Extension] Failed to load %s: %v\n", entry.Name(), err) + errors = append(errors, fmt.Errorf("%s: %w", entry.Name(), err)) + } else { + loaded = append(loaded, ext.ID) + } + } + } + + return loaded, errors +} + +// loadExtensionFromDirectory loads an extension from an already extracted directory +func (m *ExtensionManager) loadExtensionFromDirectory(dirPath string) (*LoadedExtension, error) { + m.mu.Lock() + defer m.mu.Unlock() + + // Read manifest + manifestPath := filepath.Join(dirPath, "manifest.json") + manifestData, err := os.ReadFile(manifestPath) + if err != nil { + return nil, fmt.Errorf("failed to read manifest.json: %w", err) + } + + // Parse and validate manifest + manifest, err := ParseManifest(manifestData) + if err != nil { + return nil, fmt.Errorf("Invalid extension manifest: %w", err) + } + + // Check if index.js exists + indexPath := filepath.Join(dirPath, "index.js") + if _, err := os.Stat(indexPath); os.IsNotExist(err) { + return nil, fmt.Errorf("Extension is missing index.js file") + } + + // Check if extension already loaded - skip if already exists (for directory loading on startup) + if _, exists := m.extensions[manifest.Name]; exists { + return nil, fmt.Errorf("Extension '%s' is already loaded", manifest.DisplayName) + } + + // Create data directory for extension + extDataDir := filepath.Join(m.dataDir, manifest.Name) + if err := os.MkdirAll(extDataDir, 0755); err != nil { + return nil, fmt.Errorf("failed to create extension data directory: %w", err) + } + + // Create loaded extension + ext := &LoadedExtension{ + ID: manifest.Name, + Manifest: manifest, + Enabled: true, + DataDir: extDataDir, + SourceDir: dirPath, + } + + // Initialize Goja VM + if err := m.initializeVM(ext); err != nil { + ext.Error = err.Error() + ext.Enabled = false + GoLog("[Extension] Failed to initialize VM for %s: %v\n", manifest.Name, err) + } + + m.extensions[manifest.Name] = ext + GoLog("[Extension] Loaded extension: %s v%s\n", manifest.DisplayName, manifest.Version) + + return ext, nil +} + +// RemoveExtension completely removes an extension (unload + delete files) +func (m *ExtensionManager) RemoveExtension(extensionID string) error { + ext, err := m.GetExtension(extensionID) + if err != nil { + return err + } + + // Unload first + if err := m.UnloadExtension(extensionID); err != nil { + return err + } + + // Remove source directory + if ext.SourceDir != "" { + if err := os.RemoveAll(ext.SourceDir); err != nil { + GoLog("[Extension] Warning: failed to remove source dir: %v\n", err) + } + } + + // Optionally remove data directory (keep for now to preserve settings) + // if ext.DataDir != "" { + // os.RemoveAll(ext.DataDir) + // } + + return nil +} + +// UpgradeExtension upgrades an existing extension from a new package file +// Only allows upgrades (new version > current version), not downgrades +func (m *ExtensionManager) UpgradeExtension(filePath string) (*LoadedExtension, error) { + // Validate file extension + if !strings.HasSuffix(strings.ToLower(filePath), ".spotiflac-ext") { + return nil, fmt.Errorf("Invalid file format. Please select a .spotiflac-ext file") + } + + // Open the zip file + zipReader, err := zip.OpenReader(filePath) + if err != nil { + return nil, fmt.Errorf("Cannot open extension file. The file may be corrupted or not a valid extension package") + } + defer zipReader.Close() + + // Find and read manifest.json + var manifestData []byte + var hasIndexJS bool + for _, file := range zipReader.File { + name := filepath.Base(file.Name) + if name == "manifest.json" { + rc, err := file.Open() + if err != nil { + return nil, fmt.Errorf("failed to open manifest.json: %w", err) + } + manifestData, err = io.ReadAll(rc) + rc.Close() + if err != nil { + return nil, fmt.Errorf("failed to read manifest.json: %w", err) + } + } + if name == "index.js" { + hasIndexJS = true + } + } + + if manifestData == nil { + return nil, fmt.Errorf("Invalid extension package: manifest.json not found") + } + + if !hasIndexJS { + return nil, fmt.Errorf("Invalid extension package: index.js not found") + } + + // Parse and validate manifest + newManifest, err := ParseManifest(manifestData) + if err != nil { + return nil, fmt.Errorf("Invalid extension manifest: %w", err) + } + + // Check if extension exists + m.mu.RLock() + existing, exists := m.extensions[newManifest.Name] + m.mu.RUnlock() + + if !exists { + return nil, fmt.Errorf("Extension '%s' is not installed. Use install instead of upgrade.", newManifest.DisplayName) + } + + // Compare versions - only allow upgrade, not downgrade + versionCompare := compareVersions(newManifest.Version, existing.Manifest.Version) + if versionCompare < 0 { + return nil, fmt.Errorf("Cannot downgrade extension. Current version: %s, New version: %s", existing.Manifest.Version, newManifest.Version) + } + if versionCompare == 0 { + return nil, fmt.Errorf("Extension is already at version %s", existing.Manifest.Version) + } + + 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) + extDataDir := existing.DataDir + extDir := existing.SourceDir + + // Cleanup and unload existing extension + m.CleanupExtension(existing.ID) + m.UnloadExtension(existing.ID) + + // Remove old source files but keep data directory + if extDir != "" { + if err := os.RemoveAll(extDir); err != nil { + GoLog("[Extension] Warning: failed to remove old source dir: %v\n", err) + } + } + + // Recreate extension directory + if err := os.MkdirAll(extDir, 0755); err != nil { + return nil, fmt.Errorf("failed to create extension directory: %w", err) + } + + // Extract all files from new package + for _, file := range zipReader.File { + if file.FileInfo().IsDir() { + continue + } + + // Get relative path within the zip + destPath := filepath.Join(extDir, filepath.Base(file.Name)) + + // Create destination file + destFile, err := os.Create(destPath) + if err != nil { + return nil, fmt.Errorf("failed to create file %s: %w", destPath, err) + } + + // Copy content + srcFile, err := file.Open() + if err != nil { + destFile.Close() + return nil, fmt.Errorf("failed to open file in archive: %w", err) + } + + _, err = io.Copy(destFile, srcFile) + srcFile.Close() + destFile.Close() + if err != nil { + return nil, fmt.Errorf("failed to extract file: %w", err) + } + } + + // Create new loaded extension (reusing data directory) + ext := &LoadedExtension{ + ID: newManifest.Name, + Manifest: newManifest, + Enabled: true, + DataDir: extDataDir, + SourceDir: extDir, + } + + // Initialize Goja VM + if err := m.initializeVM(ext); err != nil { + ext.Error = err.Error() + ext.Enabled = false + GoLog("[Extension] Failed to initialize VM for %s: %v\n", newManifest.Name, err) + } + + m.mu.Lock() + m.extensions[newManifest.Name] = ext + m.mu.Unlock() + + GoLog("[Extension] Upgraded extension: %s to v%s\n", newManifest.DisplayName, newManifest.Version) + + return ext, nil +} + +// ExtensionUpgradeInfo holds information about extension upgrade check +type ExtensionUpgradeInfo struct { + ExtensionID string `json:"extension_id"` + CurrentVersion string `json:"current_version"` + NewVersion string `json:"new_version"` + CanUpgrade bool `json:"can_upgrade"` + IsInstalled bool `json:"is_installed"` +} + +// checkExtensionUpgradeInternal checks if a package file is an upgrade for an existing extension +// Internal function that returns struct +func (m *ExtensionManager) checkExtensionUpgradeInternal(filePath string) (*ExtensionUpgradeInfo, error) { + // Validate file extension + if !strings.HasSuffix(strings.ToLower(filePath), ".spotiflac-ext") { + return nil, fmt.Errorf("Invalid file format") + } + + // Open the zip file + zipReader, err := zip.OpenReader(filePath) + if err != nil { + return nil, fmt.Errorf("Cannot open extension file") + } + defer zipReader.Close() + + // Find and read manifest.json + var manifestData []byte + for _, file := range zipReader.File { + name := filepath.Base(file.Name) + if name == "manifest.json" { + rc, err := file.Open() + if err != nil { + return nil, fmt.Errorf("failed to open manifest.json") + } + manifestData, err = io.ReadAll(rc) + rc.Close() + if err != nil { + return nil, fmt.Errorf("failed to read manifest.json") + } + break + } + } + + if manifestData == nil { + return nil, fmt.Errorf("manifest.json not found") + } + + // Parse manifest + newManifest, err := ParseManifest(manifestData) + if err != nil { + return nil, fmt.Errorf("Invalid manifest: %w", err) + } + + // Check if extension exists + m.mu.RLock() + existing, exists := m.extensions[newManifest.Name] + m.mu.RUnlock() + + info := &ExtensionUpgradeInfo{ + ExtensionID: newManifest.Name, + NewVersion: newManifest.Version, + IsInstalled: exists, + } + + if !exists { + // Not installed - this is a new install, not upgrade + info.CurrentVersion = "" + info.CanUpgrade = false + } else { + // Compare versions + info.CurrentVersion = existing.Manifest.Version + info.CanUpgrade = compareVersions(newManifest.Version, existing.Manifest.Version) > 0 + } + + return info, nil +} + +// CheckExtensionUpgradeJSON checks if a package file is an upgrade and returns JSON +func (m *ExtensionManager) CheckExtensionUpgradeJSON(filePath string) (string, error) { + info, err := m.checkExtensionUpgradeInternal(filePath) + if err != nil { + return "", err + } + + jsonBytes, err := json.Marshal(info) + if err != nil { + return "", err + } + + return string(jsonBytes), nil +} + +// GetInstalledExtensionsJSON returns all extensions as JSON for Flutter +func (m *ExtensionManager) GetInstalledExtensionsJSON() (string, error) { + extensions := m.GetAllExtensions() + + type ExtensionInfo 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"` + Homepage string `json:"homepage,omitempty"` + IconPath string `json:"icon_path,omitempty"` + Types []ExtensionType `json:"types"` + Enabled bool `json:"enabled"` + Status string `json:"status"` + Error string `json:"error_message,omitempty"` + Settings []ExtensionSetting `json:"settings,omitempty"` + QualityOptions []QualityOption `json:"quality_options,omitempty"` + Permissions []string `json:"permissions"` + HasMetadataProvider bool `json:"has_metadata_provider"` + HasDownloadProvider bool `json:"has_download_provider"` + SkipMetadataEnrichment bool `json:"skip_metadata_enrichment"` + SearchBehavior *SearchBehaviorConfig `json:"search_behavior,omitempty"` + TrackMatching *TrackMatchingConfig `json:"track_matching,omitempty"` + PostProcessing *PostProcessingConfig `json:"post_processing,omitempty"` + } + + infos := make([]ExtensionInfo, len(extensions)) + for i, ext := range extensions { + // Build permissions list + permissions := []string{} + for _, domain := range ext.Manifest.Permissions.Network { + permissions = append(permissions, "network:"+domain) + } + if ext.Manifest.Permissions.Storage { + permissions = append(permissions, "storage:enabled") + } + + // Determine status + status := "loaded" + if ext.Error != "" { + status = "error" + } else if !ext.Enabled { + status = "disabled" + } + + // Check for icon file + iconPath := "" + if ext.Manifest.Icon != "" && ext.SourceDir != "" { + possibleIcon := filepath.Join(ext.SourceDir, ext.Manifest.Icon) + if _, err := os.Stat(possibleIcon); err == nil { + iconPath = possibleIcon + } + } + // Fallback: check for icon.png if not specified in manifest + if iconPath == "" && ext.SourceDir != "" { + possibleIcon := filepath.Join(ext.SourceDir, "icon.png") + if _, err := os.Stat(possibleIcon); err == nil { + iconPath = possibleIcon + } + } + + infos[i] = ExtensionInfo{ + ID: ext.ID, + Name: ext.Manifest.Name, + DisplayName: ext.Manifest.DisplayName, + Version: ext.Manifest.Version, + Author: ext.Manifest.Author, + Description: ext.Manifest.Description, + Homepage: ext.Manifest.Homepage, + IconPath: iconPath, + Types: ext.Manifest.Types, + Enabled: ext.Enabled, + Status: status, + Error: ext.Error, + Settings: ext.Manifest.Settings, + QualityOptions: ext.Manifest.QualityOptions, + Permissions: permissions, + HasMetadataProvider: ext.Manifest.IsMetadataProvider(), + HasDownloadProvider: ext.Manifest.IsDownloadProvider(), + SkipMetadataEnrichment: ext.Manifest.SkipMetadataEnrichment, + SearchBehavior: ext.Manifest.SearchBehavior, + TrackMatching: ext.Manifest.TrackMatching, + PostProcessing: ext.Manifest.PostProcessing, + } + } + + jsonBytes, err := json.Marshal(infos) + if err != nil { + return "", err + } + + return string(jsonBytes), nil +} + +// ==================== Extension Lifecycle ==================== + +// InitializeExtension calls the extension's initialize method with settings +func (m *ExtensionManager) InitializeExtension(extensionID string, settings map[string]interface{}) error { + m.mu.Lock() + defer m.mu.Unlock() + + ext, exists := m.extensions[extensionID] + if !exists { + return fmt.Errorf("Extension not found") + } + + if ext.VM == nil { + return fmt.Errorf("Extension failed to load. Please reinstall the extension") + } + + // Convert settings to JSON for passing to JS + settingsJSON, err := json.Marshal(settings) + if err != nil { + return fmt.Errorf("Failed to save settings") + } + + // Call initialize function + script := fmt.Sprintf(` + (function() { + var settings = %s; + if (typeof extension !== 'undefined' && typeof extension.initialize === 'function') { + try { + extension.initialize(settings); + return { success: true }; + } catch (e) { + return { success: false, error: e.toString() }; + } + } + return { success: true, message: 'no initialize function' }; + })() + `, string(settingsJSON)) + + result, err := ext.VM.RunString(script) + if err != nil { + ext.Error = fmt.Sprintf("initialize failed: %v", err) + ext.Enabled = false + GoLog("[Extension] Initialize error for %s: %v\n", extensionID, err) + return err + } + + // Check result + if result != nil && !goja.IsUndefined(result) { + exported := result.Export() + if resultMap, ok := exported.(map[string]interface{}); ok { + if success, ok := resultMap["success"].(bool); ok && !success { + errMsg := "unknown error" + if e, ok := resultMap["error"].(string); ok { + errMsg = e + } + ext.Error = errMsg + ext.Enabled = false + GoLog("[Extension] Initialize failed for %s: %s\n", extensionID, errMsg) + return fmt.Errorf("initialize failed: %s", errMsg) + } + } + } + + GoLog("[Extension] Initialized %s\n", extensionID) + return nil +} + +// CleanupExtension calls the extension's cleanup method +func (m *ExtensionManager) CleanupExtension(extensionID string) error { + m.mu.Lock() + defer m.mu.Unlock() + + ext, exists := m.extensions[extensionID] + if !exists { + return fmt.Errorf("Extension not found") + } + + if ext.VM == nil { + return nil // No VM, nothing to cleanup + } + + // Call cleanup function + script := ` + (function() { + if (typeof extension !== 'undefined' && typeof extension.cleanup === 'function') { + try { + extension.cleanup(); + return { success: true }; + } catch (e) { + return { success: false, error: e.toString() }; + } + } + return { success: true, message: 'no cleanup function' }; + })() + ` + + result, err := ext.VM.RunString(script) + if err != nil { + GoLog("[Extension] Cleanup error for %s: %v\n", extensionID, err) + return err + } + + // Check result + if result != nil && !goja.IsUndefined(result) { + exported := result.Export() + if resultMap, ok := exported.(map[string]interface{}); ok { + if success, ok := resultMap["success"].(bool); ok && !success { + errMsg := "unknown error" + if e, ok := resultMap["error"].(string); ok { + errMsg = e + } + GoLog("[Extension] Cleanup failed for %s: %s\n", extensionID, errMsg) + return fmt.Errorf("cleanup failed: %s", errMsg) + } + } + } + + GoLog("[Extension] Cleaned up %s\n", extensionID) + return nil +} + +// UnloadAllExtensions unloads all extensions gracefully +func (m *ExtensionManager) UnloadAllExtensions() { + m.mu.Lock() + extensionIDs := make([]string, 0, len(m.extensions)) + for id := range m.extensions { + extensionIDs = append(extensionIDs, id) + } + m.mu.Unlock() + + for _, id := range extensionIDs { + // Call cleanup first + m.CleanupExtension(id) + // Then unload + m.UnloadExtension(id) + } + + GoLog("[Extension] All extensions unloaded\n") +} diff --git a/go_backend/extension_manifest.go b/go_backend/extension_manifest.go new file mode 100644 index 00000000..60b5e1fd --- /dev/null +++ b/go_backend/extension_manifest.go @@ -0,0 +1,284 @@ +// Package gobackend provides extension manifest parsing and validation +package gobackend + +import ( + "encoding/json" + "fmt" + "strings" +) + +// ExtensionType represents the type of extension +type ExtensionType string + +const ( + ExtensionTypeMetadataProvider ExtensionType = "metadata_provider" + ExtensionTypeDownloadProvider ExtensionType = "download_provider" +) + +// SettingType represents the type of a setting field +type SettingType string + +const ( + SettingTypeString SettingType = "string" + SettingTypeNumber SettingType = "number" + SettingTypeBool SettingType = "boolean" + SettingTypeSelect SettingType = "select" +) + +// ExtensionPermissions defines what resources an extension can access +type ExtensionPermissions struct { + Network []string `json:"network"` // List of allowed domains + Storage bool `json:"storage"` // Whether extension can use storage API +} + +// ExtensionSetting defines a configurable setting for an extension +type ExtensionSetting struct { + Key string `json:"key"` + Type SettingType `json:"type"` + Label string `json:"label"` + Description string `json:"description,omitempty"` + Required bool `json:"required,omitempty"` + Secret bool `json:"secret,omitempty"` + Default interface{} `json:"default,omitempty"` + Options []string `json:"options,omitempty"` // For select type +} + +// QualityOption represents a quality option for download providers +type QualityOption struct { + ID string `json:"id"` // Unique identifier (e.g., "mp3_320", "opus_128") + Label string `json:"label"` // Display name (e.g., "MP3 320kbps") + Description string `json:"description"` // Optional description (e.g., "Best quality MP3") + Settings []QualitySpecificSetting `json:"settings,omitempty"` // Quality-specific settings +} + +// QualitySpecificSetting represents a setting that's specific to a quality option +type QualitySpecificSetting struct { + Key string `json:"key"` + Type SettingType `json:"type"` + Label string `json:"label"` + Description string `json:"description,omitempty"` + Required bool `json:"required,omitempty"` + Secret bool `json:"secret,omitempty"` + Default interface{} `json:"default,omitempty"` + Options []string `json:"options,omitempty"` // For select type +} + +// SearchBehaviorConfig defines custom search behavior for an extension +type SearchBehaviorConfig struct { + Enabled bool `json:"enabled"` // Whether extension provides custom search + Placeholder string `json:"placeholder,omitempty"` // Placeholder text for search box + Primary bool `json:"primary,omitempty"` // If true, show as primary search tab + Icon string `json:"icon,omitempty"` // Icon for search tab + ThumbnailRatio string `json:"thumbnailRatio,omitempty"` // Thumbnail aspect ratio: "square" (1:1), "wide" (16:9), "portrait" (2:3) + ThumbnailWidth int `json:"thumbnailWidth,omitempty"` // Custom thumbnail width in pixels + ThumbnailHeight int `json:"thumbnailHeight,omitempty"` // Custom thumbnail height in pixels +} + +// TrackMatchingConfig defines custom track matching behavior +type TrackMatchingConfig struct { + CustomMatching bool `json:"customMatching"` // Whether extension handles matching + Strategy string `json:"strategy,omitempty"` // "isrc", "name", "duration", "custom" + DurationTolerance int `json:"durationTolerance,omitempty"` // Tolerance in seconds for duration matching +} + +// PostProcessingHook defines a post-processing hook +type PostProcessingHook struct { + ID string `json:"id"` // Unique identifier + Name string `json:"name"` // Display name + Description string `json:"description,omitempty"` // Description + DefaultEnabled bool `json:"defaultEnabled,omitempty"` // Whether enabled by default + SupportedFormats []string `json:"supportedFormats,omitempty"` // Supported file formats (e.g., ["flac", "mp3"]) +} + +// PostProcessingConfig defines post-processing capabilities +type PostProcessingConfig struct { + Enabled bool `json:"enabled"` // Whether extension provides post-processing + Hooks []PostProcessingHook `json:"hooks,omitempty"` // Available hooks +} + +// ExtensionManifest represents the manifest.json of an extension +type ExtensionManifest struct { + Name string `json:"name"` + DisplayName string `json:"displayName"` + Version string `json:"version"` + Author string `json:"author"` + Description string `json:"description"` + Homepage string `json:"homepage,omitempty"` + Icon string `json:"icon,omitempty"` // Icon filename (e.g., "icon.png") + Types []ExtensionType `json:"type"` + Permissions ExtensionPermissions `json:"permissions"` + Settings []ExtensionSetting `json:"settings,omitempty"` + QualityOptions []QualityOption `json:"qualityOptions,omitempty"` // Custom quality options for download providers + MinAppVersion string `json:"minAppVersion,omitempty"` + 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 + TrackMatching *TrackMatchingConfig `json:"trackMatching,omitempty"` // Custom track matching + PostProcessing *PostProcessingConfig `json:"postProcessing,omitempty"` // Post-processing hooks +} + +// ManifestValidationError represents a validation error in the manifest +type ManifestValidationError struct { + Field string + Message string +} + +func (e *ManifestValidationError) Error() string { + return fmt.Sprintf("manifest validation error: %s - %s", e.Field, e.Message) +} + +// ParseManifest parses and validates a manifest from JSON bytes +func ParseManifest(data []byte) (*ExtensionManifest, error) { + var manifest ExtensionManifest + if err := json.Unmarshal(data, &manifest); err != nil { + return nil, fmt.Errorf("failed to parse manifest JSON: %w", err) + } + + if err := manifest.Validate(); err != nil { + return nil, err + } + + return &manifest, nil +} + +// Validate checks if the manifest has all required fields and valid values +func (m *ExtensionManifest) Validate() error { + // Check required fields + if strings.TrimSpace(m.Name) == "" { + return &ManifestValidationError{Field: "name", Message: "name is required"} + } + + if strings.TrimSpace(m.Version) == "" { + return &ManifestValidationError{Field: "version", Message: "version is required"} + } + + if strings.TrimSpace(m.Author) == "" { + return &ManifestValidationError{Field: "author", Message: "author is required"} + } + + if strings.TrimSpace(m.Description) == "" { + return &ManifestValidationError{Field: "description", Message: "description is required"} + } + + if len(m.Types) == 0 { + return &ManifestValidationError{Field: "type", Message: "at least one type is required"} + } + + // Validate extension types + for _, t := range m.Types { + if t != ExtensionTypeMetadataProvider && t != ExtensionTypeDownloadProvider { + return &ManifestValidationError{ + Field: "type", + Message: fmt.Sprintf("invalid extension type: %s (must be 'metadata_provider' or 'download_provider')", t), + } + } + } + + // Validate settings if present + for i, setting := range m.Settings { + if strings.TrimSpace(setting.Key) == "" { + return &ManifestValidationError{ + Field: fmt.Sprintf("settings[%d].key", i), + Message: "setting key is required", + } + } + + if setting.Type == "" { + return &ManifestValidationError{ + Field: fmt.Sprintf("settings[%d].type", i), + Message: "setting type is required", + } + } + + // Validate setting type + validTypes := map[SettingType]bool{ + SettingTypeString: true, + SettingTypeNumber: true, + SettingTypeBool: true, + SettingTypeSelect: true, + } + if !validTypes[setting.Type] { + return &ManifestValidationError{ + Field: fmt.Sprintf("settings[%d].type", i), + Message: fmt.Sprintf("invalid setting type: %s", setting.Type), + } + } + + // Select type requires options + if setting.Type == SettingTypeSelect && len(setting.Options) == 0 { + return &ManifestValidationError{ + Field: fmt.Sprintf("settings[%d].options", i), + Message: "select type requires options", + } + } + } + + return nil +} + +// HasType checks if the extension has a specific type +func (m *ExtensionManifest) HasType(t ExtensionType) bool { + for _, et := range m.Types { + if et == t { + return true + } + } + return false +} + +// IsMetadataProvider returns true if extension provides metadata +func (m *ExtensionManifest) IsMetadataProvider() bool { + return m.HasType(ExtensionTypeMetadataProvider) +} + +// IsDownloadProvider returns true if extension provides downloads +func (m *ExtensionManifest) IsDownloadProvider() bool { + return m.HasType(ExtensionTypeDownloadProvider) +} + +// IsDomainAllowed checks if a domain is in the allowed network permissions +func (m *ExtensionManifest) IsDomainAllowed(domain string) bool { + domain = strings.ToLower(strings.TrimSpace(domain)) + for _, allowed := range m.Permissions.Network { + allowed = strings.ToLower(strings.TrimSpace(allowed)) + if allowed == domain { + return true + } + // Support wildcard subdomains (e.g., *.example.com) + if strings.HasPrefix(allowed, "*.") { + suffix := allowed[1:] // Remove the * + if strings.HasSuffix(domain, suffix) { + return true + } + } + } + return false +} + +// HasCustomSearch returns true if extension provides custom search +func (m *ExtensionManifest) HasCustomSearch() bool { + return m.SearchBehavior != nil && m.SearchBehavior.Enabled +} + +// HasCustomMatching returns true if extension provides custom track matching +func (m *ExtensionManifest) HasCustomMatching() bool { + return m.TrackMatching != nil && m.TrackMatching.CustomMatching +} + +// HasPostProcessing returns true if extension provides post-processing +func (m *ExtensionManifest) HasPostProcessing() bool { + return m.PostProcessing != nil && m.PostProcessing.Enabled +} + +// GetPostProcessingHooks returns all post-processing hooks +func (m *ExtensionManifest) GetPostProcessingHooks() []PostProcessingHook { + if m.PostProcessing == nil { + return nil + } + return m.PostProcessing.Hooks +} + +// ToJSON serializes the manifest to JSON +func (m *ExtensionManifest) ToJSON() ([]byte, error) { + return json.Marshal(m) +} diff --git a/go_backend/extension_providers.go b/go_backend/extension_providers.go new file mode 100644 index 00000000..5eb68360 --- /dev/null +++ b/go_backend/extension_providers.go @@ -0,0 +1,1183 @@ +// Package gobackend provides extension provider interfaces +package gobackend + +import ( + "encoding/json" + "fmt" + "path/filepath" + "strings" + "sync" + + "github.com/dop251/goja" +) + +// ==================== Metadata Types ==================== + +// ExtTrackMetadata represents track metadata from an extension +type ExtTrackMetadata struct { + ID string `json:"id"` + Name string `json:"name"` + Artists string `json:"artists"` + AlbumName string `json:"album_name"` + AlbumArtist string `json:"album_artist,omitempty"` + DurationMS int `json:"duration_ms"` + CoverURL string `json:"cover_url,omitempty"` + Images string `json:"images,omitempty"` // Alternative field for cover URL (used by some extensions) + ReleaseDate string `json:"release_date,omitempty"` + TrackNumber int `json:"track_number,omitempty"` + DiscNumber int `json:"disc_number,omitempty"` + ISRC string `json:"isrc,omitempty"` + ProviderID string `json:"provider_id"` +} + +// ResolvedCoverURL returns the cover URL, checking both CoverURL and Images fields +func (t *ExtTrackMetadata) ResolvedCoverURL() string { + if t.CoverURL != "" { + return t.CoverURL + } + return t.Images +} + +// ExtAlbumMetadata represents album metadata from an extension +type ExtAlbumMetadata struct { + ID string `json:"id"` + Name string `json:"name"` + Artists string `json:"artists"` + CoverURL string `json:"cover_url,omitempty"` + ReleaseDate string `json:"release_date,omitempty"` + TotalTracks int `json:"total_tracks"` + Tracks []ExtTrackMetadata `json:"tracks"` + ProviderID string `json:"provider_id"` +} + +// ExtArtistMetadata represents artist metadata from an extension +type ExtArtistMetadata struct { + ID string `json:"id"` + Name string `json:"name"` + ImageURL string `json:"image_url,omitempty"` + Albums []ExtAlbumMetadata `json:"albums,omitempty"` + ProviderID string `json:"provider_id"` +} + +// ExtSearchResult represents search results from an extension +type ExtSearchResult struct { + Tracks []ExtTrackMetadata `json:"tracks"` + Total int `json:"total"` +} + +// ==================== Download Types ==================== + +// ExtAvailabilityResult represents availability check result +type ExtAvailabilityResult struct { + Available bool `json:"available"` + Reason string `json:"reason,omitempty"` + TrackID string `json:"track_id,omitempty"` +} + +// ExtDownloadURLResult represents download URL info +type ExtDownloadURLResult struct { + URL string `json:"url"` + Format string `json:"format"` + BitDepth int `json:"bit_depth,omitempty"` + SampleRate int `json:"sample_rate,omitempty"` +} + +// ExtDownloadResult represents download result from an extension +type ExtDownloadResult struct { + Success bool `json:"success"` + FilePath string `json:"file_path,omitempty"` + BitDepth int `json:"bit_depth,omitempty"` + SampleRate int `json:"sample_rate,omitempty"` + ErrorMessage string `json:"error_message,omitempty"` + ErrorType string `json:"error_type,omitempty"` + // Metadata returned by extension (optional - if provided, can skip enrichment) + Title string `json:"title,omitempty"` + Artist string `json:"artist,omitempty"` + Album string `json:"album,omitempty"` + AlbumArtist string `json:"album_artist,omitempty"` + TrackNumber int `json:"track_number,omitempty"` + DiscNumber int `json:"disc_number,omitempty"` + ReleaseDate string `json:"release_date,omitempty"` + CoverURL string `json:"cover_url,omitempty"` + ISRC string `json:"isrc,omitempty"` +} + +// ==================== Provider Wrapper ==================== + +// ExtensionProviderWrapper wraps an extension to call its provider methods +type ExtensionProviderWrapper struct { + extension *LoadedExtension + vm *goja.Runtime +} + +// NewExtensionProviderWrapper creates a new provider wrapper +func NewExtensionProviderWrapper(ext *LoadedExtension) *ExtensionProviderWrapper { + return &ExtensionProviderWrapper{ + extension: ext, + vm: ext.VM, + } +} + +// ==================== Metadata Provider Methods ==================== + +// SearchTracks searches for tracks using the extension +func (p *ExtensionProviderWrapper) SearchTracks(query string, limit int) (*ExtSearchResult, error) { + if !p.extension.Manifest.IsMetadataProvider() { + return nil, fmt.Errorf("extension '%s' is not a metadata provider", p.extension.ID) + } + + if !p.extension.Enabled { + return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID) + } + + // Call extension's searchTracks function + script := fmt.Sprintf(` + (function() { + if (typeof extension !== 'undefined' && typeof extension.searchTracks === 'function') { + return extension.searchTracks(%q, %d); + } + return null; + })() + `, query, limit) + + result, err := p.vm.RunString(script) + if err != nil { + return nil, fmt.Errorf("searchTracks failed: %w", err) + } + + if result == nil || goja.IsUndefined(result) || goja.IsNull(result) { + return nil, fmt.Errorf("searchTracks returned null") + } + + // Convert result to Go struct + exported := result.Export() + jsonBytes, err := json.Marshal(exported) + if err != nil { + return nil, fmt.Errorf("failed to marshal result: %w", err) + } + + var searchResult ExtSearchResult + if err := json.Unmarshal(jsonBytes, &searchResult); err != nil { + return nil, fmt.Errorf("failed to parse search result: %w", err) + } + + // Set provider ID on all tracks + for i := range searchResult.Tracks { + searchResult.Tracks[i].ProviderID = p.extension.ID + } + + return &searchResult, nil +} + +// GetTrack gets track details by ID +func (p *ExtensionProviderWrapper) GetTrack(trackID string) (*ExtTrackMetadata, error) { + if !p.extension.Manifest.IsMetadataProvider() { + return nil, fmt.Errorf("extension '%s' is not a metadata provider", 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.getTrack === 'function') { + return extension.getTrack(%q); + } + return null; + })() + `, trackID) + + result, err := p.vm.RunString(script) + if err != nil { + return nil, fmt.Errorf("getTrack failed: %w", err) + } + + if result == nil || goja.IsUndefined(result) || goja.IsNull(result) { + return nil, fmt.Errorf("getTrack returned null") + } + + exported := result.Export() + jsonBytes, err := json.Marshal(exported) + if err != nil { + return nil, fmt.Errorf("failed to marshal result: %w", err) + } + + var track ExtTrackMetadata + if err := json.Unmarshal(jsonBytes, &track); err != nil { + return nil, fmt.Errorf("failed to parse track: %w", err) + } + + track.ProviderID = p.extension.ID + return &track, nil +} + +// GetAlbum gets album details by ID +func (p *ExtensionProviderWrapper) GetAlbum(albumID string) (*ExtAlbumMetadata, error) { + if !p.extension.Manifest.IsMetadataProvider() { + return nil, fmt.Errorf("extension '%s' is not a metadata provider", 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.getAlbum === 'function') { + return extension.getAlbum(%q); + } + return null; + })() + `, albumID) + + result, err := p.vm.RunString(script) + if err != nil { + return nil, fmt.Errorf("getAlbum failed: %w", err) + } + + if result == nil || goja.IsUndefined(result) || goja.IsNull(result) { + return nil, fmt.Errorf("getAlbum returned null") + } + + exported := result.Export() + jsonBytes, err := json.Marshal(exported) + if err != nil { + return nil, fmt.Errorf("failed to marshal result: %w", err) + } + + var album ExtAlbumMetadata + if err := json.Unmarshal(jsonBytes, &album); err != nil { + return nil, fmt.Errorf("failed to parse album: %w", err) + } + + album.ProviderID = p.extension.ID + for i := range album.Tracks { + album.Tracks[i].ProviderID = p.extension.ID + } + return &album, nil +} + +// GetArtist gets artist details by ID +func (p *ExtensionProviderWrapper) GetArtist(artistID string) (*ExtArtistMetadata, error) { + if !p.extension.Manifest.IsMetadataProvider() { + return nil, fmt.Errorf("extension '%s' is not a metadata provider", 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.getArtist === 'function') { + return extension.getArtist(%q); + } + return null; + })() + `, artistID) + + result, err := p.vm.RunString(script) + if err != nil { + return nil, fmt.Errorf("getArtist failed: %w", err) + } + + if result == nil || goja.IsUndefined(result) || goja.IsNull(result) { + return nil, fmt.Errorf("getArtist returned null") + } + + exported := result.Export() + jsonBytes, err := json.Marshal(exported) + if err != nil { + return nil, fmt.Errorf("failed to marshal result: %w", err) + } + + var artist ExtArtistMetadata + if err := json.Unmarshal(jsonBytes, &artist); err != nil { + return nil, fmt.Errorf("failed to parse artist: %w", err) + } + + artist.ProviderID = p.extension.ID + return &artist, nil +} + +// ==================== Download Provider Methods ==================== + +// CheckAvailability checks if a track is available for download +func (p *ExtensionProviderWrapper) CheckAvailability(isrc, trackName, artistName string) (*ExtAvailabilityResult, error) { + if !p.extension.Manifest.IsDownloadProvider() { + return nil, fmt.Errorf("extension '%s' is not a download provider", 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.checkAvailability === 'function') { + return extension.checkAvailability(%q, %q, %q); + } + return null; + })() + `, isrc, trackName, artistName) + + result, err := p.vm.RunString(script) + if err != nil { + return nil, fmt.Errorf("checkAvailability failed: %w", err) + } + + if result == nil || goja.IsUndefined(result) || goja.IsNull(result) { + return &ExtAvailabilityResult{Available: false, Reason: "not implemented"}, nil + } + + exported := result.Export() + jsonBytes, err := json.Marshal(exported) + if err != nil { + return nil, fmt.Errorf("failed to marshal result: %w", err) + } + + var availability ExtAvailabilityResult + if err := json.Unmarshal(jsonBytes, &availability); err != nil { + return nil, fmt.Errorf("failed to parse availability: %w", err) + } + + return &availability, nil +} + +// GetDownloadURL gets the download URL for a track +func (p *ExtensionProviderWrapper) GetDownloadURL(trackID, quality string) (*ExtDownloadURLResult, error) { + if !p.extension.Manifest.IsDownloadProvider() { + return nil, fmt.Errorf("extension '%s' is not a download provider", 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.getDownloadUrl === 'function') { + return extension.getDownloadUrl(%q, %q); + } + return null; + })() + `, trackID, quality) + + result, err := p.vm.RunString(script) + if err != nil { + return nil, fmt.Errorf("getDownloadUrl failed: %w", err) + } + + if result == nil || goja.IsUndefined(result) || goja.IsNull(result) { + return nil, fmt.Errorf("getDownloadUrl returned null") + } + + exported := result.Export() + jsonBytes, err := json.Marshal(exported) + if err != nil { + return nil, fmt.Errorf("failed to marshal result: %w", err) + } + + var urlResult ExtDownloadURLResult + if err := json.Unmarshal(jsonBytes, &urlResult); err != nil { + return nil, fmt.Errorf("failed to parse download URL: %w", err) + } + + return &urlResult, nil +} + +// Download downloads a track with progress reporting +func (p *ExtensionProviderWrapper) Download(trackID, quality, outputPath string, onProgress func(percent int)) (*ExtDownloadResult, error) { + if !p.extension.Manifest.IsDownloadProvider() { + return nil, fmt.Errorf("extension '%s' is not a download provider", p.extension.ID) + } + + if !p.extension.Enabled { + return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID) + } + + // Set up progress callback in VM + p.vm.Set("__onProgress", func(call goja.FunctionCall) goja.Value { + if len(call.Arguments) > 0 { + percent := int(call.Arguments[0].ToInteger()) + // Clamp to 0-100 + if percent < 0 { + percent = 0 + } + if percent > 100 { + percent = 100 + } + if onProgress != nil { + onProgress(percent) + } + } + return goja.Undefined() + }) + + script := fmt.Sprintf(` + (function() { + if (typeof extension !== 'undefined' && typeof extension.download === 'function') { + return extension.download(%q, %q, %q, __onProgress); + } + return null; + })() + `, trackID, quality, outputPath) + + result, err := p.vm.RunString(script) + if err != nil { + return &ExtDownloadResult{ + Success: false, + ErrorMessage: err.Error(), + ErrorType: "script_error", + }, nil + } + + if result == nil || goja.IsUndefined(result) || goja.IsNull(result) { + return &ExtDownloadResult{ + Success: false, + ErrorMessage: "download returned null", + ErrorType: "not_implemented", + }, nil + } + + exported := result.Export() + jsonBytes, err := json.Marshal(exported) + if err != nil { + return &ExtDownloadResult{ + Success: false, + ErrorMessage: fmt.Sprintf("failed to marshal result: %v", err), + ErrorType: "internal_error", + }, nil + } + + var downloadResult ExtDownloadResult + if err := json.Unmarshal(jsonBytes, &downloadResult); err != nil { + return &ExtDownloadResult{ + Success: false, + ErrorMessage: fmt.Sprintf("failed to parse result: %v", err), + ErrorType: "internal_error", + }, nil + } + + return &downloadResult, nil +} + +// ==================== Extension Manager Provider Methods ==================== + +// GetMetadataProviders returns all enabled metadata provider extensions +func (m *ExtensionManager) GetMetadataProviders() []*ExtensionProviderWrapper { + m.mu.RLock() + defer m.mu.RUnlock() + + var providers []*ExtensionProviderWrapper + for _, ext := range m.extensions { + if ext.Enabled && ext.Manifest.IsMetadataProvider() && ext.Error == "" { + providers = append(providers, NewExtensionProviderWrapper(ext)) + } + } + return providers +} + +// GetDownloadProviders returns all enabled download provider extensions +func (m *ExtensionManager) GetDownloadProviders() []*ExtensionProviderWrapper { + m.mu.RLock() + defer m.mu.RUnlock() + + var providers []*ExtensionProviderWrapper + for _, ext := range m.extensions { + if ext.Enabled && ext.Manifest.IsDownloadProvider() && ext.Error == "" { + providers = append(providers, NewExtensionProviderWrapper(ext)) + } + } + return providers +} + +// SearchTracksWithExtensions searches all metadata providers +func (m *ExtensionManager) SearchTracksWithExtensions(query string, limit int) ([]ExtTrackMetadata, error) { + providers := m.GetMetadataProviders() + if len(providers) == 0 { + return nil, nil + } + + var allTracks []ExtTrackMetadata + for _, provider := range providers { + result, err := provider.SearchTracks(query, limit) + if err != nil { + GoLog("[Extension] Search error from %s: %v\n", provider.extension.ID, err) + continue + } + if result != nil { + allTracks = append(allTracks, result.Tracks...) + } + } + + return allTracks, nil +} + +// ==================== Provider Priority ==================== + +// providerPriority stores the order of download providers +var providerPriority []string +var providerPriorityMu sync.RWMutex + +// metadataProviderPriority stores the order of metadata providers +var metadataProviderPriority []string +var metadataProviderPriorityMu sync.RWMutex + +// SetProviderPriority sets the order of download providers +// providerIDs should include both built-in ("tidal", "qobuz", "amazon") and extension IDs +func SetProviderPriority(providerIDs []string) { + providerPriorityMu.Lock() + defer providerPriorityMu.Unlock() + providerPriority = providerIDs + GoLog("[Extension] Download provider priority set: %v\n", providerIDs) +} + +// GetProviderPriority returns the current provider priority order +func GetProviderPriority() []string { + providerPriorityMu.RLock() + defer providerPriorityMu.RUnlock() + + if len(providerPriority) == 0 { + // Default order: built-in providers first + return []string{"tidal", "qobuz", "amazon"} + } + + result := make([]string, len(providerPriority)) + copy(result, providerPriority) + return result +} + +// SetMetadataProviderPriority sets the order of metadata providers +// providerIDs should include both built-in ("spotify", "deezer") and extension IDs +func SetMetadataProviderPriority(providerIDs []string) { + metadataProviderPriorityMu.Lock() + defer metadataProviderPriorityMu.Unlock() + metadataProviderPriority = providerIDs + GoLog("[Extension] Metadata provider priority set: %v\n", providerIDs) +} + +// GetMetadataProviderPriority returns the current metadata provider priority order +func GetMetadataProviderPriority() []string { + metadataProviderPriorityMu.RLock() + defer metadataProviderPriorityMu.RUnlock() + + if len(metadataProviderPriority) == 0 { + // Default order: built-in providers first + return []string{"deezer", "spotify"} + } + + result := make([]string, len(metadataProviderPriority)) + copy(result, metadataProviderPriority) + return result +} + +// isBuiltInProvider checks if a provider ID is a built-in provider +func isBuiltInProvider(providerID string) bool { + switch providerID { + case "tidal", "qobuz", "amazon": + return true + default: + return false + } +} + +// ==================== Download with Fallback ==================== + +// DownloadWithExtensionFallback tries to download from providers in priority order +// Includes both built-in providers and extension providers +// If req.Source is set (extension ID), that extension is tried first +func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, error) { + priority := GetProviderPriority() + extManager := GetExtensionManager() + + var lastErr error + var skipBuiltIn bool // If source extension has skipBuiltInFallback, don't try built-in providers + + // If source extension is specified, try it first before the priority list + if req.Source != "" && !isBuiltInProvider(req.Source) { + GoLog("[DownloadWithExtensionFallback] Track source is extension '%s', trying it first\n", req.Source) + + ext, err := extManager.GetExtension(req.Source) + if err == nil && ext.Enabled && ext.Error == "" && ext.Manifest.IsDownloadProvider() { + // Check if this extension wants to skip built-in fallback + skipBuiltIn = ext.Manifest.SkipBuiltInFallback + + provider := NewExtensionProviderWrapper(ext) + + // For tracks from extension search, use the track ID directly (e.g., "youtube:VIDEO_ID") + // The extension already knows how to handle this ID + trackID := req.SpotifyID // This contains the extension's track ID (e.g., "youtube:xxx") + + GoLog("[DownloadWithExtensionFallback] Downloading from source extension with trackID: %s (skipBuiltInFallback: %v)\n", trackID, skipBuiltIn) + + // Build output path + outputPath := buildOutputPath(req) + + // Download directly using the track ID from the extension + result, err := provider.Download(trackID, req.Quality, outputPath, func(percent int) { + if req.ItemID != "" { + SetItemProgress(req.ItemID, float64(percent), 0, 0) + } + }) + + if err == nil && result.Success { + resp := &DownloadResponse{ + Success: true, + Message: "Downloaded from " + req.Source, + FilePath: result.FilePath, + ActualBitDepth: result.BitDepth, + ActualSampleRate: result.SampleRate, + Service: req.Source, + } + + // If extension has skipMetadataEnrichment, copy metadata + if ext.Manifest.SkipMetadataEnrichment { + resp.SkipMetadataEnrichment = true + if result.Title != "" { + resp.Title = result.Title + } + if result.Artist != "" { + resp.Artist = result.Artist + } + if result.Album != "" { + resp.Album = result.Album + } + if result.AlbumArtist != "" { + resp.AlbumArtist = result.AlbumArtist + } + if result.TrackNumber > 0 { + resp.TrackNumber = result.TrackNumber + } + if result.DiscNumber > 0 { + resp.DiscNumber = result.DiscNumber + } + if result.ReleaseDate != "" { + resp.ReleaseDate = result.ReleaseDate + } + if result.CoverURL != "" { + resp.CoverURL = result.CoverURL + } + if result.ISRC != "" { + resp.ISRC = result.ISRC + } + } + + return resp, nil + } + + if err != nil { + lastErr = err + } else if result.ErrorMessage != "" { + lastErr = fmt.Errorf("%s", result.ErrorMessage) + } + GoLog("[DownloadWithExtensionFallback] Source extension %s failed: %v\n", req.Source, lastErr) + + // If skipBuiltInFallback is true, don't continue to other providers + if skipBuiltIn { + GoLog("[DownloadWithExtensionFallback] skipBuiltInFallback is true, not trying other providers\n") + return &DownloadResponse{ + Success: false, + Error: fmt.Sprintf("Download failed: %v", lastErr), + ErrorType: "extension_error", + Service: req.Source, + }, nil + } + } else { + GoLog("[DownloadWithExtensionFallback] Source extension %s not available or not a download provider\n", req.Source) + } + } + + // Continue with priority list + for _, providerID := range priority { + // Skip if we already tried this as source + if providerID == req.Source { + continue + } + + // Skip built-in providers if skipBuiltIn is set + if skipBuiltIn && isBuiltInProvider(providerID) { + GoLog("[DownloadWithExtensionFallback] Skipping built-in provider %s (skipBuiltInFallback)\n", providerID) + continue + } + + GoLog("[DownloadWithExtensionFallback] Trying provider: %s\n", providerID) + + if isBuiltInProvider(providerID) { + // Use built-in provider + result, err := tryBuiltInProvider(providerID, req) + if err == nil && result.Success { + result.Service = providerID + return result, nil + } + if err != nil { + lastErr = err + GoLog("[DownloadWithExtensionFallback] %s failed: %v\n", providerID, err) + } + } else { + // Try extension provider + ext, err := extManager.GetExtension(providerID) + if err != nil || !ext.Enabled || ext.Error != "" { + GoLog("[DownloadWithExtensionFallback] Extension %s not available\n", providerID) + continue + } + + if !ext.Manifest.IsDownloadProvider() { + continue + } + + provider := NewExtensionProviderWrapper(ext) + + // Check availability first + availability, err := provider.CheckAvailability(req.ISRC, req.TrackName, req.ArtistName) + if err != nil || !availability.Available { + GoLog("[DownloadWithExtensionFallback] %s: not available\n", providerID) + if err != nil { + lastErr = err + } + continue + } + + // Build output path + outputPath := buildOutputPath(req) + + // Download + result, err := provider.Download(availability.TrackID, req.Quality, outputPath, func(percent int) { + // Update progress + if req.ItemID != "" { + SetItemProgress(req.ItemID, float64(percent), 0, 0) + } + }) + + if err == nil && result.Success { + resp := &DownloadResponse{ + Success: true, + Message: "Downloaded from " + providerID, + FilePath: result.FilePath, + ActualBitDepth: result.BitDepth, + ActualSampleRate: result.SampleRate, + Service: providerID, + } + + // If extension has skipMetadataEnrichment and returned metadata, use it + if ext.Manifest.SkipMetadataEnrichment { + resp.SkipMetadataEnrichment = true + // Copy metadata from extension result if provided + if result.Title != "" { + resp.Title = result.Title + } + if result.Artist != "" { + resp.Artist = result.Artist + } + if result.Album != "" { + resp.Album = result.Album + } + if result.AlbumArtist != "" { + resp.AlbumArtist = result.AlbumArtist + } + if result.TrackNumber > 0 { + resp.TrackNumber = result.TrackNumber + } + if result.DiscNumber > 0 { + resp.DiscNumber = result.DiscNumber + } + if result.ReleaseDate != "" { + resp.ReleaseDate = result.ReleaseDate + } + if result.CoverURL != "" { + resp.CoverURL = result.CoverURL + } + if result.ISRC != "" { + resp.ISRC = result.ISRC + } + } + + return resp, nil + } + + if err != nil { + lastErr = err + } else if result.ErrorMessage != "" { + lastErr = fmt.Errorf("%s", result.ErrorMessage) + } + GoLog("[DownloadWithExtensionFallback] %s failed: %v\n", providerID, lastErr) + } + } + + if lastErr != nil { + return &DownloadResponse{ + Success: false, + Error: fmt.Sprintf("All providers failed. Last error: %v", lastErr), + ErrorType: "not_found", + }, nil + } + + return &DownloadResponse{ + Success: false, + Error: "No providers available", + ErrorType: "not_found", + }, nil +} + +// tryBuiltInProvider attempts download from a built-in provider +func tryBuiltInProvider(providerID string, req DownloadRequest) (*DownloadResponse, error) { + req.Service = providerID + + var result DownloadResult + var err error + + switch providerID { + case "tidal": + tidalResult, tidalErr := downloadFromTidal(req) + if tidalErr == nil { + result = DownloadResult{ + FilePath: tidalResult.FilePath, + BitDepth: tidalResult.BitDepth, + SampleRate: tidalResult.SampleRate, + Title: tidalResult.Title, + Artist: tidalResult.Artist, + Album: tidalResult.Album, + ReleaseDate: tidalResult.ReleaseDate, + TrackNumber: tidalResult.TrackNumber, + DiscNumber: tidalResult.DiscNumber, + ISRC: tidalResult.ISRC, + } + } + err = tidalErr + case "qobuz": + qobuzResult, qobuzErr := downloadFromQobuz(req) + if qobuzErr == nil { + result = DownloadResult{ + FilePath: qobuzResult.FilePath, + BitDepth: qobuzResult.BitDepth, + SampleRate: qobuzResult.SampleRate, + Title: qobuzResult.Title, + Artist: qobuzResult.Artist, + Album: qobuzResult.Album, + ReleaseDate: qobuzResult.ReleaseDate, + TrackNumber: qobuzResult.TrackNumber, + DiscNumber: qobuzResult.DiscNumber, + ISRC: qobuzResult.ISRC, + } + } + err = qobuzErr + case "amazon": + amazonResult, amazonErr := downloadFromAmazon(req) + if amazonErr == nil { + result = DownloadResult{ + FilePath: amazonResult.FilePath, + BitDepth: amazonResult.BitDepth, + SampleRate: amazonResult.SampleRate, + Title: amazonResult.Title, + Artist: amazonResult.Artist, + Album: amazonResult.Album, + ReleaseDate: amazonResult.ReleaseDate, + TrackNumber: amazonResult.TrackNumber, + DiscNumber: amazonResult.DiscNumber, + ISRC: amazonResult.ISRC, + } + } + err = amazonErr + default: + return nil, fmt.Errorf("unknown built-in provider: %s", providerID) + } + + if err != nil { + return nil, err + } + + return &DownloadResponse{ + Success: true, + Message: "Download complete", + FilePath: result.FilePath, + ActualBitDepth: result.BitDepth, + ActualSampleRate: result.SampleRate, + Title: result.Title, + Artist: result.Artist, + Album: result.Album, + ReleaseDate: result.ReleaseDate, + TrackNumber: result.TrackNumber, + DiscNumber: result.DiscNumber, + ISRC: result.ISRC, + }, nil +} + +// buildOutputPath builds the output file path from request +func buildOutputPath(req DownloadRequest) string { + metadata := map[string]interface{}{ + "title": req.TrackName, + "artist": req.ArtistName, + "album": req.AlbumName, + "album_artist": req.AlbumArtist, + "track_number": req.TrackNumber, + "disc_number": req.DiscNumber, + "isrc": req.ISRC, + } + + filename := buildFilenameFromTemplate(req.FilenameFormat, metadata) + if filename == "" { + filename = sanitizeFilename(fmt.Sprintf("%s - %s", req.ArtistName, req.TrackName)) + } + + return fmt.Sprintf("%s/%s.flac", req.OutputDir, filename) +} + +// ==================== Custom Search ==================== + +// CustomSearch performs a custom search using an extension's search function +func (p *ExtensionProviderWrapper) CustomSearch(query string, options map[string]interface{}) ([]ExtTrackMetadata, error) { + if !p.extension.Manifest.HasCustomSearch() { + return nil, fmt.Errorf("extension '%s' does not support custom search", p.extension.ID) + } + + if !p.extension.Enabled { + return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID) + } + + // Convert options to JSON + optionsJSON, _ := json.Marshal(options) + + script := fmt.Sprintf(` + (function() { + if (typeof extension !== 'undefined' && typeof extension.customSearch === 'function') { + return extension.customSearch(%q, %s); + } + return null; + })() + `, query, string(optionsJSON)) + + result, err := p.vm.RunString(script) + if err != nil { + return nil, fmt.Errorf("customSearch failed: %w", err) + } + + if result == nil || goja.IsUndefined(result) || goja.IsNull(result) { + // Return empty array instead of error for no results + return []ExtTrackMetadata{}, nil + } + + exported := result.Export() + jsonBytes, err := json.Marshal(exported) + if err != nil { + return nil, fmt.Errorf("failed to marshal result: %w", err) + } + + var tracks []ExtTrackMetadata + if err := json.Unmarshal(jsonBytes, &tracks); err != nil { + return nil, fmt.Errorf("failed to parse search result: %w", err) + } + + // Return empty array if no tracks found + if tracks == nil { + tracks = []ExtTrackMetadata{} + } + + // Set provider ID on all tracks + for i := range tracks { + tracks[i].ProviderID = p.extension.ID + } + + return tracks, nil +} + +// ==================== Custom Track Matching ==================== + +// MatchTrackResult represents the result of custom track matching +type MatchTrackResult struct { + Matched bool `json:"matched"` + TrackID string `json:"track_id,omitempty"` + Confidence float64 `json:"confidence,omitempty"` + Reason string `json:"reason,omitempty"` +} + +// MatchTrack uses extension's custom matching algorithm +func (p *ExtensionProviderWrapper) MatchTrack(sourceTrack map[string]interface{}, candidates []map[string]interface{}) (*MatchTrackResult, error) { + if !p.extension.Manifest.HasCustomMatching() { + return nil, fmt.Errorf("extension '%s' does not support custom matching", p.extension.ID) + } + + if !p.extension.Enabled { + return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID) + } + + sourceJSON, _ := json.Marshal(sourceTrack) + candidatesJSON, _ := json.Marshal(candidates) + + script := fmt.Sprintf(` + (function() { + if (typeof extension !== 'undefined' && typeof extension.matchTrack === 'function') { + return extension.matchTrack(%s, %s); + } + return null; + })() + `, string(sourceJSON), string(candidatesJSON)) + + result, err := p.vm.RunString(script) + if err != nil { + return nil, fmt.Errorf("matchTrack failed: %w", err) + } + + if result == nil || goja.IsUndefined(result) || goja.IsNull(result) { + return &MatchTrackResult{Matched: false, Reason: "not implemented"}, nil + } + + exported := result.Export() + jsonBytes, err := json.Marshal(exported) + if err != nil { + return nil, fmt.Errorf("failed to marshal result: %w", err) + } + + var matchResult MatchTrackResult + if err := json.Unmarshal(jsonBytes, &matchResult); err != nil { + return nil, fmt.Errorf("failed to parse match result: %w", err) + } + + return &matchResult, nil +} + +// ==================== Post-Processing ==================== + +// PostProcessResult represents the result of post-processing +type PostProcessResult struct { + Success bool `json:"success"` + NewFilePath string `json:"new_file_path,omitempty"` + Error string `json:"error,omitempty"` + // Additional metadata that may have changed + BitDepth int `json:"bit_depth,omitempty"` + SampleRate int `json:"sample_rate,omitempty"` +} + +// PostProcess runs post-processing hooks on a downloaded file +func (p *ExtensionProviderWrapper) PostProcess(filePath string, metadata map[string]interface{}, hookID string) (*PostProcessResult, error) { + if !p.extension.Manifest.HasPostProcessing() { + return nil, fmt.Errorf("extension '%s' does not support post-processing", p.extension.ID) + } + + if !p.extension.Enabled { + return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID) + } + + metadataJSON, _ := json.Marshal(metadata) + + script := fmt.Sprintf(` + (function() { + if (typeof extension !== 'undefined' && typeof extension.postProcess === 'function') { + return extension.postProcess(%q, %s, %q); + } + return null; + })() + `, filePath, string(metadataJSON), hookID) + + result, err := p.vm.RunString(script) + if err != nil { + return &PostProcessResult{ + Success: false, + Error: err.Error(), + }, nil + } + + if result == nil || goja.IsUndefined(result) || goja.IsNull(result) { + return &PostProcessResult{ + Success: false, + Error: "postProcess returned null", + }, nil + } + + exported := result.Export() + jsonBytes, err := json.Marshal(exported) + if err != nil { + return &PostProcessResult{ + Success: false, + Error: fmt.Sprintf("failed to marshal result: %v", err), + }, nil + } + + var postResult PostProcessResult + if err := json.Unmarshal(jsonBytes, &postResult); err != nil { + return &PostProcessResult{ + Success: false, + Error: fmt.Sprintf("failed to parse result: %v", err), + }, nil + } + + return &postResult, nil +} + +// ==================== Extension Manager Advanced Methods ==================== + +// GetSearchProviders returns all extensions that provide custom search +func (m *ExtensionManager) GetSearchProviders() []*ExtensionProviderWrapper { + m.mu.RLock() + defer m.mu.RUnlock() + + var providers []*ExtensionProviderWrapper + for _, ext := range m.extensions { + if ext.Enabled && ext.Manifest.HasCustomSearch() && ext.Error == "" { + providers = append(providers, NewExtensionProviderWrapper(ext)) + } + } + return providers +} + +// GetPostProcessingProviders returns all extensions that provide post-processing +func (m *ExtensionManager) GetPostProcessingProviders() []*ExtensionProviderWrapper { + m.mu.RLock() + defer m.mu.RUnlock() + + var providers []*ExtensionProviderWrapper + for _, ext := range m.extensions { + if ext.Enabled && ext.Manifest.HasPostProcessing() && ext.Error == "" { + providers = append(providers, NewExtensionProviderWrapper(ext)) + } + } + return providers +} + +// RunPostProcessing runs all enabled post-processing hooks on a file +func (m *ExtensionManager) RunPostProcessing(filePath string, metadata map[string]interface{}) (*PostProcessResult, error) { + providers := m.GetPostProcessingProviders() + if len(providers) == 0 { + return &PostProcessResult{Success: true, NewFilePath: filePath}, nil + } + + currentPath := filePath + for _, provider := range providers { + hooks := provider.extension.Manifest.GetPostProcessingHooks() + for _, hook := range hooks { + // Check if hook is enabled (TODO: check user settings) + if !hook.DefaultEnabled { + continue + } + + // Check if format is supported + ext := strings.ToLower(filepath.Ext(currentPath)) + if len(hook.SupportedFormats) > 0 { + supported := false + for _, format := range hook.SupportedFormats { + if "."+format == ext || format == ext[1:] { + supported = true + break + } + } + if !supported { + continue + } + } + + GoLog("[PostProcess] Running hook %s from %s on %s\n", hook.ID, provider.extension.ID, currentPath) + + result, err := provider.PostProcess(currentPath, metadata, hook.ID) + if err != nil { + GoLog("[PostProcess] Hook %s failed: %v\n", hook.ID, err) + continue + } + + if result.Success && result.NewFilePath != "" { + currentPath = result.NewFilePath + } + } + } + + return &PostProcessResult{Success: true, NewFilePath: currentPath}, nil +} diff --git a/go_backend/extension_runtime.go b/go_backend/extension_runtime.go new file mode 100644 index 00000000..5ecb4264 --- /dev/null +++ b/go_backend/extension_runtime.go @@ -0,0 +1,1935 @@ +// Package gobackend provides extension runtime with sandboxed execution +package gobackend + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/md5" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "os" + "path/filepath" + "strings" + "sync" + "time" + + "github.com/dop251/goja" +) + +// Global auth state for extensions (stores pending auth codes) +var ( + extensionAuthState = make(map[string]*ExtensionAuthState) + extensionAuthStateMu sync.RWMutex +) + +// ExtensionAuthState holds auth state for an extension +type ExtensionAuthState struct { + PendingAuthURL string + AuthCode string + AccessToken string + RefreshToken string + ExpiresAt time.Time + IsAuthenticated bool +} + +// PendingAuthRequest holds a pending OAuth request that needs Flutter to open URL +type PendingAuthRequest struct { + ExtensionID string + AuthURL string + CallbackURL string +} + +// Global pending auth requests (Flutter polls this) +var ( + pendingAuthRequests = make(map[string]*PendingAuthRequest) + pendingAuthRequestsMu sync.RWMutex +) + +// GetPendingAuthRequest returns pending auth request for an extension (called from Flutter) +func GetPendingAuthRequest(extensionID string) *PendingAuthRequest { + pendingAuthRequestsMu.RLock() + defer pendingAuthRequestsMu.RUnlock() + return pendingAuthRequests[extensionID] +} + +// ClearPendingAuthRequest clears pending auth request (called from Flutter after opening URL) +func ClearPendingAuthRequest(extensionID string) { + pendingAuthRequestsMu.Lock() + defer pendingAuthRequestsMu.Unlock() + delete(pendingAuthRequests, extensionID) +} + +// SetExtensionAuthCode sets auth code for an extension (called from Flutter after OAuth callback) +func SetExtensionAuthCode(extensionID string, authCode string) { + extensionAuthStateMu.Lock() + defer extensionAuthStateMu.Unlock() + + state, exists := extensionAuthState[extensionID] + if !exists { + state = &ExtensionAuthState{} + extensionAuthState[extensionID] = state + } + state.AuthCode = authCode +} + +// SetExtensionTokens sets access/refresh tokens for an extension +func SetExtensionTokens(extensionID string, accessToken, refreshToken string, expiresAt time.Time) { + extensionAuthStateMu.Lock() + defer extensionAuthStateMu.Unlock() + + state, exists := extensionAuthState[extensionID] + if !exists { + state = &ExtensionAuthState{} + extensionAuthState[extensionID] = state + } + state.AccessToken = accessToken + state.RefreshToken = refreshToken + state.ExpiresAt = expiresAt + state.IsAuthenticated = accessToken != "" +} + +// ExtensionRuntime provides sandboxed APIs for extensions +type ExtensionRuntime struct { + extensionID string + manifest *ExtensionManifest + settings map[string]interface{} + httpClient *http.Client + dataDir string + vm *goja.Runtime +} + +// NewExtensionRuntime creates a new runtime for an extension +func NewExtensionRuntime(ext *LoadedExtension) *ExtensionRuntime { + return &ExtensionRuntime{ + extensionID: ext.ID, + manifest: ext.Manifest, + settings: make(map[string]interface{}), + httpClient: &http.Client{ + Timeout: 30 * time.Second, + }, + dataDir: ext.DataDir, + vm: ext.VM, + } +} + +// SetSettings updates the runtime settings +func (r *ExtensionRuntime) SetSettings(settings map[string]interface{}) { + r.settings = settings +} + +// RegisterAPIs registers all sandboxed APIs to the Goja VM +func (r *ExtensionRuntime) RegisterAPIs(vm *goja.Runtime) { + r.vm = vm + + // HTTP client (sandboxed to allowed domains) + httpObj := vm.NewObject() + httpObj.Set("get", r.httpGet) + httpObj.Set("post", r.httpPost) + vm.Set("http", httpObj) + + // Storage API + storageObj := vm.NewObject() + storageObj.Set("get", r.storageGet) + storageObj.Set("set", r.storageSet) + storageObj.Set("remove", r.storageRemove) + vm.Set("storage", storageObj) + + // Secure Credentials API (encrypted storage for sensitive data) + credentialsObj := vm.NewObject() + credentialsObj.Set("store", r.credentialsStore) + credentialsObj.Set("get", r.credentialsGet) + credentialsObj.Set("remove", r.credentialsRemove) + credentialsObj.Set("has", r.credentialsHas) + vm.Set("credentials", credentialsObj) + + // Auth API (for OAuth and other auth flows) + authObj := vm.NewObject() + authObj.Set("openAuthUrl", r.authOpenUrl) + authObj.Set("getAuthCode", r.authGetCode) + authObj.Set("setAuthCode", r.authSetCode) + authObj.Set("clearAuth", r.authClear) + authObj.Set("isAuthenticated", r.authIsAuthenticated) + authObj.Set("getTokens", r.authGetTokens) + vm.Set("auth", authObj) + + // File operations (sandboxed) + fileObj := vm.NewObject() + fileObj.Set("download", r.fileDownload) + fileObj.Set("exists", r.fileExists) + fileObj.Set("delete", r.fileDelete) + fileObj.Set("read", r.fileRead) + fileObj.Set("write", r.fileWrite) + fileObj.Set("copy", r.fileCopy) + fileObj.Set("move", r.fileMove) + fileObj.Set("getSize", r.fileGetSize) + vm.Set("file", fileObj) + + // FFmpeg API (for post-processing) + ffmpegObj := vm.NewObject() + ffmpegObj.Set("execute", r.ffmpegExecute) + ffmpegObj.Set("getInfo", r.ffmpegGetInfo) + ffmpegObj.Set("convert", r.ffmpegConvert) + vm.Set("ffmpeg", ffmpegObj) + + // Track matching API + matchingObj := vm.NewObject() + matchingObj.Set("compareStrings", r.matchingCompareStrings) + matchingObj.Set("compareDuration", r.matchingCompareDuration) + matchingObj.Set("normalizeString", r.matchingNormalizeString) + vm.Set("matching", matchingObj) + + // Utilities + utilsObj := vm.NewObject() + utilsObj.Set("base64Encode", r.base64Encode) + utilsObj.Set("base64Decode", r.base64Decode) + utilsObj.Set("md5", r.md5Hash) + utilsObj.Set("sha256", r.sha256Hash) + utilsObj.Set("parseJSON", r.parseJSON) + utilsObj.Set("stringifyJSON", r.stringifyJSON) + // Crypto utilities for developers + utilsObj.Set("encrypt", r.cryptoEncrypt) + utilsObj.Set("decrypt", r.cryptoDecrypt) + utilsObj.Set("generateKey", r.cryptoGenerateKey) + vm.Set("utils", utilsObj) + + // Log object (already set in extension_manager.go, but we can enhance it) + logObj := vm.NewObject() + logObj.Set("debug", r.logDebug) + logObj.Set("info", r.logInfo) + logObj.Set("warn", r.logWarn) + logObj.Set("error", r.logError) + vm.Set("log", logObj) + + // Go backend functions + gobackendObj := vm.NewObject() + gobackendObj.Set("sanitizeFilename", r.sanitizeFilenameWrapper) + vm.Set("gobackend", gobackendObj) +} + +// ==================== 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 + for k, v := range headers { + req.Header.Set(k, v) + } + 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 + respHeaders := make(map[string]string) + for k, v := range resp.Header { + if len(v) > 0 { + respHeaders[k] = v[0] + } + } + + return r.vm.ToValue(map[string]interface{}{ + "statusCode": resp.StatusCode, + "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 + var bodyStr string + if len(call.Arguments) > 1 && !goja.IsUndefined(call.Arguments[1]) && !goja.IsNull(call.Arguments[1]) { + 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 + for k, v := range headers { + req.Header.Set(k, v) + } + 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 + respHeaders := make(map[string]string) + for k, v := range resp.Header { + if len(v) > 0 { + respHeaders[k] = v[0] + } + } + + return r.vm.ToValue(map[string]interface{}{ + "statusCode": resp.StatusCode, + "body": string(body), + "headers": respHeaders, + }) +} + +// ==================== 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, + }) +} + +// ==================== 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) +} + +// ==================== 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[:])) +} + +// 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)) +} + +// ==================== 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)) + }) +} + +// ==================== 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) +} + +// ==================== 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) +} + +// ==================== 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 +} + +// 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), + }) +} + +// ==================== Additional File Operations ==================== + +// 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(), + }) +} + +// ==================== 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) +} + +// ==================== 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) +} diff --git a/go_backend/extension_settings.go b/go_backend/extension_settings.go new file mode 100644 index 00000000..6f46773c --- /dev/null +++ b/go_backend/extension_settings.go @@ -0,0 +1,221 @@ +// Package gobackend provides extension settings storage +package gobackend + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "sync" +) + +// ExtensionSettingsStore manages settings for all extensions +type ExtensionSettingsStore struct { + mu sync.RWMutex + dataDir string + settings map[string]map[string]interface{} // extensionID -> settings +} + +// Global settings store +var ( + globalSettingsStore *ExtensionSettingsStore + globalSettingsStoreOnce sync.Once +) + +// GetExtensionSettingsStore returns the global settings store +func GetExtensionSettingsStore() *ExtensionSettingsStore { + globalSettingsStoreOnce.Do(func() { + globalSettingsStore = &ExtensionSettingsStore{ + settings: make(map[string]map[string]interface{}), + } + }) + return globalSettingsStore +} + +// SetDataDir sets the data directory for settings storage +func (s *ExtensionSettingsStore) SetDataDir(dataDir string) error { + s.mu.Lock() + defer s.mu.Unlock() + + s.dataDir = dataDir + if err := os.MkdirAll(dataDir, 0755); err != nil { + return fmt.Errorf("failed to create settings directory: %w", err) + } + + // Load all existing settings + return s.loadAllSettings() +} + +// getSettingsPath returns the path to an extension's settings file +func (s *ExtensionSettingsStore) getSettingsPath(extensionID string) string { + return filepath.Join(s.dataDir, extensionID, "settings.json") +} + +// loadAllSettings loads settings for all extensions from disk +func (s *ExtensionSettingsStore) loadAllSettings() error { + entries, err := os.ReadDir(s.dataDir) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return err + } + + for _, entry := range entries { + if entry.IsDir() { + extensionID := entry.Name() + settings, err := s.loadSettings(extensionID) + if err != nil { + GoLog("[ExtensionSettings] Failed to load settings for %s: %v\n", extensionID, err) + continue + } + s.settings[extensionID] = settings + } + } + + return nil +} + +// loadSettings loads settings for a specific extension +func (s *ExtensionSettingsStore) loadSettings(extensionID string) (map[string]interface{}, error) { + settingsPath := s.getSettingsPath(extensionID) + data, err := os.ReadFile(settingsPath) + if err != nil { + if os.IsNotExist(err) { + return make(map[string]interface{}), nil + } + return nil, err + } + + var settings map[string]interface{} + if err := json.Unmarshal(data, &settings); err != nil { + return nil, err + } + + return settings, nil +} + +// saveSettings saves settings for a specific extension +func (s *ExtensionSettingsStore) saveSettings(extensionID string, settings map[string]interface{}) error { + settingsPath := s.getSettingsPath(extensionID) + + // Create directory if needed + dir := filepath.Dir(settingsPath) + if err := os.MkdirAll(dir, 0755); err != nil { + return err + } + + data, err := json.MarshalIndent(settings, "", " ") + if err != nil { + return err + } + + return os.WriteFile(settingsPath, data, 0644) +} + +// Get retrieves a setting value for an extension +// Returns error if extension or key not found (gomobile compatible) +func (s *ExtensionSettingsStore) Get(extensionID, key string) (interface{}, error) { + s.mu.RLock() + defer s.mu.RUnlock() + + extSettings, exists := s.settings[extensionID] + if !exists { + return nil, fmt.Errorf("extension '%s' settings not found", extensionID) + } + + value, exists := extSettings[key] + if !exists { + return nil, fmt.Errorf("setting '%s' not found for extension '%s'", key, extensionID) + } + return value, nil +} + +// GetAll retrieves all settings for an extension +func (s *ExtensionSettingsStore) GetAll(extensionID string) map[string]interface{} { + s.mu.RLock() + defer s.mu.RUnlock() + + extSettings, exists := s.settings[extensionID] + if !exists { + return make(map[string]interface{}) + } + + // Return a copy + result := make(map[string]interface{}) + for k, v := range extSettings { + result[k] = v + } + return result +} + +// Set stores a setting value for an extension +func (s *ExtensionSettingsStore) Set(extensionID, key string, value interface{}) error { + s.mu.Lock() + defer s.mu.Unlock() + + if _, exists := s.settings[extensionID]; !exists { + s.settings[extensionID] = make(map[string]interface{}) + } + + s.settings[extensionID][key] = value + + // Persist to disk + return s.saveSettings(extensionID, s.settings[extensionID]) +} + +// SetAll stores all settings for an extension +func (s *ExtensionSettingsStore) SetAll(extensionID string, settings map[string]interface{}) error { + s.mu.Lock() + defer s.mu.Unlock() + + s.settings[extensionID] = settings + + // Persist to disk + return s.saveSettings(extensionID, settings) +} + +// Remove removes a setting for an extension +func (s *ExtensionSettingsStore) Remove(extensionID, key string) error { + s.mu.Lock() + defer s.mu.Unlock() + + extSettings, exists := s.settings[extensionID] + if !exists { + return nil + } + + delete(extSettings, key) + + // Persist to disk + return s.saveSettings(extensionID, extSettings) +} + +// RemoveAll removes all settings for an extension +func (s *ExtensionSettingsStore) RemoveAll(extensionID string) error { + s.mu.Lock() + defer s.mu.Unlock() + + delete(s.settings, extensionID) + + // Remove settings file + settingsPath := s.getSettingsPath(extensionID) + if err := os.Remove(settingsPath); err != nil && !os.IsNotExist(err) { + return err + } + + return nil +} + +// GetAllExtensionSettings returns settings for all extensions as JSON +func (s *ExtensionSettingsStore) GetAllExtensionSettingsJSON() (string, error) { + s.mu.RLock() + defer s.mu.RUnlock() + + data, err := json.Marshal(s.settings) + if err != nil { + return "", err + } + + return string(data), nil +} diff --git a/go_backend/extension_test.go b/go_backend/extension_test.go new file mode 100644 index 00000000..5cc552a1 --- /dev/null +++ b/go_backend/extension_test.go @@ -0,0 +1,219 @@ +package gobackend + +import ( + "testing" + + "github.com/dop251/goja" +) + +func TestParseManifest_Valid(t *testing.T) { + validManifest := `{ + "name": "test-provider", + "displayName": "Test Provider", + "version": "1.0.0", + "author": "Test Author", + "description": "A test extension", + "type": ["metadata_provider"], + "permissions": { + "network": ["api.test.com"], + "storage": true + } + }` + + manifest, err := ParseManifest([]byte(validManifest)) + if err != nil { + t.Fatalf("Expected valid manifest to parse, got error: %v", err) + } + + if manifest.Name != "test-provider" { + t.Errorf("Expected name 'test-provider', got '%s'", manifest.Name) + } + + if manifest.Version != "1.0.0" { + t.Errorf("Expected version '1.0.0', got '%s'", manifest.Version) + } + + if !manifest.IsMetadataProvider() { + t.Error("Expected IsMetadataProvider() to return true") + } + + if manifest.IsDownloadProvider() { + t.Error("Expected IsDownloadProvider() to return false") + } +} + +func TestParseManifest_MissingName(t *testing.T) { + invalidManifest := `{ + "version": "1.0.0", + "author": "Test Author", + "description": "A test extension", + "type": ["metadata_provider"] + }` + + _, err := ParseManifest([]byte(invalidManifest)) + if err == nil { + t.Fatal("Expected error for missing name") + } +} + +func TestParseManifest_MissingType(t *testing.T) { + invalidManifest := `{ + "name": "test-provider", + "version": "1.0.0", + "author": "Test Author", + "description": "A test extension" + }` + + _, err := ParseManifest([]byte(invalidManifest)) + if err == nil { + t.Fatal("Expected error for missing type") + } +} + +func TestIsDomainAllowed(t *testing.T) { + manifest := &ExtensionManifest{ + Permissions: ExtensionPermissions{ + Network: []string{"api.test.com", "*.example.com"}, + }, + } + + tests := []struct { + domain string + expected bool + }{ + {"api.test.com", true}, + {"api.example.com", true}, + {"sub.example.com", true}, + {"notallowed.com", false}, + {"test.com", false}, + } + + for _, tt := range tests { + result := manifest.IsDomainAllowed(tt.domain) + if result != tt.expected { + t.Errorf("IsDomainAllowed(%s) = %v, expected %v", tt.domain, result, tt.expected) + } + } +} + +func TestExtensionRuntime_NetworkSandbox(t *testing.T) { + // Create a mock extension with limited network permissions + ext := &LoadedExtension{ + ID: "test-ext", + Manifest: &ExtensionManifest{ + Name: "test-ext", + Permissions: ExtensionPermissions{ + Network: []string{"api.allowed.com", "*.wildcard.com"}, + }, + }, + DataDir: t.TempDir(), + } + + runtime := NewExtensionRuntime(ext) + + // Test allowed domains + if err := runtime.validateDomain("https://api.allowed.com/path"); err != nil { + t.Errorf("Expected api.allowed.com to be allowed, got error: %v", err) + } + + if err := runtime.validateDomain("https://sub.wildcard.com/path"); err != nil { + t.Errorf("Expected sub.wildcard.com to be allowed (wildcard), got error: %v", err) + } + + // Test blocked domains + if err := runtime.validateDomain("https://blocked.com/path"); err == nil { + t.Error("Expected blocked.com to be denied") + } + + if err := runtime.validateDomain("https://notallowed.com/path"); err == nil { + t.Error("Expected notallowed.com to be denied") + } +} + +func TestExtensionRuntime_FileSandbox(t *testing.T) { + tempDir := t.TempDir() + + ext := &LoadedExtension{ + ID: "test-ext", + Manifest: &ExtensionManifest{ + Name: "test-ext", + }, + DataDir: tempDir, + } + + runtime := NewExtensionRuntime(ext) + + // Test valid path within sandbox + validPath, err := runtime.validatePath("test.txt") + if err != nil { + t.Errorf("Expected relative path to be valid, got error: %v", err) + } + if validPath == "" { + t.Error("Expected non-empty path") + } + + // Test path traversal attack + _, err = runtime.validatePath("../../../etc/passwd") + if err == nil { + t.Error("Expected path traversal to be blocked") + } + + // Test nested path within sandbox (should be allowed) + nestedPath, err := runtime.validatePath("subdir/file.txt") + if err != nil { + t.Errorf("Expected nested path to be valid, got error: %v", err) + } + if nestedPath == "" { + t.Error("Expected non-empty nested path") + } +} + +func TestExtensionRuntime_UtilityFunctions(t *testing.T) { + ext := &LoadedExtension{ + ID: "test-ext", + Manifest: &ExtensionManifest{ + Name: "test-ext", + }, + DataDir: t.TempDir(), + } + + runtime := NewExtensionRuntime(ext) + vm := goja.New() + runtime.RegisterAPIs(vm) + + // Test base64 encode/decode + result, err := vm.RunString(`utils.base64Encode("hello")`) + if err != nil { + t.Fatalf("base64Encode failed: %v", err) + } + if result.String() != "aGVsbG8=" { + t.Errorf("Expected 'aGVsbG8=', got '%s'", result.String()) + } + + result, err = vm.RunString(`utils.base64Decode("aGVsbG8=")`) + if err != nil { + t.Fatalf("base64Decode failed: %v", err) + } + if result.String() != "hello" { + t.Errorf("Expected 'hello', got '%s'", result.String()) + } + + // Test MD5 + result, err = vm.RunString(`utils.md5("hello")`) + if err != nil { + t.Fatalf("md5 failed: %v", err) + } + if result.String() != "5d41402abc4b2a76b9719d911017c592" { + t.Errorf("Expected '5d41402abc4b2a76b9719d911017c592', got '%s'", result.String()) + } + + // Test JSON parse/stringify + result, err = vm.RunString(`utils.stringifyJSON({name: "test", value: 123})`) + if err != nil { + t.Fatalf("stringifyJSON failed: %v", err) + } + // JSON output may vary in order, just check it's valid + if result.String() == "" { + t.Error("Expected non-empty JSON string") + } +} diff --git a/go_backend/go.mod b/go_backend/go.mod index fcf64720..500a85f8 100644 --- a/go_backend/go.mod +++ b/go_backend/go.mod @@ -5,14 +5,19 @@ go 1.24.0 toolchain go1.24.5 require ( + github.com/dop251/goja v0.0.0-20260106131823-651366fbe6e3 github.com/go-flac/flacpicture v0.3.0 github.com/go-flac/flacvorbis v0.2.0 github.com/go-flac/go-flac v1.0.0 ) require ( + github.com/dlclark/regexp2 v1.11.4 // indirect + github.com/go-sourcemap/sourcemap v2.1.3+incompatible // indirect + github.com/google/pprof v0.0.0-20230207041349-798e818bf904 // indirect golang.org/x/mobile v0.0.0-20251209145715-2553ed8ce294 // indirect golang.org/x/mod v0.31.0 // indirect golang.org/x/sync v0.19.0 // indirect + golang.org/x/text v0.3.8 // indirect golang.org/x/tools v0.40.0 // indirect ) diff --git a/go_backend/go.sum b/go_backend/go.sum index c93680e0..68a7935e 100644 --- a/go_backend/go.sum +++ b/go_backend/go.sum @@ -1,14 +1,28 @@ +github.com/Masterminds/semver/v3 v3.2.1 h1:RN9w6+7QoMeJVGyfmbcgs28Br8cvmnucEXnY0rYXWg0= +github.com/Masterminds/semver/v3 v3.2.1/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ= +github.com/dlclark/regexp2 v1.11.4 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yAo= +github.com/dlclark/regexp2 v1.11.4/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/dop251/goja v0.0.0-20260106131823-651366fbe6e3 h1:bVp3yUzvSAJzu9GqID+Z96P+eu5TKnIMJSV4QaZMauM= +github.com/dop251/goja v0.0.0-20260106131823-651366fbe6e3/go.mod h1:MxLav0peU43GgvwVgNbLAj1s/bSGboKkhuULvq/7hx4= github.com/go-flac/flacpicture v0.3.0 h1:LkmTxzFLIynwfhHiZsX0s8xcr3/u33MzvV89u+zOT8I= github.com/go-flac/flacpicture v0.3.0/go.mod h1:DPbrzVYQ3fJcvSgLFp9HXIrEQEdfdk/+m0nQCzwodZI= github.com/go-flac/flacvorbis v0.2.0 h1:KH0xjpkNTXFER4cszH4zeJxYcrHbUobz/RticWGOESs= github.com/go-flac/flacvorbis v0.2.0/go.mod h1:uIysHOtuU7OLGoCRG92bvnkg7QEqHx19qKRV6K1pBrI= github.com/go-flac/go-flac v1.0.0 h1:6qI9XOVLcO50xpzm3nXvO31BgDgHhnr/p/rER/K/doY= github.com/go-flac/go-flac v1.0.0/go.mod h1:WnZhcpmq4u1UdZMNn9LYSoASpWOCMOoxXxcWEHSzkW8= +github.com/go-sourcemap/sourcemap v2.1.3+incompatible h1:W1iEw64niKVGogNgBN3ePyLFfuisuzeidWPMPWmECqU= +github.com/go-sourcemap/sourcemap v2.1.3+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg= +github.com/google/pprof v0.0.0-20230207041349-798e818bf904 h1:4/hN5RUoecvl+RmJRE2YxKWtnnQls6rQjjW5oV7qg2U= +github.com/google/pprof v0.0.0-20230207041349-798e818bf904/go.mod h1:uglQLonpP8qtYCYyzA+8c/9qtqgA3qsXGYqCPKARAFg= golang.org/x/mobile v0.0.0-20251209145715-2553ed8ce294 h1:Cr6kbEvA6nqvdHynE4CtVKlqpZB9dS1Jva/6IsHA19g= golang.org/x/mobile v0.0.0-20251209145715-2553ed8ce294/go.mod h1:RdZ+3sb4CVgpCFnzv+I4haEpwqFfsfzlLHs3L7ok+e0= golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI= golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY= +golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA= golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= diff --git a/go_backend/httputil.go b/go_backend/httputil.go index 88b0d6cc..8686ad5f 100644 --- a/go_backend/httputil.go +++ b/go_backend/httputil.go @@ -22,12 +22,12 @@ import ( func getRandomUserAgent() string { // Windows 10/11 Chrome format - same as PC version for maximum compatibility // Some APIs may block mobile User-Agents, so we use desktop format - winMajor := rand.Intn(2) + 10 // Windows 10 or 11 - - chromeVersion := rand.Intn(25) + 100 // Chrome 100-124 + winMajor := rand.Intn(2) + 10 // Windows 10 or 11 + + chromeVersion := rand.Intn(25) + 100 // Chrome 100-124 chromeBuild := rand.Intn(1500) + 3000 // Build 3000-4500 - chromePatch := rand.Intn(65) + 60 // Patch 60-125 - + chromePatch := rand.Intn(65) + 60 // Patch 60-125 + return fmt.Sprintf( "Mozilla/5.0 (Windows NT %d.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/%d.0.%d.%d Safari/537.36", winMajor, @@ -39,46 +39,48 @@ func getRandomUserAgent() string { // getRandomMacUserAgent generates a random Mac Chrome User-Agent string // Alternative format matching referensi/backend/spotify_metadata.go exactly -func getRandomMacUserAgent() string { - macMajor := rand.Intn(4) + 11 // macOS 11-14 - macMinor := rand.Intn(5) + 4 // Minor 4-8 - webkitMajor := rand.Intn(7) + 530 - webkitMinor := rand.Intn(7) + 30 - chromeMajor := rand.Intn(25) + 80 - chromeBuild := rand.Intn(1500) + 3000 - chromePatch := rand.Intn(65) + 60 - safariMajor := rand.Intn(7) + 530 - safariMinor := rand.Intn(6) + 30 - - 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", - macMajor, - macMinor, - webkitMajor, - webkitMinor, - chromeMajor, - chromeBuild, - chromePatch, - safariMajor, - safariMinor, - ) -} +// Kept for potential future use +// func getRandomMacUserAgent() string { +// macMajor := rand.Intn(4) + 11 // macOS 11-14 +// macMinor := rand.Intn(5) + 4 // Minor 4-8 +// webkitMajor := rand.Intn(7) + 530 +// webkitMinor := rand.Intn(7) + 30 +// chromeMajor := rand.Intn(25) + 80 +// chromeBuild := rand.Intn(1500) + 3000 +// chromePatch := rand.Intn(65) + 60 +// safariMajor := rand.Intn(7) + 530 +// safariMinor := rand.Intn(6) + 30 +// +// 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", +// macMajor, +// macMinor, +// webkitMajor, +// webkitMinor, +// chromeMajor, +// chromeBuild, +// chromePatch, +// safariMajor, +// safariMinor, +// ) +// } // getRandomDesktopUserAgent randomly picks between Windows and Mac User-Agent -func getRandomDesktopUserAgent() string { - if rand.Intn(2) == 0 { - return getRandomUserAgent() // Windows - } - return getRandomMacUserAgent() // Mac -} +// Kept for potential future use +// func getRandomDesktopUserAgent() string { +// if rand.Intn(2) == 0 { +// return getRandomUserAgent() // Windows +// } +// return getRandomMacUserAgent() // Mac +// } // Default timeout values const ( - DefaultTimeout = 60 * time.Second // Default HTTP timeout - DownloadTimeout = 120 * time.Second // Timeout for file downloads - SongLinkTimeout = 30 * time.Second // Timeout for SongLink API - DefaultMaxRetries = 3 // Default retry count - DefaultRetryDelay = 1 * time.Second // Initial retry delay + DefaultTimeout = 60 * time.Second // Default HTTP timeout + DownloadTimeout = 120 * time.Second // Timeout for file downloads + SongLinkTimeout = 30 * time.Second // Timeout for SongLink API + DefaultMaxRetries = 3 // Default retry count + DefaultRetryDelay = 1 * time.Second // Initial retry delay ) // Shared transport with connection pooling to prevent TCP exhaustion @@ -96,9 +98,9 @@ var sharedTransport = &http.Transport{ ExpectContinueTimeout: 1 * time.Second, DisableKeepAlives: false, // Enable keep-alives for connection reuse ForceAttemptHTTP2: true, - WriteBufferSize: 64 * 1024, // 64KB write buffer - ReadBufferSize: 64 * 1024, // 64KB read buffer - DisableCompression: true, // FLAC is already compressed + WriteBufferSize: 64 * 1024, // 64KB write buffer + ReadBufferSize: 64 * 1024, // 64KB read buffer + DisableCompression: true, // FLAC is already compressed } // Shared HTTP client for general requests (reuses connections) @@ -184,15 +186,15 @@ func DoRequestWithRetry(client *http.Client, req *http.Request, config RetryConf resp, err := client.Do(reqCopy) if err != nil { lastErr = err - + // Check for ISP blocking on network errors if CheckAndLogISPBlocking(err, requestURL, "HTTP") { // Don't retry if ISP blocking is detected - it won't help return nil, WrapErrorWithISPCheck(err, requestURL, "HTTP") } - + if attempt < config.MaxRetries { - GoLog("[HTTP] Request failed (attempt %d/%d): %v, retrying in %v...\n", + GoLog("[HTTP] Request failed (attempt %d/%d): %v, retrying in %v...\n", attempt+1, config.MaxRetries+1, err, delay) time.Sleep(delay) delay = calculateNextDelay(delay, config) @@ -227,13 +229,13 @@ func DoRequestWithRetry(client *http.Client, req *http.Request, config RetryConf body, _ := io.ReadAll(resp.Body) resp.Body.Close() bodyStr := strings.ToLower(string(body)) - + // Check if response looks like ISP blocking page ispBlockingIndicators := []string{ "blocked", "forbidden", "access denied", "not available in your", "restricted", "censored", "unavailable for legal", "blocked by", } - + for _, indicator := range ispBlockingIndicators { if strings.Contains(bodyStr, indicator) { LogError("HTTP", "ISP BLOCKING DETECTED via HTTP %d response", resp.StatusCode) @@ -267,10 +269,7 @@ func DoRequestWithRetry(client *http.Client, req *http.Request, config RetryConf // calculateNextDelay calculates the next delay with exponential backoff func calculateNextDelay(currentDelay time.Duration, config RetryConfig) time.Duration { nextDelay := time.Duration(float64(currentDelay) * config.BackoffFactor) - if nextDelay > config.MaxDelay { - nextDelay = config.MaxDelay - } - return nextDelay + return min(nextDelay, config.MaxDelay) } // getRetryAfterDuration parses Retry-After header and returns duration @@ -481,7 +480,7 @@ func extractDomain(rawURL string) string { if rawURL == "" { return "unknown" } - + parsed, err := url.Parse(rawURL) if err != nil { // Try to extract domain manually @@ -492,7 +491,7 @@ func extractDomain(rawURL string) string { } return rawURL } - + if parsed.Host != "" { return parsed.Host } @@ -505,11 +504,11 @@ func WrapErrorWithISPCheck(err error, requestURL string, tag string) error { if err == nil { return nil } - + if CheckAndLogISPBlocking(err, requestURL, tag) { domain := extractDomain(requestURL) return fmt.Errorf("ISP blocking detected for %s - try using VPN or change DNS to 1.1.1.1/8.8.8.8: %w", domain, err) } - + return err } diff --git a/go_backend/lyrics.go b/go_backend/lyrics.go index 9d18b46f..b1aa66cc 100644 --- a/go_backend/lyrics.go +++ b/go_backend/lyrics.go @@ -250,29 +250,30 @@ func msToLRCTimestamp(ms int64) string { // convertToLRC converts lyrics to LRC format string (without metadata headers) // Use convertToLRCWithMetadata for full LRC with headers -func convertToLRC(lyrics *LyricsResponse) string { - if lyrics == nil || len(lyrics.Lines) == 0 { - return "" - } - - var builder strings.Builder - - if lyrics.SyncType == "LINE_SYNCED" { - for _, line := range lyrics.Lines { - timestamp := msToLRCTimestamp(line.StartTimeMs) - builder.WriteString(timestamp) - builder.WriteString(line.Words) - builder.WriteString("\n") - } - } else { - for _, line := range lyrics.Lines { - builder.WriteString(line.Words) - builder.WriteString("\n") - } - } - - return builder.String() -} +// Kept for potential future use +// func convertToLRC(lyrics *LyricsResponse) string { +// if lyrics == nil || len(lyrics.Lines) == 0 { +// return "" +// } +// +// var builder strings.Builder +// +// if lyrics.SyncType == "LINE_SYNCED" { +// for _, line := range lyrics.Lines { +// timestamp := msToLRCTimestamp(line.StartTimeMs) +// builder.WriteString(timestamp) +// builder.WriteString(line.Words) +// builder.WriteString("\n") +// } +// } else { +// for _, line := range lyrics.Lines { +// builder.WriteString(line.Words) +// builder.WriteString("\n") +// } +// } +// +// return builder.String() +// } // convertToLRCWithMetadata converts lyrics to LRC format with metadata headers // Includes [ti:], [ar:], [by:] headers diff --git a/go_backend/parallel.go b/go_backend/parallel.go index ffac2dc7..3eb7c8e2 100644 --- a/go_backend/parallel.go +++ b/go_backend/parallel.go @@ -233,7 +233,7 @@ func PreWarmTrackCache(requests []PreWarmCacheRequest) { fmt.Printf("[Cache] Pre-warm complete. Cache size: %d\n", cache.Size()) } -func preWarmTidalCache(isrc, trackName, artistName string) { +func preWarmTidalCache(isrc, _, _ string) { downloader := NewTidalDownloader() track, err := downloader.SearchTrackByISRC(isrc) if err == nil && track != nil { @@ -272,7 +272,7 @@ func PreWarmCache(tracksJSON string) error { var requests []PreWarmCacheRequest // Parse JSON (simplified - in production use proper JSON parsing) // For now, this is called from exports.go with proper parsing - + go PreWarmTrackCache(requests) // Run in background return nil } diff --git a/go_backend/progress.go b/go_backend/progress.go index f1a0dfe1..1c95313e 100644 --- a/go_backend/progress.go +++ b/go_backend/progress.go @@ -23,7 +23,7 @@ type ItemProgress struct { ItemID string `json:"item_id"` BytesTotal int64 `json:"bytes_total"` BytesReceived int64 `json:"bytes_received"` - Progress float64 `json:"progress"` // 0.0 to 1.0 + Progress float64 `json:"progress"` // 0.0 to 1.0 SpeedMBps float64 `json:"speed_mbps"` // Download speed in MB/s IsDownloading bool `json:"is_downloading"` Status string `json:"status"` // "downloading", "finalizing", "completed" @@ -204,11 +204,12 @@ func setDownloadDir(path string) error { } // getDownloadDir returns the default download directory -func getDownloadDir() string { - downloadDirMu.RLock() - defer downloadDirMu.RUnlock() - return downloadDir -} +// Kept for potential future use +// func getDownloadDir() string { +// downloadDirMu.RLock() +// defer downloadDirMu.RUnlock() +// return downloadDir +// } // ItemProgressWriter wraps io.Writer to track download progress for a specific item type ItemProgressWriter struct { @@ -256,7 +257,7 @@ func (pw *ItemProgressWriter) Write(p []byte) (int, error) { bytesInInterval := pw.current - pw.lastBytes speedMBps = float64(bytesInInterval) / (1024 * 1024) / elapsed } - + SetItemBytesReceivedWithSpeed(pw.itemID, pw.current, speedMBps) pw.lastReported = pw.current pw.lastTime = now diff --git a/go_backend/qobuz.go b/go_backend/qobuz.go index 8670312e..9b57bb3b 100644 --- a/go_backend/qobuz.go +++ b/go_backend/qobuz.go @@ -271,14 +271,15 @@ func qobuzIsLatinScript(s string) bool { } // qobuzIsASCIIString checks if a string contains only ASCII characters -func qobuzIsASCIIString(s string) bool { - for _, r := range s { - if r > 127 { - return false - } - } - return true -} +// Kept for potential future use +// func qobuzIsASCIIString(s string) bool { +// for _, r := range s { +// if r > 127 { +// return false +// } +// } +// return true +// } // containsQueryQobuz checks if a query already exists in the list func containsQueryQobuz(queries []string, query string) bool { diff --git a/go_backend/tidal.go b/go_backend/tidal.go index fba8552a..ad1d04e1 100644 --- a/go_backend/tidal.go +++ b/go_backend/tidal.go @@ -345,27 +345,28 @@ func (t *TidalDownloader) SearchTrackByISRC(isrc string) (*TidalTrack, error) { return nil, fmt.Errorf("no exact ISRC match found for: %s", isrc) } -// normalizeTitle normalizes a track title for comparison (kept for potential future use) -func normalizeTitle(title string) string { - normalized := strings.ToLower(strings.TrimSpace(title)) - - // Remove common suffixes in parentheses or brackets - suffixPatterns := []string{ - " (remaster)", " (remastered)", " (deluxe)", " (deluxe edition)", - " (bonus track)", " (single)", " (album version)", " (radio edit)", - " [remaster]", " [remastered]", " [deluxe]", " [bonus track]", - } - for _, suffix := range suffixPatterns { - normalized = strings.TrimSuffix(normalized, suffix) - } - - // Remove multiple spaces - for strings.Contains(normalized, " ") { - normalized = strings.ReplaceAll(normalized, " ", " ") - } - - return normalized -} +// normalizeTitle normalizes a track title for comparison +// Kept for potential future use +// func normalizeTitle(title string) string { +// normalized := strings.ToLower(strings.TrimSpace(title)) +// +// // Remove common suffixes in parentheses or brackets +// suffixPatterns := []string{ +// " (remaster)", " (remastered)", " (deluxe)", " (deluxe edition)", +// " (bonus track)", " (single)", " (album version)", " (radio edit)", +// " [remaster]", " [remastered]", " [deluxe]", " [bonus track]", +// } +// for _, suffix := range suffixPatterns { +// normalized = strings.TrimSuffix(normalized, suffix) +// } +// +// // Remove multiple spaces +// for strings.Contains(normalized, " ") { +// normalized = strings.ReplaceAll(normalized, " ", " ") +// } +// +// return normalized +// } // SearchTrackByMetadataWithISRC searches for a track with ISRC matching priority // Now includes romaji conversion for Japanese text (4 search strategies like PC) @@ -639,151 +640,20 @@ type TidalDownloadInfo struct { } // tidalAPIResult holds the result from a parallel API request -type tidalAPIResult struct { - apiURL string - info TidalDownloadInfo - err error - duration time.Duration -} +// Kept for potential future use with _getDownloadURLParallel +// 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) -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 longer timeout for parallel requests - client := &http.Client{ - Timeout: 15 * time.Second, - } - - reqURL := fmt.Sprintf("%s/track/?id=%d&quality=%s", api, trackID, quality) - GoLog("[Tidal] [Parallel] Starting request to: %s\n", api) - - req, err := http.NewRequest("GET", reqURL, nil) - if err != nil { - GoLog("[Tidal] [Parallel] %s - Failed to create request: %v\n", api, err) - resultChan <- tidalAPIResult{apiURL: api, err: err, duration: time.Since(reqStart)} - return - } - - resp, err := client.Do(req) - if err != nil { - GoLog("[Tidal] [Parallel] %s - Request failed: %v\n", api, err) - resultChan <- tidalAPIResult{apiURL: api, err: err, duration: time.Since(reqStart)} - return - } - defer resp.Body.Close() - - if resp.StatusCode != 200 { - GoLog("[Tidal] [Parallel] %s - HTTP %d\n", api, resp.StatusCode) - 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 { - GoLog("[Tidal] [Parallel] %s - Failed to read body: %v\n", api, err) - 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" { - GoLog("[Tidal] [Parallel] %s - Rejecting PREVIEW response\n", api) - resultChan <- tidalAPIResult{apiURL: api, err: fmt.Errorf("returned PREVIEW instead of FULL"), duration: time.Since(reqStart)} - return - } - - GoLog("[Tidal] [Parallel] %s - Got FULL track (v2): %d-bit/%dHz in %v\n", - api, v2Response.Data.BitDepth, v2Response.Data.SampleRate, time.Since(reqStart)) - - 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 != "" { - GoLog("[Tidal] [Parallel] %s - Got direct URL (v1) in %v\n", api, time.Since(reqStart)) - info := TidalDownloadInfo{ - URL: item.OriginalTrackURL, - BitDepth: 16, - SampleRate: 44100, - } - resultChan <- tidalAPIResult{apiURL: api, info: info, err: nil, duration: time.Since(reqStart)} - return - } - } - } - - GoLog("[Tidal] [Parallel] %s - No download URL in response\n", api) - 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 - successCount := 0 - failCount := 0 - - for i := 0; i < len(apis); i++ { - result := <-resultChan - if result.err == nil { - successCount++ - if successCount == 1 { - // First success - use this one - GoLog("[Tidal] [Parallel] ✓ Using response from %s (took %v, total %v)\n", - result.apiURL, result.duration, time.Since(startTime)) - - // Don't return immediately - let other goroutines finish to avoid leaks - // But we'll use this result - go func() { - // Drain remaining results - for j := i + 1; j < len(apis); j++ { - <-resultChan - } - }() - - return result.apiURL, result.info, nil - } - } else { - failCount++ - errMsg := result.err.Error() - if len(errMsg) > 50 { - errMsg = errMsg[:50] + "..." - } - errors = append(errors, fmt.Sprintf("%s: %s", result.apiURL, errMsg)) - GoLog("[Tidal] [Parallel] ✗ %s failed: %s (took %v)\n", result.apiURL, errMsg, result.duration) - } - } - - 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) -} +// Kept for potential future use - currently using sequential approach +// func _getDownloadURLParallel(apis []string, trackID int64, quality string) (string, TidalDownloadInfo, error) { +// ... implementation commented out ... +// } // getDownloadURLSequential requests download URL from APIs sequentially (fallback) // Returns the first successful result (supports both v1 and v2 API formats) @@ -1473,14 +1343,15 @@ func isLatinScript(s string) bool { } // isASCIIString checks if a string contains only ASCII characters -func isASCIIString(s string) bool { - for _, r := range s { - if r > 127 { - return false - } - } - return true -} +// Kept for potential future use +// func isASCIIString(s string) bool { +// for _, r := range s { +// if r > 127 { +// return false +// } +// } +// return true +// } // downloadFromTidal downloads a track using the request parameters func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) { diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index 6af08b3c..47381420 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -281,6 +281,221 @@ import Gobackend // Import Go framework GobackendSetLoggingEnabled(enabled) return nil + // Extension System methods + case "initExtensionSystem": + let args = call.arguments as! [String: Any] + let extensionsDir = args["extensions_dir"] as! String + let dataDir = args["data_dir"] as! String + GobackendInitExtensionSystem(extensionsDir, dataDir, &error) + if let error = error { throw error } + return nil + + case "loadExtensionsFromDir": + let args = call.arguments as! [String: Any] + let dirPath = args["dir_path"] as! String + let response = GobackendLoadExtensionsFromDir(dirPath, &error) + if let error = error { throw error } + return response + + case "loadExtensionFromPath": + let args = call.arguments as! [String: Any] + let filePath = args["file_path"] as! String + let response = GobackendLoadExtensionFromPath(filePath, &error) + if let error = error { throw error } + return response + + case "unloadExtension": + let args = call.arguments as! [String: Any] + let extensionId = args["extension_id"] as! String + GobackendUnloadExtensionByID(extensionId, &error) + if let error = error { throw error } + return nil + + case "getInstalledExtensions": + let response = GobackendGetInstalledExtensions(&error) + if let error = error { throw error } + return response + + case "setExtensionEnabled": + let args = call.arguments as! [String: Any] + let extensionId = args["extension_id"] as! String + let enabled = args["enabled"] as? Bool ?? false + GobackendSetExtensionEnabledByID(extensionId, enabled, &error) + if let error = error { throw error } + return nil + + case "setProviderPriority": + let args = call.arguments as! [String: Any] + let priorityJson = args["priority"] as! String + GobackendSetProviderPriorityJSON(priorityJson, &error) + if let error = error { throw error } + return nil + + case "getProviderPriority": + let response = GobackendGetProviderPriorityJSON(&error) + if let error = error { throw error } + return response + + case "setMetadataProviderPriority": + let args = call.arguments as! [String: Any] + let priorityJson = args["priority"] as! String + GobackendSetMetadataProviderPriorityJSON(priorityJson, &error) + if let error = error { throw error } + return nil + + case "getMetadataProviderPriority": + let response = GobackendGetMetadataProviderPriorityJSON(&error) + if let error = error { throw error } + return response + + case "getExtensionSettings": + let args = call.arguments as! [String: Any] + let extensionId = args["extension_id"] as! String + let response = GobackendGetExtensionSettingsJSON(extensionId, &error) + if let error = error { throw error } + return response + + case "setExtensionSettings": + let args = call.arguments as! [String: Any] + let extensionId = args["extension_id"] as! String + let settingsJson = args["settings"] as! String + GobackendSetExtensionSettingsJSON(extensionId, settingsJson, &error) + if let error = error { throw error } + return nil + + case "searchTracksWithExtensions": + let args = call.arguments as! [String: Any] + let query = args["query"] as! String + let limit = args["limit"] as? Int ?? 20 + let response = GobackendSearchTracksWithExtensionsJSON(query, Int(limit), &error) + if let error = error { throw error } + return response + + case "downloadWithExtensions": + let requestJson = call.arguments as! String + let response = GobackendDownloadWithExtensionsJSON(requestJson, &error) + if let error = error { throw error } + return response + + case "removeExtension": + let args = call.arguments as! [String: Any] + let extensionId = args["extension_id"] as! String + GobackendRemoveExtensionByID(extensionId, &error) + if let error = error { throw error } + return nil + + case "upgradeExtension": + let args = call.arguments as! [String: Any] + let filePath = args["file_path"] as! String + let response = GobackendUpgradeExtensionFromPath(filePath, &error) + if let error = error { throw error } + return response + + case "checkExtensionUpgrade": + let args = call.arguments as! [String: Any] + let filePath = args["file_path"] as! String + let response = GobackendCheckExtensionUpgradeFromPath(filePath, &error) + if let error = error { throw error } + return response + + case "cleanupExtensions": + GobackendCleanupExtensions() + return nil + + // Extension Auth API + case "getExtensionPendingAuth": + let args = call.arguments as! [String: Any] + let extensionId = args["extension_id"] as! String + let response = GobackendGetExtensionPendingAuthJSON(extensionId, &error) + if let error = error { throw error } + return response + + case "setExtensionAuthCode": + let args = call.arguments as! [String: Any] + let extensionId = args["extension_id"] as! String + let authCode = args["auth_code"] as! String + GobackendSetExtensionAuthCode(extensionId, authCode, &error) + if let error = error { throw error } + return nil + + case "setExtensionTokens": + let args = call.arguments as! [String: Any] + let extensionId = args["extension_id"] as! String + let accessToken = args["access_token"] as! String + let refreshToken = args["refresh_token"] as? String ?? "" + let expiresIn = args["expires_in"] as? Int ?? 0 + GobackendSetExtensionTokens(extensionId, accessToken, refreshToken, Int(expiresIn), &error) + if let error = error { throw error } + return nil + + case "clearExtensionPendingAuth": + let args = call.arguments as! [String: Any] + let extensionId = args["extension_id"] as! String + GobackendClearExtensionPendingAuth(extensionId) + return nil + + case "isExtensionAuthenticated": + let args = call.arguments as! [String: Any] + let extensionId = args["extension_id"] as! String + let response = GobackendIsExtensionAuthenticated(extensionId) + return response + + case "getAllPendingAuthRequests": + let response = GobackendGetAllPendingAuthRequestsJSON(&error) + if let error = error { throw error } + return response + + // Extension FFmpeg API + case "getPendingFFmpegCommand": + let args = call.arguments as! [String: Any] + let commandId = args["command_id"] as! String + let response = GobackendGetPendingFFmpegCommandJSON(commandId, &error) + if let error = error { throw error } + return response + + case "setFFmpegCommandResult": + let args = call.arguments as! [String: Any] + let commandId = args["command_id"] as! String + let success = args["success"] as? Bool ?? false + let output = args["output"] as? String ?? "" + let errorMsg = args["error"] as? String ?? "" + GobackendSetFFmpegCommandResult(commandId, success, output, errorMsg) + return nil + + case "getAllPendingFFmpegCommands": + let response = GobackendGetAllPendingFFmpegCommandsJSON(&error) + if let error = error { throw error } + return response + + // Extension Custom Search API + case "customSearchWithExtension": + let args = call.arguments as! [String: Any] + let extensionId = args["extension_id"] as! String + let query = args["query"] as! String + let optionsJson = args["options"] as? String ?? "" + let response = GobackendCustomSearchWithExtensionJSON(extensionId, query, optionsJson, &error) + if let error = error { throw error } + return response + + case "getSearchProviders": + let response = GobackendGetSearchProvidersJSON(&error) + if let error = error { throw error } + return response + + // Extension Post-Processing API + case "runPostProcessing": + let args = call.arguments as! [String: Any] + let filePath = args["file_path"] as! String + let metadataJson = args["metadata"] as? String ?? "" + let response = GobackendRunPostProcessingJSON(filePath, metadataJson, &error) + if let error = error { throw error } + return response + + case "getPostProcessingProviders": + let response = GobackendGetPostProcessingProvidersJSON(&error) + if let error = error { throw error } + return response + default: throw NSError( domain: "SpotiFLAC", diff --git a/lib/constants/app_info.dart b/lib/constants/app_info.dart index 0d51fd7d..4e8196f5 100644 --- a/lib/constants/app_info.dart +++ b/lib/constants/app_info.dart @@ -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 = '2.2.7'; - static const String buildNumber = '49'; + static const String version = '3.0.0-alpha.1'; + static const String buildNumber = '50'; static const String fullVersion = '$version+$buildNumber'; diff --git a/lib/models/settings.dart b/lib/models/settings.dart index efab2835..47cd1b02 100644 --- a/lib/models/settings.dart +++ b/lib/models/settings.dart @@ -24,6 +24,8 @@ class AppSettings { final bool useCustomSpotifyCredentials; // Whether to use custom credentials (if set) final String metadataSource; // spotify, deezer - source for search and metadata 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 const AppSettings({ this.defaultService = 'tidal', @@ -46,6 +48,8 @@ class AppSettings { this.useCustomSpotifyCredentials = true, // Default: use custom if set this.metadataSource = 'deezer', // Default: Deezer (no rate limit) this.enableLogging = false, // Default: disabled for performance + this.useExtensionProviders = true, // Default: use extensions when available + this.searchProvider, // Default: null (use Deezer/Spotify) }); AppSettings copyWith({ @@ -69,6 +73,8 @@ class AppSettings { bool? useCustomSpotifyCredentials, String? metadataSource, bool? enableLogging, + bool? useExtensionProviders, + String? searchProvider, }) { return AppSettings( defaultService: defaultService ?? this.defaultService, @@ -91,6 +97,8 @@ class AppSettings { useCustomSpotifyCredentials: useCustomSpotifyCredentials ?? this.useCustomSpotifyCredentials, metadataSource: metadataSource ?? this.metadataSource, enableLogging: enableLogging ?? this.enableLogging, + useExtensionProviders: useExtensionProviders ?? this.useExtensionProviders, + searchProvider: searchProvider ?? this.searchProvider, ); } diff --git a/lib/models/settings.g.dart b/lib/models/settings.g.dart index 1446f8a3..5e10ac99 100644 --- a/lib/models/settings.g.dart +++ b/lib/models/settings.g.dart @@ -28,6 +28,8 @@ AppSettings _$AppSettingsFromJson(Map json) => AppSettings( json['useCustomSpotifyCredentials'] as bool? ?? true, metadataSource: json['metadataSource'] as String? ?? 'deezer', enableLogging: json['enableLogging'] as bool? ?? false, + useExtensionProviders: json['useExtensionProviders'] as bool? ?? true, + searchProvider: json['searchProvider'] as String?, ); Map _$AppSettingsToJson(AppSettings instance) => @@ -52,4 +54,6 @@ Map _$AppSettingsToJson(AppSettings instance) => 'useCustomSpotifyCredentials': instance.useCustomSpotifyCredentials, 'metadataSource': instance.metadataSource, 'enableLogging': instance.enableLogging, + 'useExtensionProviders': instance.useExtensionProviders, + 'searchProvider': instance.searchProvider, }; diff --git a/lib/models/track.dart b/lib/models/track.dart index 7ae36ce7..080d045a 100644 --- a/lib/models/track.dart +++ b/lib/models/track.dart @@ -18,6 +18,7 @@ class Track { final String? releaseDate; final String? deezerId; final ServiceAvailability? availability; + final String? source; // Extension ID that provided this track (null for built-in sources) const Track({ required this.id, @@ -33,10 +34,14 @@ class Track { this.releaseDate, this.deezerId, this.availability, + this.source, }); factory Track.fromJson(Map json) => _$TrackFromJson(json); Map toJson() => _$TrackToJson(this); + + /// Check if this track is from an extension + bool get isFromExtension => source != null && source!.isNotEmpty; } @JsonSerializable() diff --git a/lib/models/track.g.dart b/lib/models/track.g.dart index ac78e26d..5a866a4e 100644 --- a/lib/models/track.g.dart +++ b/lib/models/track.g.dart @@ -24,6 +24,7 @@ Track _$TrackFromJson(Map json) => Track( : ServiceAvailability.fromJson( json['availability'] as Map, ), + source: json['source'] as String?, ); Map _$TrackToJson(Track instance) => { @@ -40,6 +41,7 @@ Map _$TrackToJson(Track instance) => { 'releaseDate': instance.releaseDate, 'deezerId': instance.deezerId, 'availability': instance.availability, + 'source': instance.source, }; ServiceAvailability _$ServiceAvailabilityFromJson(Map json) => diff --git a/lib/providers/download_queue_provider.dart b/lib/providers/download_queue_provider.dart index 5e4d87c5..73e127a9 100644 --- a/lib/providers/download_queue_provider.dart +++ b/lib/providers/download_queue_provider.dart @@ -9,6 +9,7 @@ import 'package:spotiflac_android/models/download_item.dart'; import 'package:spotiflac_android/models/settings.dart'; import 'package:spotiflac_android/models/track.dart'; import 'package:spotiflac_android/providers/settings_provider.dart'; +import 'package:spotiflac_android/providers/extension_provider.dart'; import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/services/ffmpeg_service.dart'; import 'package:spotiflac_android/services/notification_service.dart'; @@ -831,6 +832,56 @@ class DownloadQueueNotifier extends Notifier { _saveQueueToStorage(); // Persist queue } + /// Run post-processing hooks on a downloaded file + Future _runPostProcessingHooks(String filePath, Track track) async { + try { + final settings = ref.read(settingsProvider); + final extensionState = ref.read(extensionProvider); + + // Check if post-processing is enabled and there are extensions with hooks + if (!settings.useExtensionProviders) return; + + final hasPostProcessing = extensionState.extensions.any( + (e) => e.enabled && e.hasPostProcessing, + ); + if (!hasPostProcessing) return; + + _log.d('Running post-processing hooks on: $filePath'); + + // Build metadata map for post-processing + final metadata = { + 'title': track.name, + 'artist': track.artistName, + 'album': track.albumName, + 'album_artist': track.albumArtist ?? track.artistName, + 'track_number': track.trackNumber ?? 1, + 'disc_number': track.discNumber ?? 1, + 'isrc': track.isrc ?? '', + 'release_date': track.releaseDate ?? '', + 'duration_ms': track.duration * 1000, + 'cover_url': track.coverUrl ?? '', + }; + + final result = await PlatformBridge.runPostProcessing(filePath, metadata: metadata); + + if (result['success'] == true) { + final hooksRun = result['hooks_run'] as int? ?? 0; + final newPath = result['file_path'] as String?; + _log.i('Post-processing completed: $hooksRun hook(s) executed'); + + if (newPath != null && newPath != filePath) { + _log.d('File path changed by post-processing: $newPath'); + } + } else { + final error = result['error'] as String? ?? 'Unknown error'; + _log.w('Post-processing failed: $error'); + } + } catch (e) { + _log.w('Post-processing error: $e'); + // Don't fail the download if post-processing fails + } + } + /// Embed metadata and cover to a FLAC file after M4A conversion Future _embedMetadataAndCover(String flacPath, Track track) async { // Download cover first @@ -1282,7 +1333,37 @@ class DownloadQueueNotifier extends Notifier { Map result; - if (state.autoFallback) { + // Check if extension providers should be used + final extensionState = ref.read(extensionProvider); + final hasActiveExtensions = extensionState.extensions.any((e) => e.enabled); + final useExtensions = settings.useExtensionProviders && hasActiveExtensions; + + if (useExtensions) { + // Use extension providers (includes fallback to built-in services) + _log.d('Using extension providers for download'); + _log.d( + 'Quality: $quality${item.qualityOverride != null ? ' (override)' : ''}', + ); + _log.d('Output dir: $outputDir'); + result = await PlatformBridge.downloadWithExtensions( + isrc: trackToDownload.isrc ?? '', + spotifyId: trackToDownload.id, + trackName: trackToDownload.name, + artistName: trackToDownload.artistName, + albumName: trackToDownload.albumName, + albumArtist: trackToDownload.albumArtist, + coverUrl: trackToDownload.coverUrl, + outputDir: outputDir, + filenameFormat: state.filenameFormat, + quality: quality, + trackNumber: trackToDownload.trackNumber ?? 1, + discNumber: trackToDownload.discNumber ?? 1, + releaseDate: trackToDownload.releaseDate, + itemId: item.id, + durationMs: trackToDownload.duration, + source: trackToDownload.source, // Pass extension ID that provided this track + ); + } else if (state.autoFallback) { _log.d('Using auto-fallback mode'); _log.d( 'Quality: $quality${item.qualityOverride != null ? ' (override)' : ''}', @@ -1502,6 +1583,11 @@ class DownloadQueueNotifier extends Notifier { filePath: filePath, ); + // Run post-processing hooks if enabled + if (filePath != null) { + await _runPostProcessingHooks(filePath, trackToDownload); + } + // Increment completed counter _completedInSession++; diff --git a/lib/providers/extension_provider.dart b/lib/providers/extension_provider.dart new file mode 100644 index 00000000..c3b150a4 --- /dev/null +++ b/lib/providers/extension_provider.dart @@ -0,0 +1,655 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:spotiflac_android/services/platform_bridge.dart'; +import 'package:spotiflac_android/utils/logger.dart'; + +final _log = AppLogger('ExtensionProvider'); + +/// Represents an installed extension +class Extension { + final String id; + final String name; + final String displayName; + final String version; + final String author; + final String description; + final bool enabled; + final String status; // 'loaded', 'error', 'disabled' + final String? errorMessage; + final String? iconPath; // Path to extension icon + final List permissions; + final List settings; + final List qualityOptions; // Custom quality options for download providers + final bool hasMetadataProvider; + final bool hasDownloadProvider; + final bool skipMetadataEnrichment; // If true, use metadata from extension instead of enriching + final SearchBehavior? searchBehavior; // Custom search behavior + final TrackMatching? trackMatching; // Custom track matching + final PostProcessing? postProcessing; // Post-processing hooks + + const Extension({ + required this.id, + required this.name, + required this.displayName, + required this.version, + required this.author, + required this.description, + required this.enabled, + required this.status, + this.errorMessage, + this.iconPath, + this.permissions = const [], + this.settings = const [], + this.qualityOptions = const [], + this.hasMetadataProvider = false, + this.hasDownloadProvider = false, + this.skipMetadataEnrichment = false, + this.searchBehavior, + this.trackMatching, + this.postProcessing, + }); + + factory Extension.fromJson(Map json) { + return Extension( + 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? ?? '', + enabled: json['enabled'] as bool? ?? false, + status: json['status'] as String? ?? 'loaded', + errorMessage: json['error_message'] as String?, + iconPath: json['icon_path'] as String?, + permissions: (json['permissions'] as List?)?.cast() ?? [], + settings: (json['settings'] as List?) + ?.map((s) => ExtensionSetting.fromJson(s as Map)) + .toList() ?? [], + qualityOptions: (json['quality_options'] as List?) + ?.map((q) => QualityOption.fromJson(q as Map)) + .toList() ?? [], + hasMetadataProvider: json['has_metadata_provider'] as bool? ?? false, + hasDownloadProvider: json['has_download_provider'] as bool? ?? false, + skipMetadataEnrichment: json['skip_metadata_enrichment'] as bool? ?? false, + searchBehavior: json['search_behavior'] != null + ? SearchBehavior.fromJson(json['search_behavior'] as Map) + : null, + trackMatching: json['track_matching'] != null + ? TrackMatching.fromJson(json['track_matching'] as Map) + : null, + postProcessing: json['post_processing'] != null + ? PostProcessing.fromJson(json['post_processing'] as Map) + : null, + ); + } + + Extension copyWith({ + String? id, + String? name, + String? displayName, + String? version, + String? author, + String? description, + bool? enabled, + String? status, + String? errorMessage, + String? iconPath, + List? permissions, + List? settings, + List? qualityOptions, + bool? hasMetadataProvider, + bool? hasDownloadProvider, + bool? skipMetadataEnrichment, + SearchBehavior? searchBehavior, + TrackMatching? trackMatching, + PostProcessing? postProcessing, + }) { + return Extension( + id: id ?? this.id, + name: name ?? this.name, + displayName: displayName ?? this.displayName, + version: version ?? this.version, + author: author ?? this.author, + description: description ?? this.description, + enabled: enabled ?? this.enabled, + status: status ?? this.status, + errorMessage: errorMessage ?? this.errorMessage, + iconPath: iconPath ?? this.iconPath, + permissions: permissions ?? this.permissions, + settings: settings ?? this.settings, + qualityOptions: qualityOptions ?? this.qualityOptions, + hasMetadataProvider: hasMetadataProvider ?? this.hasMetadataProvider, + hasDownloadProvider: hasDownloadProvider ?? this.hasDownloadProvider, + skipMetadataEnrichment: skipMetadataEnrichment ?? this.skipMetadataEnrichment, + searchBehavior: searchBehavior ?? this.searchBehavior, + trackMatching: trackMatching ?? this.trackMatching, + postProcessing: postProcessing ?? this.postProcessing, + ); + } + + bool get hasCustomSearch => searchBehavior?.enabled ?? false; + bool get hasCustomMatching => trackMatching?.customMatching ?? false; + bool get hasPostProcessing => postProcessing?.enabled ?? false; +} + +/// Custom search behavior configuration +class SearchBehavior { + final bool enabled; + final String? placeholder; + final bool primary; + final String? icon; + final String? thumbnailRatio; // "square" (1:1), "wide" (16:9), "portrait" (2:3) + final int? thumbnailWidth; + final int? thumbnailHeight; + + const SearchBehavior({ + required this.enabled, + this.placeholder, + this.primary = false, + this.icon, + this.thumbnailRatio, + this.thumbnailWidth, + this.thumbnailHeight, + }); + + factory SearchBehavior.fromJson(Map json) { + return SearchBehavior( + enabled: json['enabled'] as bool? ?? false, + placeholder: json['placeholder'] as String?, + primary: json['primary'] as bool? ?? false, + icon: json['icon'] as String?, + thumbnailRatio: json['thumbnailRatio'] as String?, + thumbnailWidth: json['thumbnailWidth'] as int?, + thumbnailHeight: json['thumbnailHeight'] as int?, + ); + } + + /// Get thumbnail size based on configuration + /// Returns (width, height) tuple + (double, double) getThumbnailSize({double defaultSize = 56}) { + // If custom dimensions specified, use them + if (thumbnailWidth != null && thumbnailHeight != null) { + return (thumbnailWidth!.toDouble(), thumbnailHeight!.toDouble()); + } + + // Otherwise use ratio presets + switch (thumbnailRatio) { + case 'wide': // 16:9 - YouTube style + return (defaultSize * 16 / 9, defaultSize); + case 'portrait': // 2:3 - Poster style + return (defaultSize * 2 / 3, defaultSize); + case 'square': // 1:1 - Album art style + default: + return (defaultSize, defaultSize); + } + } +} + +/// Custom track matching configuration +class TrackMatching { + final bool customMatching; + final String? strategy; // "isrc", "name", "duration", "custom" + final int durationTolerance; // in seconds + + const TrackMatching({ + required this.customMatching, + this.strategy, + this.durationTolerance = 3, + }); + + factory TrackMatching.fromJson(Map json) { + return TrackMatching( + customMatching: json['customMatching'] as bool? ?? false, + strategy: json['strategy'] as String?, + durationTolerance: json['durationTolerance'] as int? ?? 3, + ); + } +} + +/// Post-processing configuration +class PostProcessing { + final bool enabled; + final List hooks; + + const PostProcessing({ + required this.enabled, + this.hooks = const [], + }); + + factory PostProcessing.fromJson(Map json) { + return PostProcessing( + enabled: json['enabled'] as bool? ?? false, + hooks: (json['hooks'] as List?) + ?.map((h) => PostProcessingHook.fromJson(h as Map)) + .toList() ?? [], + ); + } +} + +/// A post-processing hook +class PostProcessingHook { + final String id; + final String name; + final String? description; + final bool defaultEnabled; + final List supportedFormats; + + const PostProcessingHook({ + required this.id, + required this.name, + this.description, + this.defaultEnabled = false, + this.supportedFormats = const [], + }); + + factory PostProcessingHook.fromJson(Map json) { + return PostProcessingHook( + id: json['id'] as String? ?? '', + name: json['name'] as String? ?? '', + description: json['description'] as String?, + defaultEnabled: json['defaultEnabled'] as bool? ?? false, + supportedFormats: (json['supportedFormats'] as List?)?.cast() ?? [], + ); + } +} + +/// Represents a quality option for download providers +class QualityOption { + final String id; + final String label; + final String? description; + final List settings; // Quality-specific settings + + const QualityOption({ + required this.id, + required this.label, + this.description, + this.settings = const [], + }); + + factory QualityOption.fromJson(Map json) { + return QualityOption( + id: json['id'] as String? ?? '', + label: json['label'] as String? ?? '', + description: json['description'] as String?, + settings: (json['settings'] as List?) + ?.map((s) => QualitySpecificSetting.fromJson(s as Map)) + .toList() ?? [], + ); + } +} + +/// Represents a setting that's specific to a quality option +class QualitySpecificSetting { + final String key; + final String label; + final String type; // 'string', 'number', 'boolean', 'select' + final dynamic defaultValue; + final String? description; + final List? options; // For select type + final bool required; + final bool secret; + + const QualitySpecificSetting({ + required this.key, + required this.label, + required this.type, + this.defaultValue, + this.description, + this.options, + this.required = false, + this.secret = false, + }); + + factory QualitySpecificSetting.fromJson(Map json) { + return QualitySpecificSetting( + key: json['key'] as String? ?? '', + label: json['label'] as String? ?? '', + type: json['type'] as String? ?? 'string', + defaultValue: json['default'], + description: json['description'] as String?, + options: (json['options'] as List?)?.cast(), + required: json['required'] as bool? ?? false, + secret: json['secret'] as bool? ?? false, + ); + } +} + +/// Represents a setting field for an extension +class ExtensionSetting { + final String key; + final String label; + final String type; // 'string', 'number', 'boolean', 'select' + final dynamic defaultValue; + final String? description; + final List? options; // For select type + final bool required; + + const ExtensionSetting({ + required this.key, + required this.label, + required this.type, + this.defaultValue, + this.description, + this.options, + this.required = false, + }); + + factory ExtensionSetting.fromJson(Map json) { + return ExtensionSetting( + key: json['key'] as String? ?? '', + label: json['label'] as String? ?? '', + type: json['type'] as String? ?? 'string', + defaultValue: json['default'], + description: json['description'] as String?, + options: (json['options'] as List?)?.cast(), + required: json['required'] as bool? ?? false, + ); + } +} + +/// State for extension management +class ExtensionState { + final List extensions; + final List providerPriority; + final List metadataProviderPriority; + final bool isLoading; + final String? error; + final bool isInitialized; + + const ExtensionState({ + this.extensions = const [], + this.providerPriority = const [], + this.metadataProviderPriority = const [], + this.isLoading = false, + this.error, + this.isInitialized = false, + }); + + ExtensionState copyWith({ + List? extensions, + List? providerPriority, + List? metadataProviderPriority, + bool? isLoading, + String? error, + bool? isInitialized, + }) { + return ExtensionState( + extensions: extensions ?? this.extensions, + providerPriority: providerPriority ?? this.providerPriority, + metadataProviderPriority: metadataProviderPriority ?? this.metadataProviderPriority, + isLoading: isLoading ?? this.isLoading, + error: error, + isInitialized: isInitialized ?? this.isInitialized, + ); + } +} + + +/// Provider for managing extensions +class ExtensionNotifier extends Notifier { + @override + ExtensionState build() { + return const ExtensionState(); + } + + /// Initialize the extension system + Future initialize(String extensionsDir, String dataDir) async { + if (state.isInitialized) return; + + state = state.copyWith(isLoading: true, error: null); + + try { + await PlatformBridge.initExtensionSystem(extensionsDir, dataDir); + await loadExtensions(extensionsDir); + await loadProviderPriority(); + await loadMetadataProviderPriority(); + state = state.copyWith(isInitialized: true, isLoading: false); + _log.i('Extension system initialized'); + } catch (e) { + _log.e('Failed to initialize extension system: $e'); + state = state.copyWith(isLoading: false, error: e.toString()); + } + } + + /// Load all extensions from directory + Future loadExtensions(String dirPath) async { + state = state.copyWith(isLoading: true, error: null); + + try { + final result = await PlatformBridge.loadExtensionsFromDir(dirPath); + _log.d('Load extensions result: $result'); + await refreshExtensions(); + state = state.copyWith(isLoading: false); + } catch (e) { + _log.e('Failed to load extensions: $e'); + state = state.copyWith(isLoading: false, error: e.toString()); + } + } + + /// Refresh the list of installed extensions + Future refreshExtensions() async { + try { + final list = await PlatformBridge.getInstalledExtensions(); + final extensions = list.map((e) => Extension.fromJson(e)).toList(); + state = state.copyWith(extensions: extensions); + _log.d('Loaded ${extensions.length} extensions'); + + // Log search behavior for extensions that have it + for (final ext in extensions) { + if (ext.searchBehavior != null) { + _log.d('Extension ${ext.id}: thumbnailRatio=${ext.searchBehavior!.thumbnailRatio}'); + } + } + } catch (e) { + _log.e('Failed to refresh extensions: $e'); + state = state.copyWith(error: e.toString()); + } + } + + /// Clear any error state + void clearError() { + state = state.copyWith(error: null); + } + + /// Install extension from file (auto-upgrades if already installed with newer version) + Future installExtension(String filePath) async { + state = state.copyWith(isLoading: true, error: null); + + try { + final result = await PlatformBridge.loadExtensionFromPath(filePath); + _log.i('Installed extension: ${result['name']}'); + await refreshExtensions(); + state = state.copyWith(isLoading: false); + return true; + } catch (e) { + _log.e('Failed to install extension: $e'); + state = state.copyWith(isLoading: false, error: e.toString()); + return false; + } + } + + /// Check if a package file is an upgrade for an existing extension + /// Returns: {extension_id, current_version, new_version, can_upgrade, is_installed} + Future> checkExtensionUpgrade(String filePath) async { + try { + return await PlatformBridge.checkExtensionUpgrade(filePath); + } catch (e) { + _log.e('Failed to check extension upgrade: $e'); + return {'error': e.toString()}; + } + } + + /// Upgrade an existing extension from a new package file + Future upgradeExtension(String filePath) async { + state = state.copyWith(isLoading: true, error: null); + + try { + final result = await PlatformBridge.upgradeExtension(filePath); + _log.i('Upgraded extension: ${result['display_name']} to v${result['version']}'); + await refreshExtensions(); + state = state.copyWith(isLoading: false); + return true; + } catch (e) { + _log.e('Failed to upgrade extension: $e'); + state = state.copyWith(isLoading: false, error: e.toString()); + return false; + } + } + + /// Uninstall/remove an extension + Future removeExtension(String extensionId) async { + state = state.copyWith(isLoading: true, error: null); + + try { + await PlatformBridge.removeExtension(extensionId); + _log.i('Removed extension: $extensionId'); + await refreshExtensions(); + state = state.copyWith(isLoading: false); + return true; + } catch (e) { + _log.e('Failed to remove extension: $e'); + state = state.copyWith(isLoading: false, error: e.toString()); + return false; + } + } + + /// Enable or disable an extension + Future setExtensionEnabled(String extensionId, bool enabled) async { + try { + await PlatformBridge.setExtensionEnabled(extensionId, enabled); + _log.d('Set extension $extensionId enabled: $enabled'); + + // Update local state + final extensions = state.extensions.map((ext) { + if (ext.id == extensionId) { + return ext.copyWith(enabled: enabled); + } + return ext; + }).toList(); + + state = state.copyWith(extensions: extensions); + } catch (e) { + _log.e('Failed to set extension enabled: $e'); + state = state.copyWith(error: e.toString()); + } + } + + /// Get settings for an extension + Future> getExtensionSettings(String extensionId) async { + try { + return await PlatformBridge.getExtensionSettings(extensionId); + } catch (e) { + _log.e('Failed to get extension settings: $e'); + return {}; + } + } + + /// Update settings for an extension + Future setExtensionSettings(String extensionId, Map settings) async { + try { + await PlatformBridge.setExtensionSettings(extensionId, settings); + _log.d('Updated settings for extension: $extensionId'); + } catch (e) { + _log.e('Failed to set extension settings: $e'); + state = state.copyWith(error: e.toString()); + } + } + + /// Load provider priority order + Future loadProviderPriority() async { + try { + final priority = await PlatformBridge.getProviderPriority(); + state = state.copyWith(providerPriority: priority); + } catch (e) { + _log.e('Failed to load provider priority: $e'); + } + } + + /// Set provider priority order + Future setProviderPriority(List priority) async { + try { + await PlatformBridge.setProviderPriority(priority); + state = state.copyWith(providerPriority: priority); + _log.d('Updated provider priority: $priority'); + } catch (e) { + _log.e('Failed to set provider priority: $e'); + state = state.copyWith(error: e.toString()); + } + } + + /// Load metadata provider priority order + Future loadMetadataProviderPriority() async { + try { + final priority = await PlatformBridge.getMetadataProviderPriority(); + state = state.copyWith(metadataProviderPriority: priority); + } catch (e) { + _log.e('Failed to load metadata provider priority: $e'); + } + } + + /// Set metadata provider priority order + Future setMetadataProviderPriority(List priority) async { + try { + await PlatformBridge.setMetadataProviderPriority(priority); + state = state.copyWith(metadataProviderPriority: priority); + _log.d('Updated metadata provider priority: $priority'); + } catch (e) { + _log.e('Failed to set metadata provider priority: $e'); + state = state.copyWith(error: e.toString()); + } + } + + /// Cleanup all extensions (call on app close) + Future cleanup() async { + try { + await PlatformBridge.cleanupExtensions(); + _log.d('Extensions cleaned up'); + } catch (e) { + _log.e('Failed to cleanup extensions: $e'); + } + } + + /// Get extension by ID + Extension? getExtension(String extensionId) { + try { + return state.extensions.firstWhere((ext) => ext.id == extensionId); + } catch (_) { + return null; + } + } + + /// Get all enabled extensions + List get enabledExtensions { + return state.extensions.where((ext) => ext.enabled).toList(); + } + + /// Get all download providers (built-in + extensions) + List getAllDownloadProviders() { + final providers = ['tidal', 'qobuz', 'amazon']; + for (final ext in state.extensions) { + if (ext.enabled && ext.hasDownloadProvider) { + providers.add(ext.id); + } + } + return providers; + } + + /// Get all metadata providers (built-in + extensions) + List getAllMetadataProviders() { + final providers = ['deezer', 'spotify']; + for (final ext in state.extensions) { + if (ext.enabled && ext.hasMetadataProvider) { + providers.add(ext.id); + } + } + return providers; + } + /// Get all extensions that provide custom search + List get searchProviders { + return state.extensions.where((ext) => ext.enabled && ext.hasCustomSearch).toList(); + } +} + +final extensionProvider = NotifierProvider( + ExtensionNotifier.new, +); diff --git a/lib/providers/settings_provider.dart b/lib/providers/settings_provider.dart index 2020adc0..1d7fbf0a 100644 --- a/lib/providers/settings_provider.dart +++ b/lib/providers/settings_provider.dart @@ -192,12 +192,22 @@ class SettingsNotifier extends Notifier { _saveSettings(); } + void setSearchProvider(String? provider) { + state = state.copyWith(searchProvider: provider); + _saveSettings(); + } + void setEnableLogging(bool enabled) { state = state.copyWith(enableLogging: enabled); _saveSettings(); // Sync logging state to LogBuffer LogBuffer.loggingEnabled = enabled; } + + void setUseExtensionProviders(bool enabled) { + state = state.copyWith(useExtensionProviders: enabled); + _saveSettings(); + } } final settingsProvider = NotifierProvider( diff --git a/lib/providers/track_provider.dart b/lib/providers/track_provider.dart index ef0ca6b5..9ed80280 100644 --- a/lib/providers/track_provider.dart +++ b/lib/providers/track_provider.dart @@ -2,6 +2,8 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:spotiflac_android/models/track.dart'; import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/utils/logger.dart'; +import 'package:spotiflac_android/providers/settings_provider.dart'; +import 'package:spotiflac_android/providers/extension_provider.dart'; final _log = AppLogger('TrackProvider'); @@ -18,6 +20,7 @@ class TrackState { final List? artistAlbums; // For artist page final List? searchArtists; // For search results final bool hasSearchText; // For back button handling + final String? searchExtensionId; // Extension ID used for current search results const TrackState({ this.tracks = const [], @@ -32,6 +35,7 @@ class TrackState { this.artistAlbums, this.searchArtists, this.hasSearchText = false, + this.searchExtensionId, }); bool get hasContent => tracks.isNotEmpty || artistAlbums != null || (searchArtists != null && searchArtists!.isNotEmpty); @@ -49,6 +53,7 @@ class TrackState { List? artistAlbums, List? searchArtists, bool? hasSearchText, + String? searchExtensionId, }) { return TrackState( tracks: tracks ?? this.tracks, @@ -63,6 +68,7 @@ class TrackState { artistAlbums: artistAlbums ?? this.artistAlbums, searchArtists: searchArtists ?? this.searchArtists, hasSearchText: hasSearchText ?? this.hasSearchText, + searchExtensionId: searchExtensionId, ); } } @@ -210,12 +216,43 @@ class TrackNotifier extends Notifier { state = TrackState(isLoading: true, hasSearchText: state.hasSearchText); try { + // Check if extension providers should be used for search + final settings = ref.read(settingsProvider); + final extensionState = ref.read(extensionProvider); + final hasActiveMetadataExtensions = extensionState.extensions.any( + (e) => e.enabled && e.hasMetadataProvider, + ); + final useExtensions = settings.useExtensionProviders && hasActiveMetadataExtensions; + // Use Deezer or Spotify based on settings final source = metadataSource ?? 'deezer'; - _log.i('Search started: source=$source, query="$query"'); + _log.i('Search started: source=$source, query="$query", useExtensions=$useExtensions'); Map results; + List extensionTracks = []; + + // Try extension providers first if enabled + if (useExtensions) { + try { + _log.d('Calling extension search API...'); + final extResults = await PlatformBridge.searchTracksWithExtensions(query, limit: 20); + _log.i('Extensions returned ${extResults.length} tracks'); + + // Parse extension results + for (final t in extResults) { + try { + extensionTracks.add(_parseSearchTrack(t)); + } catch (e) { + _log.e('Failed to parse extension track: $e', e); + } + } + } catch (e) { + _log.w('Extension search failed, falling back to built-in: $e'); + } + } + + // Also search with built-in providers if (source == 'deezer') { _log.d('Calling Deezer search API...'); results = await PlatformBridge.searchDeezerAll(query, trackLimit: 20, artistLimit: 5); @@ -238,11 +275,26 @@ class TrackNotifier extends Notifier { // Parse tracks with error handling per item final tracks = []; + + // Add extension tracks first (they have priority) + tracks.addAll(extensionTracks); + + // Add built-in provider tracks, avoiding duplicates by ISRC + final existingIsrcs = extensionTracks + .where((t) => t.isrc != null && t.isrc!.isNotEmpty) + .map((t) => t.isrc!) + .toSet(); + for (int i = 0; i < trackList.length; i++) { final t = trackList[i]; try { if (t is Map) { - tracks.add(_parseSearchTrack(t)); + final track = _parseSearchTrack(t); + // Skip if we already have this track from extensions + if (track.isrc != null && existingIsrcs.contains(track.isrc)) { + continue; + } + tracks.add(track); } else { _log.w('Track[$i] is not a Map: ${t.runtimeType}'); } @@ -266,7 +318,7 @@ class TrackNotifier extends Notifier { } } - _log.i('Search complete: ${tracks.length} tracks, ${artists.length} artists parsed successfully'); + _log.i('Search complete: ${tracks.length} tracks (${extensionTracks.length} from extensions), ${artists.length} artists parsed successfully'); state = TrackState( tracks: tracks, @@ -281,6 +333,53 @@ class TrackNotifier extends Notifier { } } + /// Perform custom search using a specific extension + Future customSearch(String extensionId, String query, {Map? options}) async { + // Increment request ID to cancel any pending requests + final requestId = ++_currentRequestId; + + // Preserve hasSearchText during search + state = TrackState(isLoading: true, hasSearchText: state.hasSearchText); + + try { + _log.i('Custom search started: extension=$extensionId, query="$query"'); + + final results = await PlatformBridge.customSearchWithExtension(extensionId, query, options: options); + + if (!_isRequestValid(requestId)) { + _log.w('Custom search request cancelled (requestId=$requestId)'); + return; + } + + _log.i('Custom search returned ${results.length} tracks'); + + // Parse tracks with error handling per item, setting source to extension ID + final tracks = []; + for (int i = 0; i < results.length; i++) { + final t = results[i]; + try { + tracks.add(_parseSearchTrack(t, source: extensionId)); + } catch (e) { + _log.e('Failed to parse custom search track[$i]: $e', e); + } + } + + _log.i('Custom search complete: ${tracks.length} tracks parsed (source=$extensionId)'); + + state = TrackState( + tracks: tracks, + searchArtists: [], // Custom search doesn't return artists + isLoading: false, + hasSearchText: state.hasSearchText, + searchExtensionId: extensionId, // Store which extension was used + ); + } catch (e, stackTrace) { + if (!_isRequestValid(requestId)) return; + _log.e('Custom search failed: $e', e, stackTrace); + state = TrackState(isLoading: false, error: e.toString(), hasSearchText: state.hasSearchText); + } + } + Future checkAvailability(int index) async { if (index < 0 || index >= state.tracks.length) return; @@ -344,7 +443,7 @@ class TrackNotifier extends Notifier { ); } - Track _parseSearchTrack(Map data) { + Track _parseSearchTrack(Map data, {String? source}) { // Handle duration_ms which might be int or double int durationMs = 0; final durationValue = data['duration_ms']; @@ -366,6 +465,7 @@ class TrackNotifier extends Notifier { trackNumber: data['track_number'] as int?, discNumber: data['disc_number'] as int?, releaseDate: data['release_date']?.toString(), + source: source ?? data['source']?.toString() ?? data['provider_id']?.toString(), ); } diff --git a/lib/screens/album_screen.dart b/lib/screens/album_screen.dart index 87fc3fca..64eb8fa6 100644 --- a/lib/screens/album_screen.dart +++ b/lib/screens/album_screen.dart @@ -7,6 +7,7 @@ import 'package:spotiflac_android/models/download_item.dart'; import 'package:spotiflac_android/providers/download_queue_provider.dart'; import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/services/platform_bridge.dart'; +import 'package:spotiflac_android/widgets/download_service_picker.dart'; /// Simple in-memory cache for album tracks class _AlbumCache { @@ -316,10 +317,16 @@ class _AlbumScreenState extends ConsumerState { void _downloadTrack(BuildContext context, Track track) { final settings = ref.read(settingsProvider); if (settings.askQualityBeforeDownload) { - _showQualityPicker(context, (quality, service) { - ref.read(downloadQueueProvider.notifier).addToQueue(track, service, qualityOverride: quality); - ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added "${track.name}" to queue'))); - }, trackName: track.name, artistName: track.artistName, coverUrl: track.coverUrl); + DownloadServicePicker.show( + context, + trackName: track.name, + artistName: track.artistName, + coverUrl: track.coverUrl, + onSelect: (quality, service) { + ref.read(downloadQueueProvider.notifier).addToQueue(track, service, qualityOverride: quality); + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added "${track.name}" to queue'))); + }, + ); } else { ref.read(downloadQueueProvider.notifier).addToQueue(track, settings.defaultService); ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added "${track.name}" to queue'))); @@ -331,84 +338,21 @@ class _AlbumScreenState extends ConsumerState { if (tracks == null || tracks.isEmpty) return; final settings = ref.read(settingsProvider); if (settings.askQualityBeforeDownload) { - _showQualityPicker(context, (quality, service) { - ref.read(downloadQueueProvider.notifier).addMultipleToQueue(tracks, service, qualityOverride: quality); - ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added ${tracks.length} tracks to queue'))); - }, trackName: '${tracks.length} tracks', artistName: widget.albumName); + DownloadServicePicker.show( + context, + trackName: '${tracks.length} tracks', + artistName: widget.albumName, + onSelect: (quality, service) { + ref.read(downloadQueueProvider.notifier).addMultipleToQueue(tracks, service, qualityOverride: quality); + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added ${tracks.length} tracks to queue'))); + }, + ); } else { ref.read(downloadQueueProvider.notifier).addMultipleToQueue(tracks, settings.defaultService); ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added ${tracks.length} tracks to queue'))); } } - void _showQualityPicker(BuildContext context, void Function(String quality, String service) onSelect, {String? trackName, String? artistName, String? coverUrl}) { - final colorScheme = Theme.of(context).colorScheme; - final settings = ref.read(settingsProvider); - String selectedService = settings.defaultService; - - showModalBottomSheet( - context: context, - backgroundColor: colorScheme.surfaceContainerHigh, - shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(28))), - isScrollControlled: true, - builder: (context) => StatefulBuilder( - builder: (context, setModalState) => SafeArea( - child: SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (trackName != null) ...[ - _TrackInfoHeader(trackName: trackName, artistName: artistName, coverUrl: coverUrl), - Divider(height: 1, color: colorScheme.outlineVariant.withValues(alpha: 0.5)), - ] else ...[ - const SizedBox(height: 8), - Center(child: Container(width: 40, height: 4, decoration: BoxDecoration(color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4), borderRadius: BorderRadius.circular(2)))), - ], - // Service selector - Padding( - padding: const EdgeInsets.fromLTRB(24, 16, 24, 8), - child: Text('Download From', style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold)), - ), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 20), - child: Row( - children: [ - _ServiceChip(label: 'Tidal', isSelected: selectedService == 'tidal', onTap: () => setModalState(() => selectedService = 'tidal')), - const SizedBox(width: 8), - _ServiceChip(label: 'Qobuz', isSelected: selectedService == 'qobuz', onTap: () => setModalState(() => selectedService = 'qobuz')), - const SizedBox(width: 8), - _ServiceChip(label: 'Amazon', isSelected: selectedService == 'amazon', onTap: () => setModalState(() => selectedService = 'amazon')), - ], - ), - ), - Padding( - padding: const EdgeInsets.fromLTRB(24, 16, 24, 8), - child: Text('Select Quality', style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold)), - ), - // Disclaimer - Padding( - padding: const EdgeInsets.fromLTRB(24, 0, 24, 12), - child: Text( - 'Actual quality depends on track availability. Hi-Res may not be available for all tracks.', - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: colorScheme.onSurfaceVariant, - fontStyle: FontStyle.italic, - ), - ), - ), - _QualityOption(title: 'FLAC Lossless', subtitle: '16-bit / 44.1kHz', icon: Icons.music_note, onTap: () { Navigator.pop(context); onSelect('LOSSLESS', selectedService); }), - _QualityOption(title: 'Hi-Res FLAC', subtitle: '24-bit / up to 96kHz', icon: Icons.high_quality, onTap: () { Navigator.pop(context); onSelect('HI_RES', selectedService); }), - _QualityOption(title: 'Hi-Res FLAC Max', subtitle: '24-bit / up to 192kHz', icon: Icons.four_k, onTap: () { Navigator.pop(context); onSelect('HI_RES_LOSSLESS', selectedService); }), - const SizedBox(height: 16), - ], - ), - ), - ), - ), - ); - } - /// Build error widget with special handling for rate limit (429) Widget _buildErrorWidget(String error, ColorScheme colorScheme) { final isRateLimit = error.contains('429') || @@ -473,148 +417,6 @@ class _AlbumScreenState extends ConsumerState { } } -class _QualityOption extends StatelessWidget { - final String title; - final String subtitle; - final IconData icon; - final VoidCallback onTap; - - const _QualityOption({required this.title, required this.subtitle, required this.icon, required this.onTap}); - - @override - Widget build(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; - return ListTile( - contentPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 4), - leading: Container(padding: const EdgeInsets.all(10), decoration: BoxDecoration(color: colorScheme.primaryContainer, borderRadius: BorderRadius.circular(12)), child: Icon(icon, color: colorScheme.onPrimaryContainer, size: 20)), - title: Text(title, style: const TextStyle(fontWeight: FontWeight.w500)), - subtitle: Text(subtitle, style: TextStyle(color: colorScheme.onSurfaceVariant)), - onTap: onTap, - ); - } -} - -class _ServiceChip extends StatelessWidget { - final String label; - final bool isSelected; - final VoidCallback onTap; - const _ServiceChip({required this.label, required this.isSelected, required this.onTap}); - - @override - Widget build(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; - return Expanded( - child: GestureDetector( - onTap: onTap, - child: AnimatedContainer( - duration: const Duration(milliseconds: 200), - padding: const EdgeInsets.symmetric(vertical: 10), - decoration: BoxDecoration( - color: isSelected ? colorScheme.primaryContainer : colorScheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(12), - border: isSelected ? null : Border.all(color: colorScheme.outlineVariant.withValues(alpha: 0.5)), - ), - child: Text( - label, - textAlign: TextAlign.center, - style: TextStyle( - fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal, - color: isSelected ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant, - ), - ), - ), - ), - ); - } -} - -class _TrackInfoHeader extends StatefulWidget { - final String trackName; - final String? artistName; - final String? coverUrl; - const _TrackInfoHeader({required this.trackName, this.artistName, this.coverUrl}); - - @override - State<_TrackInfoHeader> createState() => _TrackInfoHeaderState(); -} - -class _TrackInfoHeaderState extends State<_TrackInfoHeader> { - bool _expanded = false; - bool _isOverflowing = false; - - @override - Widget build(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; - return Material( - color: Colors.transparent, - child: InkWell( - onTap: _isOverflowing ? () => setState(() => _expanded = !_expanded) : null, - borderRadius: const BorderRadius.only(topLeft: Radius.circular(28), topRight: Radius.circular(28)), - child: Column( - children: [ - const SizedBox(height: 8), - Container(width: 40, height: 4, decoration: BoxDecoration(color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4), borderRadius: BorderRadius.circular(2))), - Padding( - padding: const EdgeInsets.fromLTRB(16, 12, 16, 12), - child: Row( - children: [ - ClipRRect( - borderRadius: BorderRadius.circular(8), - child: widget.coverUrl != null - ? Image.network(widget.coverUrl!, width: 56, height: 56, fit: BoxFit.cover, - errorBuilder: (_, e, s) => Container(width: 56, height: 56, color: colorScheme.surfaceContainerHighest, child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant))) - : Container(width: 56, height: 56, color: colorScheme.surfaceContainerHighest, child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant)), - ), - const SizedBox(width: 12), - Expanded( - child: LayoutBuilder( - builder: (context, constraints) { - final titleStyle = Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600); - final titleSpan = TextSpan(text: widget.trackName, style: titleStyle); - final titlePainter = TextPainter(text: titleSpan, maxLines: 1, textDirection: TextDirection.ltr)..layout(maxWidth: constraints.maxWidth); - final titleOverflows = titlePainter.didExceedMaxLines; - - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted && _isOverflowing != titleOverflows) { - setState(() => _isOverflowing = titleOverflows); - } - }); - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - widget.trackName, - style: titleStyle, - maxLines: _expanded ? 10 : 1, - overflow: _expanded ? TextOverflow.visible : TextOverflow.ellipsis, - ), - if (widget.artistName != null) ...[ - const SizedBox(height: 2), - Text( - widget.artistName!, - style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant), - maxLines: _expanded ? 3 : 1, - overflow: _expanded ? TextOverflow.visible : TextOverflow.ellipsis, - ), - ], - ], - ); - }, - ), - ), - if (_isOverflowing || _expanded) - Icon(_expanded ? Icons.expand_less : Icons.expand_more, color: colorScheme.onSurfaceVariant, size: 20), - ], - ), - ), - ], - ), - ), - ); - } -} - /// Separate Consumer widget for each track - only rebuilds when this specific track's status changes class _AlbumTrackItem extends ConsumerWidget { final Track track; diff --git a/lib/screens/home_tab.dart b/lib/screens/home_tab.dart index e7fa901b..44b6457d 100644 --- a/lib/screens/home_tab.dart +++ b/lib/screens/home_tab.dart @@ -8,12 +8,14 @@ import 'package:spotiflac_android/models/track.dart'; import 'package:spotiflac_android/providers/track_provider.dart'; import 'package:spotiflac_android/providers/download_queue_provider.dart'; import 'package:spotiflac_android/providers/settings_provider.dart'; +import 'package:spotiflac_android/providers/extension_provider.dart'; import 'package:spotiflac_android/screens/track_metadata_screen.dart'; import 'package:spotiflac_android/screens/album_screen.dart'; import 'package:spotiflac_android/screens/artist_screen.dart'; import 'package:spotiflac_android/services/csv_import_service.dart'; import 'package:spotiflac_android/screens/playlist_screen.dart'; import 'package:spotiflac_android/models/download_item.dart'; +import 'package:spotiflac_android/widgets/download_service_picker.dart'; class HomeTab extends ConsumerStatefulWidget { const HomeTab({super.key}); @@ -78,12 +80,21 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient } Future _performSearch(String query) async { - // Skip if same query already searched - if (_lastSearchQuery == query) return; - _lastSearchQuery = query; - final settings = ref.read(settingsProvider); - await ref.read(trackProvider.notifier).search(query, metadataSource: settings.metadataSource); + final searchProvider = settings.searchProvider; + + // Skip if same query already searched with same provider + final searchKey = '${searchProvider ?? 'default'}:$query'; + if (_lastSearchQuery == searchKey) return; + _lastSearchQuery = searchKey; + + if (searchProvider != null && searchProvider.isNotEmpty) { + // Use custom search from extension + await ref.read(trackProvider.notifier).customSearch(searchProvider, query); + } else { + // Use default search (Deezer/Spotify) + await ref.read(trackProvider.notifier).search(query, metadataSource: settings.metadataSource); + } ref.read(settingsProvider.notifier).setHasSearchedBefore(); } @@ -173,10 +184,16 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient final settings = ref.read(settingsProvider); if (settings.askQualityBeforeDownload) { - _showQualityPicker(context, (quality, service) { - ref.read(downloadQueueProvider.notifier).addToQueue(track, service, qualityOverride: quality); - ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added "${track.name}" to queue'))); - }, trackName: track.name, artistName: track.artistName, coverUrl: track.coverUrl); + DownloadServicePicker.show( + context, + trackName: track.name, + artistName: track.artistName, + coverUrl: track.coverUrl, + onSelect: (quality, service) { + ref.read(downloadQueueProvider.notifier).addToQueue(track, service, qualityOverride: quality); + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added "${track.name}" to queue'))); + }, + ); } else { ref.read(downloadQueueProvider.notifier).addToQueue(track, settings.defaultService); ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added "${track.name}" to queue'))); @@ -184,107 +201,23 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient } } - void _showQualityPicker(BuildContext context, void Function(String quality, String service) onSelect, {String? trackName, String? artistName, String? coverUrl}) { - final colorScheme = Theme.of(context).colorScheme; - final settings = ref.read(settingsProvider); - String selectedService = settings.defaultService; - - showModalBottomSheet( - context: context, - backgroundColor: colorScheme.surfaceContainerHigh, - shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(28))), - isScrollControlled: true, - builder: (context) => StatefulBuilder( - builder: (context, setModalState) => SafeArea( - child: SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (trackName != null) ...[ - _TrackInfoHeader(trackName: trackName, artistName: artistName, coverUrl: coverUrl), - Divider(height: 1, color: colorScheme.outlineVariant.withValues(alpha: 0.5)), - ] else ...[ - const SizedBox(height: 8), - Center(child: Container(width: 40, height: 4, decoration: BoxDecoration(color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4), borderRadius: BorderRadius.circular(2)))), - ], - // Service selector - Padding( - padding: const EdgeInsets.fromLTRB(24, 16, 24, 8), - child: Text('Download From', style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold)), - ), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 20), - child: Row( - children: [ - _ServiceChip(label: 'Tidal', isSelected: selectedService == 'tidal', onTap: () => setModalState(() => selectedService = 'tidal')), - const SizedBox(width: 8), - _ServiceChip(label: 'Qobuz', isSelected: selectedService == 'qobuz', onTap: () => setModalState(() => selectedService = 'qobuz')), - const SizedBox(width: 8), - _ServiceChip(label: 'Amazon', isSelected: selectedService == 'amazon', onTap: () => setModalState(() => selectedService = 'amazon')), - ], - ), - ), - Padding( - padding: const EdgeInsets.fromLTRB(24, 16, 24, 8), - child: Text('Select Quality', style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold)), - ), - // Disclaimer - Padding( - padding: const EdgeInsets.fromLTRB(24, 0, 24, 12), - child: Text( - 'Actual quality depends on track availability. Hi-Res may not be available for all tracks.', - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: colorScheme.onSurfaceVariant, - fontStyle: FontStyle.italic, - ), - ), - ), - _QualityPickerOption( - title: 'FLAC Lossless', - subtitle: '16-bit / 44.1kHz', - icon: Icons.music_note, - onTap: () { Navigator.pop(context); onSelect('LOSSLESS', selectedService); }, - ), - _QualityPickerOption( - title: 'Hi-Res FLAC', - subtitle: '24-bit / up to 96kHz', - icon: Icons.high_quality, - onTap: () { Navigator.pop(context); onSelect('HI_RES', selectedService); }, - ), - _QualityPickerOption( - title: 'Hi-Res FLAC Max', - subtitle: '24-bit / up to 192kHz', - icon: Icons.four_k, - onTap: () { Navigator.pop(context); onSelect('HI_RES_LOSSLESS', selectedService); }, - ), - const SizedBox(height: 16), - ], - ), - ), - ), - ), - ); - } - Future _importCsv(BuildContext context, WidgetRef ref) async { // Show loading dialog with progress int currentProgress = 0; int totalTracks = 0; // Use StatefulBuilder to update dialog content - final dialogContext = context; bool dialogShown = false; StateSetter? setDialogState; void showProgressDialog() { - if (dialogShown) return; + if (dialogShown || !mounted) return; dialogShown = true; showDialog( - context: dialogContext, + context: this.context, barrierDismissible: false, - builder: (context) => StatefulBuilder( - builder: (context, setState) { + builder: (dialogCtx) => StatefulBuilder( + builder: (dialogCtx, setState) { setDialogState = setState; return AlertDialog( content: Column( @@ -318,25 +251,27 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient // Close progress dialog if (dialogShown && mounted) { - Navigator.of(dialogContext).pop(); + Navigator.of(this.context).pop(); } if (tracks.isNotEmpty) { final settings = ref.read(settingsProvider); + if (!mounted) return; + // Optionally show confirmation dialog final confirmed = await showDialog( - context: context, - builder: (context) => AlertDialog( + context: this.context, + builder: (dialogCtx) => AlertDialog( title: const Text('Import Playlist'), content: Text('Found ${tracks.length} tracks in CSV. Add them to download queue?'), actions: [ TextButton( - onPressed: () => Navigator.pop(context, false), + onPressed: () => Navigator.pop(dialogCtx, false), child: const Text('Cancel'), ), FilledButton( - onPressed: () => Navigator.pop(context, true), + onPressed: () => Navigator.pop(dialogCtx, true), child: const Text('Import'), ), ], @@ -346,7 +281,7 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient if (confirmed == true) { ref.read(downloadQueueProvider.notifier).addMultipleToQueue(tracks, settings.defaultService); if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( + ScaffoldMessenger.of(this.context).showSnackBar( SnackBar( content: Text('Added ${tracks.length} tracks to queue'), action: SnackBarAction( @@ -836,6 +771,22 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient )); } + /// Get search hint based on selected provider + String _getSearchHint() { + final settings = ref.read(settingsProvider); + final searchProvider = settings.searchProvider; + + if (searchProvider != null && searchProvider.isNotEmpty) { + final extState = ref.read(extensionProvider); + final ext = extState.extensions.where((e) => e.id == searchProvider).firstOrNull; + if (ext?.searchBehavior?.placeholder != null) { + return ext!.searchBehavior!.placeholder!; + } + return 'Search with ${ext?.displayName ?? 'extension'}...'; + } + return 'Paste Spotify URL or search...'; + } + Widget _buildSearchBar(ColorScheme colorScheme) { final hasText = _urlController.text.isNotEmpty; @@ -844,7 +795,7 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient focusNode: _searchFocusNode, autofocus: false, decoration: InputDecoration( - hintText: 'Paste Spotify URL or search...', + hintText: _getSearchHint(), filled: true, fillColor: colorScheme.surfaceContainerHighest, border: OutlineInputBorder( @@ -910,147 +861,6 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient } -class _QualityPickerOption extends StatelessWidget { - final String title; - final String subtitle; - final IconData icon; - final VoidCallback onTap; - const _QualityPickerOption({required this.title, required this.subtitle, required this.icon, required this.onTap}); - - @override - Widget build(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; - return ListTile( - contentPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 4), - leading: Container(padding: const EdgeInsets.all(10), decoration: BoxDecoration(color: colorScheme.primaryContainer, borderRadius: BorderRadius.circular(12)), child: Icon(icon, color: colorScheme.onPrimaryContainer, size: 20)), - title: Text(title, style: const TextStyle(fontWeight: FontWeight.w500)), - subtitle: Text(subtitle, style: TextStyle(color: colorScheme.onSurfaceVariant)), - onTap: onTap, - ); - } -} - -class _ServiceChip extends StatelessWidget { - final String label; - final bool isSelected; - final VoidCallback onTap; - const _ServiceChip({required this.label, required this.isSelected, required this.onTap}); - - @override - Widget build(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; - return Expanded( - child: GestureDetector( - onTap: onTap, - child: AnimatedContainer( - duration: const Duration(milliseconds: 200), - padding: const EdgeInsets.symmetric(vertical: 10), - decoration: BoxDecoration( - color: isSelected ? colorScheme.primaryContainer : colorScheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(12), - border: isSelected ? null : Border.all(color: colorScheme.outlineVariant.withValues(alpha: 0.5)), - ), - child: Text( - label, - textAlign: TextAlign.center, - style: TextStyle( - fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal, - color: isSelected ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant, - ), - ), - ), - ), - ); - } -} - -class _TrackInfoHeader extends StatefulWidget { - final String trackName; - final String? artistName; - final String? coverUrl; - const _TrackInfoHeader({required this.trackName, this.artistName, this.coverUrl}); - - @override - State<_TrackInfoHeader> createState() => _TrackInfoHeaderState(); -} - -class _TrackInfoHeaderState extends State<_TrackInfoHeader> { - bool _expanded = false; - bool _isOverflowing = false; - - @override - Widget build(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; - return Material( - color: Colors.transparent, - child: InkWell( - onTap: _isOverflowing ? () => setState(() => _expanded = !_expanded) : null, - borderRadius: const BorderRadius.only(topLeft: Radius.circular(28), topRight: Radius.circular(28)), - child: Column( - children: [ - const SizedBox(height: 8), - Container(width: 40, height: 4, decoration: BoxDecoration(color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4), borderRadius: BorderRadius.circular(2))), - Padding( - padding: const EdgeInsets.fromLTRB(16, 12, 16, 12), - child: Row( - children: [ - ClipRRect( - borderRadius: BorderRadius.circular(8), - child: widget.coverUrl != null - ? Image.network(widget.coverUrl!, width: 56, height: 56, fit: BoxFit.cover, - errorBuilder: (_, e, s) => Container(width: 56, height: 56, color: colorScheme.surfaceContainerHighest, child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant))) - : Container(width: 56, height: 56, color: colorScheme.surfaceContainerHighest, child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant)), - ), - const SizedBox(width: 12), - Expanded( - child: LayoutBuilder( - builder: (context, constraints) { - final titleStyle = Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600); - final titleSpan = TextSpan(text: widget.trackName, style: titleStyle); - final titlePainter = TextPainter(text: titleSpan, maxLines: 1, textDirection: TextDirection.ltr)..layout(maxWidth: constraints.maxWidth); - final titleOverflows = titlePainter.didExceedMaxLines; - - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted && _isOverflowing != titleOverflows) { - setState(() => _isOverflowing = titleOverflows); - } - }); - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - widget.trackName, - style: titleStyle, - maxLines: _expanded ? 10 : 1, - overflow: _expanded ? TextOverflow.visible : TextOverflow.ellipsis, - ), - if (widget.artistName != null) ...[ - const SizedBox(height: 2), - Text( - widget.artistName!, - style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant), - maxLines: _expanded ? 3 : 1, - overflow: _expanded ? TextOverflow.visible : TextOverflow.ellipsis, - ), - ], - ], - ); - }, - ), - ), - if (_isOverflowing || _expanded) - Icon(_expanded ? Icons.expand_less : Icons.expand_more, color: colorScheme.onSurfaceVariant, size: 20), - ], - ), - ), - ], - ), - ), - ); - } -} - /// Separate Consumer widget for each track item - only rebuilds when this specific track's status changes class _TrackItemWithStatus extends ConsumerWidget { final Track track; @@ -1080,6 +890,28 @@ class _TrackItemWithStatus extends ConsumerWidget { return state.isDownloaded(track.id); })); + // Get thumbnail size from extension if track is from extension + double thumbWidth = 56; + double thumbHeight = 56; + + // Get extension ID from track.source or from TrackState.searchExtensionId + final trackState = ref.watch(trackProvider); + final extensionId = track.source ?? trackState.searchExtensionId; + + if (extensionId != null && extensionId.isNotEmpty) { + final extState = ref.watch(extensionProvider); + final extension = extState.extensions.where((e) => e.id == extensionId).firstOrNull; + if (extension?.searchBehavior != null) { + final size = extension!.searchBehavior!.getThumbnailSize(defaultSize: 56); + thumbWidth = size.$1; + thumbHeight = size.$2; + // Debug: log only when using custom size + if (thumbWidth != 56 || thumbHeight != 56) { + debugPrint('[Thumbnail] ${track.name}: using ${thumbWidth.toInt()}x${thumbHeight.toInt()} from ${extension.id}'); + } + } + } + final isQueued = queueItem != null; final isDownloading = queueItem?.status == DownloadStatus.downloading; final isFinalizing = queueItem?.status == DownloadStatus.finalizing; @@ -1100,21 +932,21 @@ class _TrackItemWithStatus extends ConsumerWidget { padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), child: Row( children: [ - // Album art + // Album art with dynamic size based on extension config ClipRRect( borderRadius: BorderRadius.circular(10), child: track.coverUrl != null ? CachedNetworkImage( imageUrl: track.coverUrl!, - width: 56, - height: 56, + width: thumbWidth, + height: thumbHeight, fit: BoxFit.cover, - memCacheWidth: 112, - memCacheHeight: 112, + memCacheWidth: (thumbWidth * 2).toInt(), + memCacheHeight: (thumbHeight * 2).toInt(), ) : Container( - width: 56, - height: 56, + width: thumbWidth, + height: thumbHeight, color: colorScheme.surfaceContainerHighest, child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant), ), @@ -1151,7 +983,7 @@ class _TrackItemWithStatus extends ConsumerWidget { Divider( height: 1, thickness: 1, - indent: 80, + indent: thumbWidth + 24, // Adjust divider indent based on thumbnail width endIndent: 12, color: colorScheme.outlineVariant.withValues(alpha: 0.3), ), diff --git a/lib/screens/playlist_screen.dart b/lib/screens/playlist_screen.dart index cc3baec7..73d9c962 100644 --- a/lib/screens/playlist_screen.dart +++ b/lib/screens/playlist_screen.dart @@ -6,6 +6,7 @@ import 'package:spotiflac_android/models/track.dart'; import 'package:spotiflac_android/models/download_item.dart'; import 'package:spotiflac_android/providers/download_queue_provider.dart'; import 'package:spotiflac_android/providers/settings_provider.dart'; +import 'package:spotiflac_android/widgets/download_service_picker.dart'; /// Playlist detail screen with Material Expressive 3 design class PlaylistScreen extends ConsumerWidget { @@ -168,10 +169,16 @@ class PlaylistScreen extends ConsumerWidget { void _downloadTrack(BuildContext context, WidgetRef ref, Track track) { final settings = ref.read(settingsProvider); if (settings.askQualityBeforeDownload) { - _showQualityPicker(context, ref, (quality, service) { - ref.read(downloadQueueProvider.notifier).addToQueue(track, service, qualityOverride: quality); - ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added "${track.name}" to queue'))); - }, trackName: track.name, artistName: track.artistName, coverUrl: track.coverUrl); + DownloadServicePicker.show( + context, + trackName: track.name, + artistName: track.artistName, + coverUrl: track.coverUrl, + onSelect: (quality, service) { + ref.read(downloadQueueProvider.notifier).addToQueue(track, service, qualityOverride: quality); + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added "${track.name}" to queue'))); + }, + ); } else { ref.read(downloadQueueProvider.notifier).addToQueue(track, settings.defaultService); ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added "${track.name}" to queue'))); @@ -182,222 +189,20 @@ class PlaylistScreen extends ConsumerWidget { if (tracks.isEmpty) return; final settings = ref.read(settingsProvider); if (settings.askQualityBeforeDownload) { - _showQualityPicker(context, ref, (quality, service) { - ref.read(downloadQueueProvider.notifier).addMultipleToQueue(tracks, service, qualityOverride: quality); - ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added ${tracks.length} tracks to queue'))); - }, trackName: '${tracks.length} tracks', artistName: playlistName); + DownloadServicePicker.show( + context, + trackName: '${tracks.length} tracks', + artistName: playlistName, + onSelect: (quality, service) { + ref.read(downloadQueueProvider.notifier).addMultipleToQueue(tracks, service, qualityOverride: quality); + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added ${tracks.length} tracks to queue'))); + }, + ); } else { ref.read(downloadQueueProvider.notifier).addMultipleToQueue(tracks, settings.defaultService); ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added ${tracks.length} tracks to queue'))); } } - - void _showQualityPicker(BuildContext context, WidgetRef ref, void Function(String quality, String service) onSelect, {String? trackName, String? artistName, String? coverUrl}) { - final colorScheme = Theme.of(context).colorScheme; - final settings = ref.read(settingsProvider); - String selectedService = settings.defaultService; - - showModalBottomSheet( - context: context, - backgroundColor: colorScheme.surfaceContainerHigh, - shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(28))), - isScrollControlled: true, - builder: (context) => StatefulBuilder( - builder: (context, setModalState) => SafeArea( - child: SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (trackName != null) ...[ - _TrackInfoHeader(trackName: trackName, artistName: artistName, coverUrl: coverUrl), - Divider(height: 1, color: colorScheme.outlineVariant.withValues(alpha: 0.5)), - ] else ...[ - const SizedBox(height: 8), - Center(child: Container(width: 40, height: 4, decoration: BoxDecoration(color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4), borderRadius: BorderRadius.circular(2)))), - ], - // Service selector - Padding( - padding: const EdgeInsets.fromLTRB(24, 16, 24, 8), - child: Text('Download From', style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold)), - ), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 20), - child: Row( - children: [ - _ServiceChip(label: 'Tidal', isSelected: selectedService == 'tidal', onTap: () => setModalState(() => selectedService = 'tidal')), - const SizedBox(width: 8), - _ServiceChip(label: 'Qobuz', isSelected: selectedService == 'qobuz', onTap: () => setModalState(() => selectedService = 'qobuz')), - const SizedBox(width: 8), - _ServiceChip(label: 'Amazon', isSelected: selectedService == 'amazon', onTap: () => setModalState(() => selectedService = 'amazon')), - ], - ), - ), - Padding(padding: const EdgeInsets.fromLTRB(24, 16, 24, 8), child: Text('Select Quality', style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold))), - // Disclaimer - Padding( - padding: const EdgeInsets.fromLTRB(24, 0, 24, 12), - child: Text( - 'Actual quality depends on track availability. Hi-Res may not be available for all tracks.', - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: colorScheme.onSurfaceVariant, - fontStyle: FontStyle.italic, - ), - ), - ), - _QualityOption(title: 'FLAC Lossless', subtitle: '16-bit / 44.1kHz', icon: Icons.music_note, onTap: () { Navigator.pop(context); onSelect('LOSSLESS', selectedService); }), - _QualityOption(title: 'Hi-Res FLAC', subtitle: '24-bit / up to 96kHz', icon: Icons.high_quality, onTap: () { Navigator.pop(context); onSelect('HI_RES', selectedService); }), - _QualityOption(title: 'Hi-Res FLAC Max', subtitle: '24-bit / up to 192kHz', icon: Icons.four_k, onTap: () { Navigator.pop(context); onSelect('HI_RES_LOSSLESS', selectedService); }), - const SizedBox(height: 16), - ], - ), - ), - ), - ), - ); - } -} - -class _QualityOption extends StatelessWidget { - final String title; - final String subtitle; - final IconData icon; - final VoidCallback onTap; - - const _QualityOption({required this.title, required this.subtitle, required this.icon, required this.onTap}); - - @override - Widget build(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; - return ListTile( - contentPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 4), - leading: Container(padding: const EdgeInsets.all(10), decoration: BoxDecoration(color: colorScheme.primaryContainer, borderRadius: BorderRadius.circular(12)), child: Icon(icon, color: colorScheme.onPrimaryContainer, size: 20)), - title: Text(title, style: const TextStyle(fontWeight: FontWeight.w500)), - subtitle: Text(subtitle, style: TextStyle(color: colorScheme.onSurfaceVariant)), - onTap: onTap, - ); - } -} - -class _ServiceChip extends StatelessWidget { - final String label; - final bool isSelected; - final VoidCallback onTap; - const _ServiceChip({required this.label, required this.isSelected, required this.onTap}); - - @override - Widget build(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; - return Expanded( - child: GestureDetector( - onTap: onTap, - child: AnimatedContainer( - duration: const Duration(milliseconds: 200), - padding: const EdgeInsets.symmetric(vertical: 10), - decoration: BoxDecoration( - color: isSelected ? colorScheme.primaryContainer : colorScheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(12), - border: isSelected ? null : Border.all(color: colorScheme.outlineVariant.withValues(alpha: 0.5)), - ), - child: Text( - label, - textAlign: TextAlign.center, - style: TextStyle( - fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal, - color: isSelected ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant, - ), - ), - ), - ), - ); - } -} - -class _TrackInfoHeader extends StatefulWidget { - final String trackName; - final String? artistName; - final String? coverUrl; - const _TrackInfoHeader({required this.trackName, this.artistName, this.coverUrl}); - - @override - State<_TrackInfoHeader> createState() => _TrackInfoHeaderState(); -} - -class _TrackInfoHeaderState extends State<_TrackInfoHeader> { - bool _expanded = false; - bool _isOverflowing = false; - - @override - Widget build(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; - return Material( - color: Colors.transparent, - child: InkWell( - onTap: _isOverflowing ? () => setState(() => _expanded = !_expanded) : null, - borderRadius: const BorderRadius.only(topLeft: Radius.circular(28), topRight: Radius.circular(28)), - child: Column( - children: [ - const SizedBox(height: 8), - Container(width: 40, height: 4, decoration: BoxDecoration(color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4), borderRadius: BorderRadius.circular(2))), - Padding( - padding: const EdgeInsets.fromLTRB(16, 12, 16, 12), - child: Row( - children: [ - ClipRRect( - borderRadius: BorderRadius.circular(8), - child: widget.coverUrl != null - ? Image.network(widget.coverUrl!, width: 56, height: 56, fit: BoxFit.cover, - errorBuilder: (_, e, s) => Container(width: 56, height: 56, color: colorScheme.surfaceContainerHighest, child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant))) - : Container(width: 56, height: 56, color: colorScheme.surfaceContainerHighest, child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant)), - ), - const SizedBox(width: 12), - Expanded( - child: LayoutBuilder( - builder: (context, constraints) { - final titleStyle = Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600); - final titleSpan = TextSpan(text: widget.trackName, style: titleStyle); - final titlePainter = TextPainter(text: titleSpan, maxLines: 1, textDirection: TextDirection.ltr)..layout(maxWidth: constraints.maxWidth); - final titleOverflows = titlePainter.didExceedMaxLines; - - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted && _isOverflowing != titleOverflows) { - setState(() => _isOverflowing = titleOverflows); - } - }); - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - widget.trackName, - style: titleStyle, - maxLines: _expanded ? 10 : 1, - overflow: _expanded ? TextOverflow.visible : TextOverflow.ellipsis, - ), - if (widget.artistName != null) ...[ - const SizedBox(height: 2), - Text( - widget.artistName!, - style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant), - maxLines: _expanded ? 3 : 1, - overflow: _expanded ? TextOverflow.visible : TextOverflow.ellipsis, - ), - ], - ], - ); - }, - ), - ), - if (_isOverflowing || _expanded) - Icon(_expanded ? Icons.expand_less : Icons.expand_more, color: colorScheme.onSurfaceVariant, size: 20), - ], - ), - ), - ], - ), - ), - ); - } } /// Separate Consumer widget for each track - only rebuilds when this specific track's status changes diff --git a/lib/screens/settings/download_settings_page.dart b/lib/screens/settings/download_settings_page.dart index c08fdadd..e1db9669 100644 --- a/lib/screens/settings/download_settings_page.dart +++ b/lib/screens/settings/download_settings_page.dart @@ -8,12 +8,18 @@ import 'package:spotiflac_android/widgets/settings_group.dart'; class DownloadSettingsPage extends ConsumerWidget { const DownloadSettingsPage({super.key}); + + // Built-in services that support quality options + static const _builtInServices = ['tidal', 'qobuz', 'amazon']; @override Widget build(BuildContext context, WidgetRef ref) { final settings = ref.watch(settingsProvider); final colorScheme = Theme.of(context).colorScheme; final topPadding = MediaQuery.of(context).padding.top; + + // Check if current service is built-in (supports quality options) + final isBuiltInService = _builtInServices.contains(settings.defaultService); return PopScope( canPop: true, @@ -87,13 +93,17 @@ class DownloadSettingsPage extends ConsumerWidget { SettingsSwitchItem( icon: Icons.tune, title: 'Ask Before Download', - subtitle: 'Choose quality for each download', + subtitle: isBuiltInService + ? 'Choose quality for each download' + : 'Select a built-in service to enable', value: settings.askQualityBeforeDownload, + // Not selected visually if extension is active + enabled: isBuiltInService, onChanged: (value) => ref .read(settingsProvider.notifier) .setAskQualityBeforeDownload(value), ), - if (!settings.askQualityBeforeDownload) ...[ + if (!settings.askQualityBeforeDownload && isBuiltInService) ...[ _QualityOption( title: 'FLAC Lossless', subtitle: '16-bit / 44.1kHz', @@ -120,6 +130,29 @@ class DownloadSettingsPage extends ConsumerWidget { showDivider: false, ), ], + if (!isBuiltInService) ...[ + Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 16), + child: Row( + children: [ + Icon( + Icons.info_outline, + size: 16, + color: colorScheme.onSurfaceVariant, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + 'Select Tidal, Qobuz, or Amazon above to configure quality', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ), + ], + ), + ), + ], ], ), ), @@ -359,8 +392,9 @@ class DownloadSettingsPage extends ConsumerWidget { } else { // Android: Use file picker final result = await FilePicker.platform.getDirectoryPath(); - if (result != null) + if (result != null) { ref.read(settingsProvider.notifier).setDownloadDirectory(result); + } } } diff --git a/lib/screens/settings/extension_detail_page.dart b/lib/screens/settings/extension_detail_page.dart new file mode 100644 index 00000000..18c98e91 --- /dev/null +++ b/lib/screens/settings/extension_detail_page.dart @@ -0,0 +1,964 @@ +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/widgets/settings_group.dart'; + +class ExtensionDetailPage extends ConsumerStatefulWidget { + final String extensionId; + + const ExtensionDetailPage({super.key, required this.extensionId}); + + @override + ConsumerState createState() => _ExtensionDetailPageState(); +} + +class _ExtensionDetailPageState extends ConsumerState { + Map _settings = {}; + bool _isLoadingSettings = true; + + @override + void initState() { + super.initState(); + _loadSettings(); + } + + Future _loadSettings() async { + final settings = await ref + .read(extensionProvider.notifier) + .getExtensionSettings(widget.extensionId); + setState(() { + _settings = settings; + _isLoadingSettings = false; + }); + } + + @override + Widget build(BuildContext context) { + final extState = ref.watch(extensionProvider); + final extension = extState.extensions.firstWhere( + (e) => e.id == widget.extensionId, + orElse: () => const Extension( + id: '', + name: '', + displayName: 'Unknown', + version: '0.0.0', + author: 'Unknown', + description: '', + enabled: false, + status: 'error', + ), + ); + + final colorScheme = Theme.of(context).colorScheme; + final topPadding = MediaQuery.of(context).padding.top; + final hasError = extension.status == 'error'; + + return Scaffold( + body: CustomScrollView( + slivers: [ + // App Bar + SliverAppBar( + expandedHeight: 120 + topPadding, + collapsedHeight: kToolbarHeight, + floating: false, + pinned: true, + backgroundColor: colorScheme.surface, + surfaceTintColor: Colors.transparent, + leading: IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () => Navigator.pop(context), + ), + 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); + final leftPadding = 56 - (32 * expandRatio); + return FlexibleSpaceBar( + expandedTitleScale: 1.0, + titlePadding: EdgeInsets.only(left: leftPadding, bottom: 16), + title: Text( + extension.displayName, + style: TextStyle( + fontSize: 20 + (8 * expandRatio), + fontWeight: FontWeight.bold, + color: colorScheme.onSurface, + ), + ), + ); + }, + ), + ), + + // Extension Info Card + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.all(16), + child: Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.3), + borderRadius: BorderRadius.circular(20), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + width: 56, + height: 56, + decoration: BoxDecoration( + color: hasError + ? colorScheme.errorContainer + : colorScheme.primaryContainer, + borderRadius: BorderRadius.circular(16), + ), + child: extension.iconPath != null && extension.iconPath!.isNotEmpty + ? ClipRRect( + borderRadius: BorderRadius.circular(16), + child: Image.file( + File(extension.iconPath!), + width: 56, + height: 56, + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) => Icon( + hasError ? Icons.error_outline : Icons.extension, + size: 28, + color: hasError + ? colorScheme.error + : colorScheme.onPrimaryContainer, + ), + ), + ) + : Icon( + hasError ? Icons.error_outline : Icons.extension, + size: 28, + color: hasError + ? colorScheme.error + : colorScheme.onPrimaryContainer, + ), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + extension.displayName, + style: Theme.of(context).textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + Text( + 'v${extension.version}', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + Switch( + value: extension.enabled, + onChanged: hasError + ? null + : (enabled) => ref + .read(extensionProvider.notifier) + .setExtensionEnabled(widget.extensionId, enabled), + ), + ], + ), + if (extension.description.isNotEmpty) ...[ + const SizedBox(height: 16), + Text( + extension.description, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ], + const SizedBox(height: 16), + _InfoRow(label: 'Author', value: extension.author), + _InfoRow(label: 'ID', value: extension.id), + if (hasError && extension.errorMessage != null) + _InfoRow( + label: 'Error', + value: extension.errorMessage!, + isError: true, + ), + ], + ), + ), + ), + ), + + // Capabilities + const SliverToBoxAdapter( + child: SettingsSectionHeader(title: 'Capabilities'), + ), + SliverToBoxAdapter( + child: SettingsGroup( + children: [ + _CapabilityItem( + icon: Icons.search, + title: 'Metadata Provider', + enabled: extension.hasMetadataProvider, + ), + _CapabilityItem( + icon: Icons.download, + title: 'Download Provider', + enabled: extension.hasDownloadProvider, + ), + _CapabilityItem( + icon: Icons.manage_search, + title: 'Custom Search', + enabled: extension.hasCustomSearch, + subtitle: extension.searchBehavior?.placeholder, + ), + _CapabilityItem( + icon: Icons.compare_arrows, + title: 'Custom Track Matching', + enabled: extension.hasCustomMatching, + subtitle: extension.trackMatching?.strategy != null + ? 'Strategy: ${extension.trackMatching!.strategy}' + : null, + ), + _CapabilityItem( + icon: Icons.auto_fix_high, + title: 'Post-Processing', + enabled: extension.hasPostProcessing, + subtitle: extension.postProcessing?.hooks.isNotEmpty == true + ? '${extension.postProcessing!.hooks.length} hook(s) available' + : null, + showDivider: false, + ), + ], + ), + ), + + // Search Provider Section (if extension has custom search) + if (extension.hasCustomSearch) ...[ + const SliverToBoxAdapter( + child: SettingsSectionHeader(title: 'Search Provider'), + ), + SliverToBoxAdapter( + child: SettingsGroup( + children: [ + _SearchProviderInfo( + extension: extension, + ), + ], + ), + ), + ], + + // Post-Processing Hooks (if available) + if (extension.hasPostProcessing && extension.postProcessing!.hooks.isNotEmpty) ...[ + const SliverToBoxAdapter( + child: SettingsSectionHeader(title: 'Post-Processing Hooks'), + ), + SliverToBoxAdapter( + child: SettingsGroup( + children: extension.postProcessing!.hooks.asMap().entries.map((entry) { + final index = entry.key; + final hook = entry.value; + return _PostProcessingHookItem( + hook: hook, + showDivider: index < extension.postProcessing!.hooks.length - 1, + ); + }).toList(), + ), + ), + ], + + // Permissions + if (extension.permissions.isNotEmpty) ...[ + const SliverToBoxAdapter( + child: SettingsSectionHeader(title: 'Permissions'), + ), + SliverToBoxAdapter( + child: SettingsGroup( + children: extension.permissions.asMap().entries.map((entry) { + final index = entry.key; + final permission = entry.value; + return _PermissionItem( + permission: permission, + showDivider: index < extension.permissions.length - 1, + ); + }).toList(), + ), + ), + ], + + // Settings + if (extension.settings.isNotEmpty) ...[ + const SliverToBoxAdapter( + child: SettingsSectionHeader(title: 'Settings'), + ), + if (_isLoadingSettings) + const SliverToBoxAdapter( + child: Padding( + padding: EdgeInsets.all(32), + child: Center(child: CircularProgressIndicator()), + ), + ) + else + SliverToBoxAdapter( + child: SettingsGroup( + children: extension.settings.asMap().entries.map((entry) { + final index = entry.key; + final setting = entry.value; + return _SettingItem( + setting: setting, + value: _settings[setting.key] ?? setting.defaultValue, + showDivider: index < extension.settings.length - 1, + onChanged: (value) => _updateSetting(setting.key, value), + ); + }).toList(), + ), + ), + ], + + // Remove button + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.all(16), + child: OutlinedButton.icon( + onPressed: () => _confirmRemove(context), + icon: const Icon(Icons.delete_outline), + label: const Text('Remove Extension'), + style: OutlinedButton.styleFrom( + foregroundColor: colorScheme.error, + side: BorderSide(color: colorScheme.error), + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + ), + ), + ), + ), + + const SliverToBoxAdapter(child: SizedBox(height: 32)), + ], + ), + ); + } + + Future _updateSetting(String key, dynamic value) async { + setState(() { + _settings[key] = value; + }); + await ref + .read(extensionProvider.notifier) + .setExtensionSettings(widget.extensionId, _settings); + } + + Future _confirmRemove(BuildContext context) async { + final colorScheme = Theme.of(context).colorScheme; + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Remove Extension'), + content: const Text( + 'Are you sure you want to remove this extension? ' + 'This action cannot be undone.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () => Navigator.pop(context, true), + style: FilledButton.styleFrom( + backgroundColor: colorScheme.error, + ), + child: const Text('Remove'), + ), + ], + ), + ); + + if (confirmed == true && mounted) { + final success = await ref + .read(extensionProvider.notifier) + .removeExtension(widget.extensionId); + if (success && mounted) { + Navigator.pop(this.context); + } + } + } +} + +class _InfoRow extends StatelessWidget { + final String label; + final String value; + final bool isError; + + const _InfoRow({ + required this.label, + required this.value, + this.isError = false, + }); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + return Padding( + padding: const EdgeInsets.only(top: 8), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 60, + child: Text( + label, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ), + Expanded( + child: Text( + value, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: isError ? colorScheme.error : colorScheme.onSurface, + ), + ), + ), + ], + ), + ); + } +} + +class _CapabilityItem extends StatelessWidget { + final IconData icon; + final String title; + final bool enabled; + final bool showDivider; + final String? subtitle; + + const _CapabilityItem({ + required this.icon, + required this.title, + required this.enabled, + this.showDivider = true, + this.subtitle, + }); + + @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: [ + Icon( + icon, + color: enabled ? colorScheme.primary : colorScheme.outline, + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: Theme.of(context).textTheme.bodyLarge, + ), + if (subtitle != null && enabled) ...[ + const SizedBox(height: 2), + Text( + subtitle!, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ], + ), + ), + Icon( + enabled ? Icons.check_circle : Icons.cancel_outlined, + color: enabled ? colorScheme.primary : colorScheme.outline, + ), + ], + ), + ), + if (showDivider) + Divider( + height: 1, + thickness: 1, + indent: 56, + endIndent: 16, + color: colorScheme.outlineVariant.withValues(alpha: 0.3), + ), + ], + ); + } +} + +class _PermissionItem extends StatelessWidget { + final String permission; + final bool showDivider; + + const _PermissionItem({ + required this.permission, + this.showDivider = true, + }); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + + // Parse permission to get icon and description + IconData icon = Icons.security; + String description = permission; + + if (permission.startsWith('network:')) { + icon = Icons.language; + description = 'Network access to: ${permission.substring(8)}'; + } else if (permission.startsWith('storage:')) { + icon = Icons.folder; + description = 'Storage access: ${permission.substring(8)}'; + } + + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Row( + children: [ + Icon(icon, color: colorScheme.onSurfaceVariant), + const SizedBox(width: 16), + Expanded( + child: Text( + description, + style: Theme.of(context).textTheme.bodyMedium, + ), + ), + ], + ), + ), + if (showDivider) + Divider( + height: 1, + thickness: 1, + indent: 56, + endIndent: 16, + color: colorScheme.outlineVariant.withValues(alpha: 0.3), + ), + ], + ); + } +} + +class _SettingItem extends StatelessWidget { + final ExtensionSetting setting; + final dynamic value; + final bool showDivider; + final ValueChanged onChanged; + + const _SettingItem({ + required this.setting, + required this.value, + required this.onChanged, + this.showDivider = true, + }); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + + Widget trailing; + switch (setting.type) { + case 'boolean': + trailing = Switch( + value: value as bool? ?? false, + onChanged: onChanged, + ); + break; + case 'select': + trailing = DropdownButton( + value: value as String?, + items: setting.options?.map((opt) { + return DropdownMenuItem(value: opt, child: Text(opt)); + }).toList(), + onChanged: onChanged, + underline: const SizedBox(), + ); + break; + default: + trailing = Icon( + Icons.chevron_right, + color: colorScheme.onSurfaceVariant, + ); + } + + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + InkWell( + onTap: setting.type == 'string' || setting.type == 'number' + ? () => _showEditDialog(context) + : null, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + setting.label, + style: Theme.of(context).textTheme.bodyLarge, + ), + if (setting.description != null) ...[ + const SizedBox(height: 2), + Text( + setting.description!, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ], + if (setting.type == 'string' || setting.type == 'number') ...[ + const SizedBox(height: 4), + Text( + value?.toString() ?? 'Not set', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.primary, + ), + ), + ], + ], + ), + ), + trailing, + ], + ), + ), + ), + if (showDivider) + Divider( + height: 1, + thickness: 1, + indent: 16, + endIndent: 16, + color: colorScheme.outlineVariant.withValues(alpha: 0.3), + ), + ], + ); + } + + void _showEditDialog(BuildContext context) { + final controller = TextEditingController(text: value?.toString() ?? ''); + final colorScheme = Theme.of(context).colorScheme; + + showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(setting.label), + content: TextField( + controller: controller, + keyboardType: setting.type == 'number' + ? TextInputType.number + : TextInputType.text, + decoration: InputDecoration( + hintText: setting.description ?? 'Enter value', + filled: true, + fillColor: colorScheme.surfaceContainerHighest.withValues(alpha: 0.3), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () { + final newValue = setting.type == 'number' + ? num.tryParse(controller.text) + : controller.text; + onChanged(newValue); + Navigator.pop(context); + }, + child: const Text('Save'), + ), + ], + ), + ); + } +} + +class _PostProcessingHookItem extends StatelessWidget { + final PostProcessingHook hook; + final bool showDivider; + + const _PostProcessingHookItem({ + required this.hook, + this.showDivider = true, + }); + + @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: [ + Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: colorScheme.tertiaryContainer, + borderRadius: BorderRadius.circular(10), + ), + child: Icon( + Icons.auto_fix_high, + color: colorScheme.onTertiaryContainer, + size: 20, + ), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + hook.name, + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + fontWeight: FontWeight.w500, + ), + ), + if (hook.description != null) ...[ + const SizedBox(height: 2), + Text( + hook.description!, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ], + if (hook.supportedFormats.isNotEmpty) ...[ + const SizedBox(height: 4), + Wrap( + spacing: 4, + children: hook.supportedFormats.map((format) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(4), + ), + child: Text( + format.toUpperCase(), + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ); + }).toList(), + ), + ], + ], + ), + ), + if (hook.defaultEnabled) + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: colorScheme.primaryContainer, + borderRadius: BorderRadius.circular(8), + ), + child: Text( + 'Auto', + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: colorScheme.onPrimaryContainer, + ), + ), + ), + ], + ), + ), + if (showDivider) + Divider( + height: 1, + thickness: 1, + indent: 72, + endIndent: 16, + color: colorScheme.outlineVariant.withValues(alpha: 0.3), + ), + ], + ); + } +} + +class _SearchProviderInfo extends StatelessWidget { + final Extension extension; + + const _SearchProviderInfo({ + required this.extension, + }); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final searchBehavior = extension.searchBehavior; + + return Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: colorScheme.secondaryContainer, + borderRadius: BorderRadius.circular(12), + ), + child: Icon( + Icons.manage_search, + color: colorScheme.onSecondaryContainer, + size: 24, + ), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Custom Search Available', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 2), + Text( + 'This extension provides its own search functionality', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + ], + ), + const SizedBox(height: 16), + // Search placeholder info + if (searchBehavior?.placeholder != null) ...[ + _InfoTile( + icon: Icons.text_fields, + label: 'Search Hint', + value: searchBehavior!.placeholder!, + ), + const SizedBox(height: 8), + ], + // Primary search info + _InfoTile( + icon: searchBehavior?.primary == true ? Icons.star : Icons.star_border, + label: 'Priority', + value: searchBehavior?.primary == true + ? 'Primary search provider' + : 'Secondary search provider', + ), + const SizedBox(height: 16), + // Usage instructions + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.5), + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + Icon( + Icons.info_outline, + size: 20, + color: colorScheme.primary, + ), + const SizedBox(width: 12), + Expanded( + child: Text( + 'To use this search provider, tap the search bar on the Home tab and select "${extension.displayName}" from the provider chips.', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ), + ], + ), + ), + ], + ), + ); + } +} + +class _InfoTile extends StatelessWidget { + final IconData icon; + final String label; + final String value; + + const _InfoTile({ + required this.icon, + required this.label, + required this.value, + }); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + + return Row( + children: [ + Icon( + icon, + size: 18, + color: colorScheme.onSurfaceVariant, + ), + const SizedBox(width: 8), + Text( + '$label: ', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + Expanded( + child: Text( + value, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onSurface, + fontWeight: FontWeight.w500, + ), + ), + ), + ], + ); + } +} diff --git a/lib/screens/settings/extensions_page.dart b/lib/screens/settings/extensions_page.dart new file mode 100644 index 00000000..6f2a6a71 --- /dev/null +++ b/lib/screens/settings/extensions_page.dart @@ -0,0 +1,721 @@ +import 'dart:io'; +import 'package:flutter/material.dart'; +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/extension_provider.dart'; +import 'package:spotiflac_android/providers/settings_provider.dart'; +import 'package:spotiflac_android/screens/settings/extension_detail_page.dart'; +import 'package:spotiflac_android/screens/settings/provider_priority_page.dart'; +import 'package:spotiflac_android/screens/settings/metadata_provider_priority_page.dart'; +import 'package:spotiflac_android/widgets/settings_group.dart'; + +class ExtensionsPage extends ConsumerStatefulWidget { + const ExtensionsPage({super.key}); + + @override + ConsumerState createState() => _ExtensionsPageState(); +} + +class _ExtensionsPageState extends ConsumerState { + @override + void initState() { + super.initState(); + _initializeExtensions(); + } + + Future _initializeExtensions() async { + final extState = ref.read(extensionProvider); + if (!extState.isInitialized) { + final appDir = await getApplicationDocumentsDirectory(); + final extensionsDir = '${appDir.path}/extensions'; + final dataDir = '${appDir.path}/extension_data'; + + // Create directories if they don't exist + await Directory(extensionsDir).create(recursive: true); + await Directory(dataDir).create(recursive: true); + + await ref.read(extensionProvider.notifier).initialize(extensionsDir, dataDir); + } + } + + @override + Widget build(BuildContext context) { + final extState = ref.watch(extensionProvider); + final colorScheme = Theme.of(context).colorScheme; + final topPadding = MediaQuery.of(context).padding.top; + + return Scaffold( + body: CustomScrollView( + slivers: [ + // App Bar + SliverAppBar( + expandedHeight: 120 + topPadding, + collapsedHeight: kToolbarHeight, + floating: false, + pinned: true, + backgroundColor: colorScheme.surface, + surfaceTintColor: Colors.transparent, + leading: IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () => Navigator.pop(context), + ), + 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); + final leftPadding = 56 - (32 * expandRatio); + return FlexibleSpaceBar( + expandedTitleScale: 1.0, + titlePadding: EdgeInsets.only(left: leftPadding, bottom: 16), + title: Text( + 'Extensions', + style: TextStyle( + fontSize: 20 + (8 * expandRatio), + fontWeight: FontWeight.bold, + color: colorScheme.onSurface, + ), + ), + ); + }, + ), + ), + + // Loading indicator + if (extState.isLoading) + const SliverToBoxAdapter( + child: Padding( + padding: EdgeInsets.all(32), + child: Center(child: CircularProgressIndicator()), + ), + ), + + // Error message + if (extState.error != null) + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.all(16), + child: Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: colorScheme.errorContainer, + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + Icon(Icons.error_outline, color: colorScheme.error), + const SizedBox(width: 12), + Expanded( + child: Text( + extState.error!, + style: TextStyle(color: colorScheme.onErrorContainer), + ), + ), + ], + ), + ), + ), + ), + + // Provider Priority + const SliverToBoxAdapter( + child: SettingsSectionHeader(title: 'Provider Priority'), + ), + SliverToBoxAdapter( + child: SettingsGroup( + children: [ + _DownloadPriorityItem(), + _MetadataPriorityItem(), + _SearchProviderSelector(), + ], + ), + ), + + // Installed Extensions + const SliverToBoxAdapter( + child: SettingsSectionHeader(title: 'Installed Extensions'), + ), + + if (extState.extensions.isEmpty && !extState.isLoading) + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Container( + padding: const EdgeInsets.all(24), + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.3), + borderRadius: BorderRadius.circular(16), + ), + child: Column( + children: [ + Icon( + Icons.extension_outlined, + size: 48, + color: colorScheme.onSurfaceVariant, + ), + const SizedBox(height: 12), + Text( + 'No extensions installed', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 4), + Text( + 'Install .spotiflac-ext files to add new providers', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + textAlign: TextAlign.center, + ), + ], + ), + ), + ), + ), + + if (extState.extensions.isNotEmpty) + SliverToBoxAdapter( + child: SettingsGroup( + children: extState.extensions.asMap().entries.map((entry) { + final index = entry.key; + final ext = entry.value; + return _ExtensionItem( + extension: ext, + showDivider: index < extState.extensions.length - 1, + onTap: () => Navigator.push( + context, + MaterialPageRoute( + builder: (_) => ExtensionDetailPage(extensionId: ext.id), + ), + ), + onToggle: (enabled) => ref + .read(extensionProvider.notifier) + .setExtensionEnabled(ext.id, enabled), + ); + }).toList(), + ), + ), + + // Install button + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.all(16), + child: FilledButton.icon( + onPressed: _installExtension, + icon: const Icon(Icons.add), + label: const Text('Install Extension'), + style: FilledButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + ), + ), + ), + ), + + // Info section + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 32), + child: Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: colorScheme.tertiaryContainer.withValues(alpha: 0.3), + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + Icon(Icons.info_outline, size: 20, color: colorScheme.tertiary), + const SizedBox(width: 12), + Expanded( + child: Text( + 'Extensions can add new metadata and download providers. ' + 'Only install extensions from trusted sources.', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onTertiaryContainer, + ), + ), + ), + ], + ), + ), + ), + ), + ], + ), + ); + } + + Future _installExtension() async { + final result = await FilePicker.platform.pickFiles( + type: FileType.any, + allowMultiple: false, + ); + + if (result != null && result.files.isNotEmpty) { + final file = result.files.first; + if (file.path != null) { + if (!file.path!.endsWith('.spotiflac-ext')) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Please select a .spotiflac-ext file'), + ), + ); + } + return; + } + + final success = await ref + .read(extensionProvider.notifier) + .installExtension(file.path!); + + if (mounted) { + final extState = ref.read(extensionProvider); + String message; + if (success) { + message = 'Extension installed successfully'; + } else { + // Parse friendly error message + message = _getFriendlyErrorMessage(extState.error); + } + + // Clear the error from state to avoid showing it twice (in error container) + ref.read(extensionProvider.notifier).clearError(); + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(message)), + ); + } + } + } + } + + /// Parse error message to be more user-friendly + String _getFriendlyErrorMessage(String? error) { + if (error == null) return 'Failed to install extension'; + + String message = error; + + // Remove PlatformException wrapper if present + // Format: PlatformException(ERROR, actual message, null, null) + if (message.contains('PlatformException')) { + // Try to extract the actual error message + final match = RegExp(r'PlatformException\([^,]+,\s*([^,]+(?:,[^,]+)?),').firstMatch(message); + if (match != null) { + message = match.group(1)?.trim() ?? message; + } else { + // Fallback: try simpler extraction + final simpleMatch = RegExp(r'PlatformException\([^,]+,\s*(.+?),\s*null').firstMatch(message); + if (simpleMatch != null) { + message = simpleMatch.group(1)?.trim() ?? message; + } + } + } + + // Clean up any remaining artifacts + message = message.replaceAll(RegExp(r',\s*null\s*,\s*null\)?$'), ''); + message = message.replaceAll(RegExp(r'^\s*,\s*'), ''); + + return message; + } +} + +class _ExtensionItem extends StatelessWidget { + final Extension extension; + final bool showDivider; + final VoidCallback onTap; + final ValueChanged onToggle; + + const _ExtensionItem({ + required this.extension, + required this.showDivider, + required this.onTap, + required this.onToggle, + }); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final hasError = extension.status == 'error'; + + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + InkWell( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Row( + children: [ + // Extension icon + Container( + width: 44, + height: 44, + decoration: BoxDecoration( + color: hasError + ? colorScheme.errorContainer + : colorScheme.primaryContainer, + borderRadius: BorderRadius.circular(12), + ), + child: extension.iconPath != null && extension.iconPath!.isNotEmpty + ? ClipRRect( + borderRadius: BorderRadius.circular(12), + child: Image.file( + File(extension.iconPath!), + width: 44, + height: 44, + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) => Icon( + hasError ? Icons.error_outline : Icons.extension, + color: hasError + ? colorScheme.error + : colorScheme.onPrimaryContainer, + ), + ), + ) + : Icon( + hasError ? Icons.error_outline : Icons.extension, + color: hasError + ? colorScheme.error + : colorScheme.onPrimaryContainer, + ), + ), + const SizedBox(width: 16), + // Extension info + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + extension.displayName, + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 2), + Text( + hasError + ? extension.errorMessage ?? 'Error loading extension' + : 'v${extension.version} by ${extension.author}', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: hasError + ? colorScheme.error + : colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + // Toggle switch + Switch( + value: extension.enabled, + onChanged: hasError ? null : onToggle, + ), + ], + ), + ), + ), + if (showDivider) + Divider( + height: 1, + thickness: 1, + indent: 76, + endIndent: 16, + color: colorScheme.outlineVariant.withValues(alpha: 0.3), + ), + ], + ); + } +} + +class _DownloadPriorityItem extends ConsumerWidget { + const _DownloadPriorityItem(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final extState = ref.watch(extensionProvider); + final colorScheme = Theme.of(context).colorScheme; + + // Check if any extension has download provider + final hasDownloadExtensions = extState.extensions + .any((e) => e.enabled && e.hasDownloadProvider); + + return InkWell( + onTap: hasDownloadExtensions + ? () => Navigator.push( + context, + MaterialPageRoute( + builder: (_) => const ProviderPriorityPage(), + ), + ) + : null, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Row( + children: [ + Icon( + Icons.download, + color: hasDownloadExtensions + ? colorScheme.onSurfaceVariant + : colorScheme.outline, + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Download Priority', + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + color: hasDownloadExtensions + ? null + : colorScheme.outline, + ), + ), + const SizedBox(height: 2), + Text( + hasDownloadExtensions + ? 'Set download service order' + : 'No extensions with download provider', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + Icon( + Icons.chevron_right, + color: hasDownloadExtensions + ? colorScheme.onSurfaceVariant + : colorScheme.outline, + ), + ], + ), + ), + ); + } +} + +class _MetadataPriorityItem extends ConsumerWidget { + const _MetadataPriorityItem(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final extState = ref.watch(extensionProvider); + final colorScheme = Theme.of(context).colorScheme; + + // Check if any extension has metadata provider + final hasMetadataExtensions = extState.extensions + .any((e) => e.enabled && e.hasMetadataProvider); + + return InkWell( + onTap: hasMetadataExtensions + ? () => Navigator.push( + context, + MaterialPageRoute( + builder: (_) => const MetadataProviderPriorityPage(), + ), + ) + : null, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Row( + children: [ + Icon( + Icons.search, + color: hasMetadataExtensions + ? colorScheme.onSurfaceVariant + : colorScheme.outline, + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Metadata Priority', + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + color: hasMetadataExtensions + ? null + : colorScheme.outline, + ), + ), + const SizedBox(height: 2), + Text( + hasMetadataExtensions + ? 'Set search & metadata source order' + : 'No extensions with metadata provider', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + Icon( + Icons.chevron_right, + color: hasMetadataExtensions + ? colorScheme.onSurfaceVariant + : colorScheme.outline, + ), + ], + ), + ), + ); + } +} + +class _SearchProviderSelector extends ConsumerWidget { + const _SearchProviderSelector(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final settings = ref.watch(settingsProvider); + final extState = ref.watch(extensionProvider); + final colorScheme = Theme.of(context).colorScheme; + + // Get extensions with custom search + final searchProviders = extState.extensions + .where((e) => e.enabled && e.hasCustomSearch) + .toList(); + + // Get current provider name + String currentProviderName = 'Default (Deezer/Spotify)'; + if (settings.searchProvider != null && settings.searchProvider!.isNotEmpty) { + final ext = searchProviders.where((e) => e.id == settings.searchProvider).firstOrNull; + currentProviderName = ext?.displayName ?? settings.searchProvider!; + } + + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + InkWell( + onTap: searchProviders.isEmpty + ? null + : () => _showSearchProviderPicker(context, ref, settings, searchProviders), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Row( + children: [ + Icon( + Icons.manage_search, + color: searchProviders.isEmpty + ? colorScheme.outline + : colorScheme.onSurfaceVariant, + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Search Provider', + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + color: searchProviders.isEmpty + ? colorScheme.outline + : null, + ), + ), + const SizedBox(height: 2), + Text( + searchProviders.isEmpty + ? 'No extensions with custom search' + : currentProviderName, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + Icon( + Icons.chevron_right, + color: searchProviders.isEmpty + ? colorScheme.outline + : colorScheme.onSurfaceVariant, + ), + ], + ), + ), + ), + ], + ); + } + + void _showSearchProviderPicker( + BuildContext context, + WidgetRef ref, + dynamic settings, + List searchProviders, + ) { + final colorScheme = Theme.of(context).colorScheme; + + showModalBottomSheet( + context: context, + backgroundColor: colorScheme.surfaceContainerHigh, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(28)), + ), + builder: (ctx) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(24, 24, 24, 8), + child: Text( + 'Search Provider', + style: Theme.of(context).textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(24, 0, 24, 16), + child: Text( + 'Choose which service to use for searching tracks', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ), + // Default option + ListTile( + leading: Icon(Icons.music_note, color: colorScheme.primary), + title: const Text('Default (Deezer/Spotify)'), + subtitle: const Text('Use built-in search'), + trailing: (settings.searchProvider == null || settings.searchProvider!.isEmpty) + ? Icon(Icons.check_circle, color: colorScheme.primary) + : Icon(Icons.circle_outlined, color: colorScheme.outline), + onTap: () { + ref.read(settingsProvider.notifier).setSearchProvider(null); + Navigator.pop(ctx); + }, + ), + // Extension options + ...searchProviders.map((ext) => ListTile( + leading: Icon(Icons.extension, color: colorScheme.secondary), + title: Text(ext.displayName), + subtitle: Text(ext.searchBehavior?.placeholder ?? 'Custom search'), + trailing: settings.searchProvider == ext.id + ? Icon(Icons.check_circle, color: colorScheme.primary) + : Icon(Icons.circle_outlined, color: colorScheme.outline), + onTap: () { + ref.read(settingsProvider.notifier).setSearchProvider(ext.id); + Navigator.pop(ctx); + }, + )), + const SizedBox(height: 16), + ], + ), + ), + ); + } +} diff --git a/lib/screens/settings/metadata_provider_priority_page.dart b/lib/screens/settings/metadata_provider_priority_page.dart new file mode 100644 index 00000000..d9327086 --- /dev/null +++ b/lib/screens/settings/metadata_provider_priority_page.dart @@ -0,0 +1,366 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:spotiflac_android/providers/extension_provider.dart'; + +class MetadataProviderPriorityPage extends ConsumerStatefulWidget { + const MetadataProviderPriorityPage({super.key}); + + @override + ConsumerState createState() => _MetadataProviderPriorityPageState(); +} + +class _MetadataProviderPriorityPageState extends ConsumerState { + late List _providers; + bool _hasChanges = false; + + @override + void initState() { + super.initState(); + _loadProviders(); + } + + void _loadProviders() { + final extState = ref.read(extensionProvider); + final allProviders = ref.read(extensionProvider.notifier).getAllMetadataProviders(); + + // Use saved priority if available, otherwise use default order + if (extState.metadataProviderPriority.isNotEmpty) { + _providers = List.from(extState.metadataProviderPriority); + // Add any new providers not in saved priority + for (final provider in allProviders) { + if (!_providers.contains(provider)) { + _providers.add(provider); + } + } + // Remove providers that no longer exist + _providers.removeWhere((p) => !allProviders.contains(p)); + } else { + _providers = allProviders; + } + } + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final topPadding = MediaQuery.of(context).padding.top; + + return PopScope( + canPop: !_hasChanges, + onPopInvokedWithResult: (didPop, result) async { + if (didPop) return; + final shouldPop = await _confirmDiscard(context); + if (shouldPop && context.mounted) { + Navigator.pop(context); + } + }, + child: Scaffold( + body: CustomScrollView( + slivers: [ + // App Bar + SliverAppBar( + expandedHeight: 120 + topPadding, + collapsedHeight: kToolbarHeight, + floating: false, + pinned: true, + backgroundColor: colorScheme.surface, + surfaceTintColor: Colors.transparent, + leading: IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () async { + if (_hasChanges) { + final shouldPop = await _confirmDiscard(context); + if (shouldPop && context.mounted) { + Navigator.pop(context); + } + } else { + Navigator.pop(context); + } + }, + ), + actions: [ + if (_hasChanges) + TextButton( + onPressed: _saveChanges, + child: const Text('Save'), + ), + ], + 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); + final leftPadding = 56 - (32 * expandRatio); + return FlexibleSpaceBar( + expandedTitleScale: 1.0, + titlePadding: EdgeInsets.only(left: leftPadding, bottom: 16), + title: Text( + 'Metadata Priority', + style: TextStyle( + fontSize: 20 + (8 * expandRatio), + fontWeight: FontWeight.bold, + color: colorScheme.onSurface, + ), + ), + ); + }, + ), + ), + + // Description + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.all(16), + child: Text( + 'Drag to reorder metadata providers. The app will try providers ' + 'from top to bottom when searching for tracks and fetching metadata.', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ), + ), + + // Provider list + SliverPadding( + padding: const EdgeInsets.symmetric(horizontal: 16), + sliver: SliverReorderableList( + itemCount: _providers.length, + itemBuilder: (context, index) { + final provider = _providers[index]; + return _MetadataProviderItem( + key: ValueKey(provider), + provider: provider, + index: index, + isFirst: index == 0, + isLast: index == _providers.length - 1, + ); + }, + onReorder: (oldIndex, newIndex) { + setState(() { + if (newIndex > oldIndex) { + newIndex -= 1; + } + final item = _providers.removeAt(oldIndex); + _providers.insert(newIndex, item); + _hasChanges = true; + }); + }, + ), + ), + + // Info section + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.all(16), + child: Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: colorScheme.tertiaryContainer.withValues(alpha: 0.3), + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + Icon(Icons.info_outline, size: 20, color: colorScheme.tertiary), + const SizedBox(width: 12), + Expanded( + child: Text( + 'Deezer has no rate limits and is recommended as primary. ' + 'Spotify may rate limit after many requests.', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onTertiaryContainer, + ), + ), + ), + ], + ), + ), + ), + ), + + const SliverToBoxAdapter(child: SizedBox(height: 32)), + ], + ), + ), + ); + } + + Future _confirmDiscard(BuildContext context) async { + final result = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Discard Changes?'), + content: const Text('You have unsaved changes. Do you want to discard them?'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () => Navigator.pop(context, true), + child: const Text('Discard'), + ), + ], + ), + ); + return result ?? false; + } + + Future _saveChanges() async { + await ref.read(extensionProvider.notifier).setMetadataProviderPriority(_providers); + setState(() { + _hasChanges = false; + }); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Metadata provider priority saved')), + ); + } + } +} + +class _MetadataProviderItem extends StatelessWidget { + final String provider; + final int index; + final bool isFirst; + final bool isLast; + + const _MetadataProviderItem({ + super.key, + required this.provider, + required this.index, + required this.isFirst, + required this.isLast, + }); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final isDark = Theme.of(context).brightness == Brightness.dark; + + final backgroundColor = isDark + ? Color.alphaBlend( + Colors.white.withValues(alpha: 0.05), + colorScheme.surface, + ) + : colorScheme.surfaceContainerHigh; + + final info = _getProviderInfo(provider); + + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Material( + color: backgroundColor, + borderRadius: BorderRadius.circular(16), + child: ReorderableDragStartListener( + index: index, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Row( + children: [ + // Priority number + Container( + width: 28, + height: 28, + decoration: BoxDecoration( + color: isFirst + ? colorScheme.primaryContainer + : colorScheme.surfaceContainerHighest, + shape: BoxShape.circle, + ), + child: Center( + child: Text( + '${index + 1}', + style: TextStyle( + fontWeight: FontWeight.bold, + color: isFirst + ? colorScheme.onPrimaryContainer + : colorScheme.onSurfaceVariant, + ), + ), + ), + ), + const SizedBox(width: 16), + // Provider icon + Icon( + info.icon, + color: info.isBuiltIn + ? colorScheme.primary + : colorScheme.secondary, + ), + const SizedBox(width: 12), + // Provider name + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + info.name, + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + fontWeight: FontWeight.w500, + ), + ), + Text( + info.description, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + // Drag handle + Icon( + Icons.drag_handle, + color: colorScheme.onSurfaceVariant, + ), + ], + ), + ), + ), + ), + ); + } + + _MetadataProviderInfo _getProviderInfo(String provider) { + switch (provider) { + case 'deezer': + return _MetadataProviderInfo( + name: 'Deezer', + icon: Icons.album, + description: 'No rate limits', + isBuiltIn: true, + ); + case 'spotify': + return _MetadataProviderInfo( + name: 'Spotify', + icon: Icons.music_note, + description: 'May rate limit', + isBuiltIn: true, + ); + default: + // Extension provider + return _MetadataProviderInfo( + name: provider, + icon: Icons.extension, + description: 'Extension', + isBuiltIn: false, + ); + } + } +} + +class _MetadataProviderInfo { + final String name; + final IconData icon; + final String description; + final bool isBuiltIn; + + _MetadataProviderInfo({ + required this.name, + required this.icon, + required this.description, + required this.isBuiltIn, + }); +} diff --git a/lib/screens/settings/options_settings_page.dart b/lib/screens/settings/options_settings_page.dart index f9822fb6..78f1f749 100644 --- a/lib/screens/settings/options_settings_page.dart +++ b/lib/screens/settings/options_settings_page.dart @@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:spotiflac_android/models/settings.dart'; import 'package:spotiflac_android/providers/download_queue_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 OptionsSettingsPage extends ConsumerWidget { @@ -11,6 +12,8 @@ class OptionsSettingsPage extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final settings = ref.watch(settingsProvider); + final extensionState = ref.watch(extensionProvider); + final hasExtensions = extensionState.extensions.isNotEmpty; final colorScheme = Theme.of(context).colorScheme; final topPadding = MediaQuery.of(context).padding.top; @@ -129,6 +132,18 @@ class OptionsSettingsPage extends ConsumerWidget { onChanged: (v) => ref.read(settingsProvider.notifier).setAutoFallback(v), ), + if (hasExtensions) + SettingsSwitchItem( + icon: Icons.extension, + title: 'Use Extension Providers', + subtitle: settings.useExtensionProviders + ? 'Extensions will be tried first' + : 'Using built-in providers only', + value: settings.useExtensionProviders, + onChanged: (v) => ref + .read(settingsProvider.notifier) + .setUseExtensionProviders(v), + ), SettingsSwitchItem( icon: Icons.lyrics, title: 'Embed Lyrics', @@ -345,11 +360,15 @@ class OptionsSettingsPage extends ConsumerWidget { ), border: OutlineInputBorder( borderRadius: BorderRadius.circular(16), - borderSide: BorderSide.none, + borderSide: BorderSide( + color: colorScheme.outlineVariant, + ), ), enabledBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(16), - borderSide: BorderSide.none, + borderSide: BorderSide( + color: colorScheme.outlineVariant, + ), ), focusedBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(16), @@ -380,11 +399,15 @@ class OptionsSettingsPage extends ConsumerWidget { ), border: OutlineInputBorder( borderRadius: BorderRadius.circular(16), - borderSide: BorderSide.none, + borderSide: BorderSide( + color: colorScheme.outlineVariant, + ), ), enabledBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(16), - borderSide: BorderSide.none, + borderSide: BorderSide( + color: colorScheme.outlineVariant, + ), ), focusedBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(16), @@ -745,7 +768,7 @@ class _ChannelChip extends StatelessWidget { } } -class _MetadataSourceSelector extends StatelessWidget { +class _MetadataSourceSelector extends ConsumerWidget { final String currentSource; final ValueChanged onChanged; const _MetadataSourceSelector({ @@ -754,8 +777,21 @@ class _MetadataSourceSelector extends StatelessWidget { }); @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { final colorScheme = Theme.of(context).colorScheme; + 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; + + String? extensionName; + if (hasExtensionSearch) { + final ext = extState.extensions.where((e) => e.id == settings.searchProvider).firstOrNull; + extensionName = ext?.displayName ?? settings.searchProvider; + } + return Padding( padding: const EdgeInsets.all(16), child: Column( @@ -769,9 +805,13 @@ class _MetadataSourceSelector extends StatelessWidget { ), const SizedBox(height: 4), Text( - 'Service used when searching by track name.', + hasExtensionSearch + ? 'Using extension: $extensionName' + : 'Service used when searching by track name.', style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: colorScheme.onSurfaceVariant, + color: hasExtensionSearch + ? colorScheme.primary + : colorScheme.onSurfaceVariant, ), ), const SizedBox(height: 16), @@ -780,18 +820,53 @@ class _MetadataSourceSelector extends StatelessWidget { _SourceChip( icon: Icons.graphic_eq, label: 'Deezer', - isSelected: currentSource == 'deezer', - onTap: () => onChanged('deezer'), + // Not selected if extension is active + isSelected: currentSource == 'deezer' && !hasExtensionSearch, + onTap: () { + // If extension was active, reset it to default + if (hasExtensionSearch) { + ref.read(settingsProvider.notifier).setSearchProvider(null); + } + onChanged('deezer'); + }, ), const SizedBox(width: 12), _SourceChip( icon: Icons.music_note, label: 'Spotify', - isSelected: currentSource == 'spotify', - onTap: () => onChanged('spotify'), + // Not selected if extension is active + isSelected: currentSource == 'spotify' && !hasExtensionSearch, + onTap: () { + // If extension was active, reset it to default + if (hasExtensionSearch) { + ref.read(settingsProvider.notifier).setSearchProvider(null); + } + onChanged('spotify'); + }, ), ], ), + if (hasExtensionSearch) ...[ + const SizedBox(height: 12), + Row( + children: [ + Icon( + Icons.info_outline, + size: 16, + color: colorScheme.onSurfaceVariant, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + 'Tap Deezer or Spotify to switch back from extension', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ), + ], + ), + ], ], ), ); @@ -802,13 +877,13 @@ class _SourceChip extends StatelessWidget { final IconData icon; final String label; final bool isSelected; - final VoidCallback onTap; + final VoidCallback? onTap; const _SourceChip({ required this.icon, required this.label, required this.isSelected, - required this.onTap, + this.onTap, }); @override diff --git a/lib/screens/settings/provider_priority_page.dart b/lib/screens/settings/provider_priority_page.dart new file mode 100644 index 00000000..34170337 --- /dev/null +++ b/lib/screens/settings/provider_priority_page.dart @@ -0,0 +1,369 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:spotiflac_android/providers/extension_provider.dart'; + +class ProviderPriorityPage extends ConsumerStatefulWidget { + const ProviderPriorityPage({super.key}); + + @override + ConsumerState createState() => _ProviderPriorityPageState(); +} + +class _ProviderPriorityPageState extends ConsumerState { + late List _providers; + bool _hasChanges = false; + + @override + void initState() { + super.initState(); + _loadProviders(); + } + + void _loadProviders() { + final extState = ref.read(extensionProvider); + final allProviders = ref.read(extensionProvider.notifier).getAllDownloadProviders(); + + // Use saved priority if available, otherwise use default order + if (extState.providerPriority.isNotEmpty) { + // Start with saved priority + _providers = List.from(extState.providerPriority); + // Add any new providers not in saved priority + for (final provider in allProviders) { + if (!_providers.contains(provider)) { + _providers.add(provider); + } + } + // Remove providers that no longer exist + _providers.removeWhere((p) => !allProviders.contains(p)); + } else { + _providers = allProviders; + } + } + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final topPadding = MediaQuery.of(context).padding.top; + + return PopScope( + canPop: !_hasChanges, + onPopInvokedWithResult: (didPop, result) async { + if (didPop) return; + final shouldPop = await _confirmDiscard(context); + if (shouldPop && context.mounted) { + Navigator.pop(context); + } + }, + child: Scaffold( + body: CustomScrollView( + slivers: [ + // App Bar + SliverAppBar( + expandedHeight: 120 + topPadding, + collapsedHeight: kToolbarHeight, + floating: false, + pinned: true, + backgroundColor: colorScheme.surface, + surfaceTintColor: Colors.transparent, + leading: IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () async { + if (_hasChanges) { + final shouldPop = await _confirmDiscard(context); + if (shouldPop && context.mounted) { + Navigator.pop(context); + } + } else { + Navigator.pop(context); + } + }, + ), + actions: [ + if (_hasChanges) + TextButton( + onPressed: _saveChanges, + child: const Text('Save'), + ), + ], + 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); + final leftPadding = 56 - (32 * expandRatio); + return FlexibleSpaceBar( + expandedTitleScale: 1.0, + titlePadding: EdgeInsets.only(left: leftPadding, bottom: 16), + title: Text( + 'Provider Priority', + style: TextStyle( + fontSize: 20 + (8 * expandRatio), + fontWeight: FontWeight.bold, + color: colorScheme.onSurface, + ), + ), + ); + }, + ), + ), + + // Description + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.all(16), + child: Text( + 'Drag to reorder download providers. The app will try providers ' + 'from top to bottom when downloading tracks.', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ), + ), + + // Provider list + SliverPadding( + padding: const EdgeInsets.symmetric(horizontal: 16), + sliver: SliverReorderableList( + itemCount: _providers.length, + itemBuilder: (context, index) { + final provider = _providers[index]; + return _ProviderItem( + key: ValueKey(provider), + provider: provider, + index: index, + isFirst: index == 0, + isLast: index == _providers.length - 1, + ); + }, + onReorder: (oldIndex, newIndex) { + setState(() { + if (newIndex > oldIndex) { + newIndex -= 1; + } + final item = _providers.removeAt(oldIndex); + _providers.insert(newIndex, item); + _hasChanges = true; + }); + }, + ), + ), + + // Info section + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.all(16), + child: Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: colorScheme.tertiaryContainer.withValues(alpha: 0.3), + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + Icon(Icons.info_outline, size: 20, color: colorScheme.tertiary), + const SizedBox(width: 12), + Expanded( + child: Text( + 'If a track is not available on the first provider, ' + 'the app will automatically try the next one.', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onTertiaryContainer, + ), + ), + ), + ], + ), + ), + ), + ), + + const SliverToBoxAdapter(child: SizedBox(height: 32)), + ], + ), + ), + ); + } + + Future _confirmDiscard(BuildContext context) async { + final result = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Discard Changes?'), + content: const Text('You have unsaved changes. Do you want to discard them?'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () => Navigator.pop(context, true), + child: const Text('Discard'), + ), + ], + ), + ); + return result ?? false; + } + + Future _saveChanges() async { + await ref.read(extensionProvider.notifier).setProviderPriority(_providers); + setState(() { + _hasChanges = false; + }); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Provider priority saved')), + ); + } + } +} + +class _ProviderItem extends StatelessWidget { + final String provider; + final int index; + final bool isFirst; + final bool isLast; + + const _ProviderItem({ + super.key, + required this.provider, + required this.index, + required this.isFirst, + required this.isLast, + }); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final isDark = Theme.of(context).brightness == Brightness.dark; + + final backgroundColor = isDark + ? Color.alphaBlend( + Colors.white.withValues(alpha: 0.05), + colorScheme.surface, + ) + : colorScheme.surfaceContainerHigh; + + // Get provider info + final info = _getProviderInfo(provider); + + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Material( + color: backgroundColor, + borderRadius: BorderRadius.circular(16), + child: ReorderableDragStartListener( + index: index, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Row( + children: [ + // Priority number + Container( + width: 28, + height: 28, + decoration: BoxDecoration( + color: isFirst + ? colorScheme.primaryContainer + : colorScheme.surfaceContainerHighest, + shape: BoxShape.circle, + ), + child: Center( + child: Text( + '${index + 1}', + style: TextStyle( + fontWeight: FontWeight.bold, + color: isFirst + ? colorScheme.onPrimaryContainer + : colorScheme.onSurfaceVariant, + ), + ), + ), + ), + const SizedBox(width: 16), + // Provider icon + Icon( + info.icon, + color: info.isBuiltIn + ? colorScheme.primary + : colorScheme.secondary, + ), + const SizedBox(width: 12), + // Provider name + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + info.name, + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + fontWeight: FontWeight.w500, + ), + ), + Text( + info.isBuiltIn ? 'Built-in' : 'Extension', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + // Drag handle + Icon( + Icons.drag_handle, + color: colorScheme.onSurfaceVariant, + ), + ], + ), + ), + ), + ), + ); + } + + _ProviderInfo _getProviderInfo(String provider) { + switch (provider) { + case 'tidal': + return _ProviderInfo( + name: 'Tidal', + icon: Icons.music_note, + isBuiltIn: true, + ); + case 'qobuz': + return _ProviderInfo( + name: 'Qobuz', + icon: Icons.album, + isBuiltIn: true, + ); + case 'amazon': + return _ProviderInfo( + name: 'Amazon Music', + icon: Icons.shopping_bag, + isBuiltIn: true, + ); + default: + // Extension provider + return _ProviderInfo( + name: provider, + icon: Icons.extension, + isBuiltIn: false, + ); + } + } +} + +class _ProviderInfo { + final String name; + final IconData icon; + final bool isBuiltIn; + + _ProviderInfo({ + required this.name, + required this.icon, + required this.isBuiltIn, + }); +} diff --git a/lib/screens/settings/settings_tab.dart b/lib/screens/settings/settings_tab.dart index 9c30f67d..1861c3d2 100644 --- a/lib/screens/settings/settings_tab.dart +++ b/lib/screens/settings/settings_tab.dart @@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:spotiflac_android/constants/app_info.dart'; import 'package:spotiflac_android/screens/settings/appearance_settings_page.dart'; import 'package:spotiflac_android/screens/settings/download_settings_page.dart'; +import 'package:spotiflac_android/screens/settings/extensions_page.dart'; import 'package:spotiflac_android/screens/settings/options_settings_page.dart'; import 'package:spotiflac_android/screens/settings/about_page.dart'; import 'package:spotiflac_android/screens/settings/log_screen.dart'; @@ -31,8 +32,11 @@ class SettingsTab extends ConsumerWidget { builder: (context, constraints) { final maxHeight = 120 + topPadding; final minHeight = kToolbarHeight + topPadding; - final expandRatio = ((constraints.maxHeight - minHeight) / (maxHeight - minHeight)).clamp(0.0, 1.0); - + 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), @@ -58,7 +62,8 @@ class SettingsTab extends ConsumerWidget { icon: Icons.palette_outlined, title: 'Appearance', subtitle: 'Theme, colors, display', - onTap: () => _navigateTo(context, const AppearanceSettingsPage()), + onTap: () => + _navigateTo(context, const AppearanceSettingsPage()), ), SettingsItem( icon: Icons.download_outlined, @@ -71,6 +76,12 @@ class SettingsTab extends ConsumerWidget { title: 'Options', subtitle: 'Fallback, lyrics, cover art, updates', onTap: () => _navigateTo(context, const OptionsSettingsPage()), + ), + SettingsItem( + icon: Icons.extension_outlined, + title: 'Extensions', + subtitle: 'Manage download providers', + onTap: () => _navigateTo(context, const ExtensionsPage()), showDivider: false, ), ], @@ -97,7 +108,7 @@ class SettingsTab extends ConsumerWidget { ], ), ), - + // Fill remaining space const SliverFillRemaining(hasScrollBody: false, child: SizedBox()), ], diff --git a/lib/services/platform_bridge.dart b/lib/services/platform_bridge.dart index fb4fb284..a5f58e38 100644 --- a/lib/services/platform_bridge.dart +++ b/lib/services/platform_bridge.dart @@ -439,4 +439,332 @@ class PlatformBridge { static Future setGoLoggingEnabled(bool enabled) async { await _channel.invokeMethod('setLoggingEnabled', {'enabled': enabled}); } + + // ==================== EXTENSION SYSTEM ==================== + + /// Initialize the extension system + static Future initExtensionSystem(String extensionsDir, String dataDir) async { + _log.d('initExtensionSystem: $extensionsDir, $dataDir'); + await _channel.invokeMethod('initExtensionSystem', { + 'extensions_dir': extensionsDir, + 'data_dir': dataDir, + }); + } + + /// Load all extensions from directory + static Future> loadExtensionsFromDir(String dirPath) async { + _log.d('loadExtensionsFromDir: $dirPath'); + final result = await _channel.invokeMethod('loadExtensionsFromDir', { + 'dir_path': dirPath, + }); + return jsonDecode(result as String) as Map; + } + + /// Load a single extension from file + static Future> loadExtensionFromPath(String filePath) async { + _log.d('loadExtensionFromPath: $filePath'); + final result = await _channel.invokeMethod('loadExtensionFromPath', { + 'file_path': filePath, + }); + return jsonDecode(result as String) as Map; + } + + /// Unload an extension + static Future unloadExtension(String extensionId) async { + _log.d('unloadExtension: $extensionId'); + await _channel.invokeMethod('unloadExtension', { + 'extension_id': extensionId, + }); + } + + /// Remove an extension completely (unload + delete files) + static Future removeExtension(String extensionId) async { + _log.d('removeExtension: $extensionId'); + await _channel.invokeMethod('removeExtension', { + 'extension_id': extensionId, + }); + } + + /// Upgrade an existing extension from a new package file + static Future> upgradeExtension(String filePath) async { + _log.d('upgradeExtension: $filePath'); + final result = await _channel.invokeMethod('upgradeExtension', { + 'file_path': filePath, + }); + return jsonDecode(result as String) as Map; + } + + /// Check if a package file is an upgrade for an existing extension + static Future> checkExtensionUpgrade(String filePath) async { + _log.d('checkExtensionUpgrade: $filePath'); + final result = await _channel.invokeMethod('checkExtensionUpgrade', { + 'file_path': filePath, + }); + return jsonDecode(result as String) as Map; + } + + /// Get all installed extensions + static Future>> getInstalledExtensions() async { + final result = await _channel.invokeMethod('getInstalledExtensions'); + final list = jsonDecode(result as String) as List; + return list.map((e) => e as Map).toList(); + } + + /// Enable or disable an extension + static Future setExtensionEnabled(String extensionId, bool enabled) async { + _log.d('setExtensionEnabled: $extensionId = $enabled'); + await _channel.invokeMethod('setExtensionEnabled', { + 'extension_id': extensionId, + 'enabled': enabled, + }); + } + + /// Set provider priority order + static Future setProviderPriority(List providerIds) async { + _log.d('setProviderPriority: $providerIds'); + await _channel.invokeMethod('setProviderPriority', { + 'priority': jsonEncode(providerIds), + }); + } + + /// Get provider priority order + static Future> getProviderPriority() async { + final result = await _channel.invokeMethod('getProviderPriority'); + final list = jsonDecode(result as String) as List; + return list.map((e) => e as String).toList(); + } + + /// Set metadata provider priority order + static Future setMetadataProviderPriority(List providerIds) async { + _log.d('setMetadataProviderPriority: $providerIds'); + await _channel.invokeMethod('setMetadataProviderPriority', { + 'priority': jsonEncode(providerIds), + }); + } + + /// Get metadata provider priority order + static Future> getMetadataProviderPriority() async { + final result = await _channel.invokeMethod('getMetadataProviderPriority'); + final list = jsonDecode(result as String) as List; + return list.map((e) => e as String).toList(); + } + + /// Get extension settings + static Future> getExtensionSettings(String extensionId) async { + final result = await _channel.invokeMethod('getExtensionSettings', { + 'extension_id': extensionId, + }); + return jsonDecode(result as String) as Map; + } + + /// Set extension settings + static Future setExtensionSettings(String extensionId, Map settings) async { + _log.d('setExtensionSettings: $extensionId'); + await _channel.invokeMethod('setExtensionSettings', { + 'extension_id': extensionId, + 'settings': jsonEncode(settings), + }); + } + + /// Search tracks using extension providers + static Future>> searchTracksWithExtensions(String query, {int limit = 20}) async { + _log.d('searchTracksWithExtensions: "$query"'); + final result = await _channel.invokeMethod('searchTracksWithExtensions', { + 'query': query, + 'limit': limit, + }); + final list = jsonDecode(result as String) as List; + return list.map((e) => e as Map).toList(); + } + + /// Download with extension providers (includes fallback) + static Future> downloadWithExtensions({ + required String isrc, + required String spotifyId, + required String trackName, + required String artistName, + required String albumName, + String? albumArtist, + String? coverUrl, + required String outputDir, + required String filenameFormat, + String quality = 'LOSSLESS', + bool embedLyrics = true, + bool embedMaxQualityCover = true, + int trackNumber = 1, + int discNumber = 1, + int totalTracks = 1, + String? releaseDate, + String? itemId, + int durationMs = 0, + String? source, // Extension ID that provided this track (prioritize this extension) + }) async { + _log.i('downloadWithExtensions: "$trackName" by $artistName${source != null ? ' (source: $source)' : ''}'); + final request = jsonEncode({ + 'isrc': isrc, + 'spotify_id': spotifyId, + 'track_name': trackName, + 'artist_name': artistName, + 'album_name': albumName, + 'album_artist': albumArtist ?? artistName, + 'cover_url': coverUrl, + 'output_dir': outputDir, + 'filename_format': filenameFormat, + 'quality': quality, + 'embed_lyrics': embedLyrics, + 'embed_max_quality_cover': embedMaxQualityCover, + 'track_number': trackNumber, + 'disc_number': discNumber, + 'total_tracks': totalTracks, + 'release_date': releaseDate ?? '', + 'item_id': itemId ?? '', + 'duration_ms': durationMs, + 'source': source ?? '', // Extension ID that provided this track + }); + + final result = await _channel.invokeMethod('downloadWithExtensions', request); + return jsonDecode(result as String) as Map; + } + + /// Cleanup all extensions (call on app close) + static Future cleanupExtensions() async { + _log.d('cleanupExtensions'); + await _channel.invokeMethod('cleanupExtensions'); + } + + // ==================== EXTENSION AUTH API ==================== + + /// Get pending auth request for an extension (if any) + static Future?> getExtensionPendingAuth(String extensionId) async { + final result = await _channel.invokeMethod('getExtensionPendingAuth', { + 'extension_id': extensionId, + }); + if (result == null) return null; + return jsonDecode(result as String) as Map; + } + + /// Set auth code for an extension (after OAuth callback) + static Future setExtensionAuthCode(String extensionId, String authCode) async { + _log.d('setExtensionAuthCode: $extensionId'); + await _channel.invokeMethod('setExtensionAuthCode', { + 'extension_id': extensionId, + 'auth_code': authCode, + }); + } + + /// Set tokens for an extension (after token exchange) + static Future setExtensionTokens( + String extensionId, { + required String accessToken, + String? refreshToken, + int? expiresIn, + }) async { + _log.d('setExtensionTokens: $extensionId'); + await _channel.invokeMethod('setExtensionTokens', { + 'extension_id': extensionId, + 'access_token': accessToken, + 'refresh_token': refreshToken ?? '', + 'expires_in': expiresIn ?? 0, + }); + } + + /// Clear pending auth request for an extension + static Future clearExtensionPendingAuth(String extensionId) async { + await _channel.invokeMethod('clearExtensionPendingAuth', { + 'extension_id': extensionId, + }); + } + + /// Check if extension is authenticated + static Future isExtensionAuthenticated(String extensionId) async { + final result = await _channel.invokeMethod('isExtensionAuthenticated', { + 'extension_id': extensionId, + }); + return result as bool; + } + + /// Get all pending auth requests (for polling) + static Future>> getAllPendingAuthRequests() async { + final result = await _channel.invokeMethod('getAllPendingAuthRequests'); + final list = jsonDecode(result as String) as List; + return list.map((e) => e as Map).toList(); + } + + // ==================== EXTENSION FFMPEG API ==================== + + /// Get pending FFmpeg command for execution + static Future?> getPendingFFmpegCommand(String commandId) async { + final result = await _channel.invokeMethod('getPendingFFmpegCommand', { + 'command_id': commandId, + }); + if (result == null) return null; + return jsonDecode(result as String) as Map; + } + + /// Set FFmpeg command result + static Future setFFmpegCommandResult( + String commandId, { + required bool success, + String output = '', + String error = '', + }) async { + await _channel.invokeMethod('setFFmpegCommandResult', { + 'command_id': commandId, + 'success': success, + 'output': output, + 'error': error, + }); + } + + /// Get all pending FFmpeg commands + static Future>> getAllPendingFFmpegCommands() async { + final result = await _channel.invokeMethod('getAllPendingFFmpegCommands'); + final list = jsonDecode(result as String) as List; + return list.map((e) => e as Map).toList(); + } + + // ==================== EXTENSION CUSTOM SEARCH ==================== + + /// Perform custom search using an extension + static Future>> customSearchWithExtension( + String extensionId, + String query, { + Map? options, + }) async { + final result = await _channel.invokeMethod('customSearchWithExtension', { + 'extension_id': extensionId, + 'query': query, + 'options': options != null ? jsonEncode(options) : '', + }); + final list = jsonDecode(result as String) as List; + return list.map((e) => e as Map).toList(); + } + + /// Get all extensions that provide custom search + static Future>> getSearchProviders() async { + final result = await _channel.invokeMethod('getSearchProviders'); + final list = jsonDecode(result as String) as List; + return list.map((e) => e as Map).toList(); + } + + // ==================== EXTENSION POST-PROCESSING ==================== + + /// Run post-processing hooks on a file + static Future> runPostProcessing( + String filePath, { + Map? metadata, + }) async { + final result = await _channel.invokeMethod('runPostProcessing', { + 'file_path': filePath, + 'metadata': metadata != null ? jsonEncode(metadata) : '', + }); + return jsonDecode(result as String) as Map; + } + + /// Get all extensions that provide post-processing + static Future>> getPostProcessingProviders() async { + final result = await _channel.invokeMethod('getPostProcessingProviders'); + final list = jsonDecode(result as String) as List; + return list.map((e) => e as Map).toList(); + } } diff --git a/lib/widgets/download_service_picker.dart b/lib/widgets/download_service_picker.dart new file mode 100644 index 00000000..a5f9a38b --- /dev/null +++ b/lib/widgets/download_service_picker.dart @@ -0,0 +1,483 @@ +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/settings_provider.dart'; + +/// Built-in service info with quality options +class BuiltInService { + final String id; + final String label; + final List qualityOptions; + + const BuiltInService({ + required this.id, + required this.label, + required this.qualityOptions, + }); +} + +/// Default quality options for built-in services (Tidal, Qobuz, Amazon) +const _builtInServices = [ + BuiltInService( + id: 'tidal', + label: 'Tidal', + qualityOptions: [ + QualityOption(id: 'LOSSLESS', label: 'FLAC Lossless', description: '16-bit / 44.1kHz'), + QualityOption(id: 'HI_RES', label: 'Hi-Res FLAC', description: '24-bit / up to 96kHz'), + QualityOption(id: 'HI_RES_LOSSLESS', label: 'Hi-Res FLAC Max', description: '24-bit / up to 192kHz'), + ], + ), + BuiltInService( + id: 'qobuz', + label: 'Qobuz', + qualityOptions: [ + QualityOption(id: 'LOSSLESS', label: 'FLAC Lossless', description: '16-bit / 44.1kHz'), + QualityOption(id: 'HI_RES', label: 'Hi-Res FLAC', description: '24-bit / up to 96kHz'), + QualityOption(id: 'HI_RES_LOSSLESS', label: 'Hi-Res FLAC Max', description: '24-bit / up to 192kHz'), + ], + ), + BuiltInService( + id: 'amazon', + label: 'Amazon', + qualityOptions: [ + QualityOption(id: 'LOSSLESS', label: 'FLAC Lossless', description: '16-bit / 44.1kHz'), + QualityOption(id: 'HI_RES', label: 'Hi-Res FLAC', description: '24-bit / up to 96kHz'), + QualityOption(id: 'HI_RES_LOSSLESS', label: 'Hi-Res FLAC Max', description: '24-bit / up to 192kHz'), + ], + ), +]; + +/// A reusable widget for selecting download service (built-in + extensions) +class DownloadServicePicker extends ConsumerStatefulWidget { + final String? trackName; + final String? artistName; + final String? coverUrl; + final void Function(String quality, String service) onSelect; + + const DownloadServicePicker({ + super.key, + this.trackName, + this.artistName, + this.coverUrl, + required this.onSelect, + }); + + @override + ConsumerState createState() => _DownloadServicePickerState(); + + /// Show the download service picker as a modal bottom sheet + static void show( + BuildContext context, { + String? trackName, + String? artistName, + String? coverUrl, + required void Function(String quality, String service) onSelect, + }) { + final colorScheme = Theme.of(context).colorScheme; + + showModalBottomSheet( + context: context, + backgroundColor: colorScheme.surfaceContainerHigh, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(28)), + ), + isScrollControlled: true, + builder: (context) => DownloadServicePicker( + trackName: trackName, + artistName: artistName, + coverUrl: coverUrl, + onSelect: onSelect, + ), + ); + } +} + +class _DownloadServicePickerState extends ConsumerState { + late String _selectedService; + + @override + void initState() { + super.initState(); + _selectedService = ref.read(settingsProvider).defaultService; + } + + /// Get quality options for the selected service + List _getQualityOptions() { + // Check if it's a built-in service + final builtIn = _builtInServices.where((s) => s.id == _selectedService).firstOrNull; + if (builtIn != null) { + return builtIn.qualityOptions; + } + + // Check if it's an extension + final extensionState = ref.read(extensionProvider); + final ext = extensionState.extensions.where((e) => e.id == _selectedService).firstOrNull; + if (ext != null && ext.qualityOptions.isNotEmpty) { + return ext.qualityOptions; + } + + // Default quality options if extension doesn't specify any + return const [ + QualityOption(id: 'DEFAULT', label: 'Default Quality', description: 'Best available'), + ]; + } + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final extensionState = ref.watch(extensionProvider); + + // Get enabled download provider extensions + final downloadExtensions = extensionState.extensions + .where((ext) => ext.enabled && ext.hasDownloadProvider) + .toList(); + + final qualityOptions = _getQualityOptions(); + + return SafeArea( + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Track info header (if provided) + if (widget.trackName != null) ...[ + _TrackInfoHeader( + trackName: widget.trackName!, + artistName: widget.artistName, + coverUrl: widget.coverUrl, + ), + Divider(height: 1, color: colorScheme.outlineVariant.withValues(alpha: 0.5)), + ] else ...[ + const SizedBox(height: 8), + Center( + child: Container( + width: 40, + height: 4, + decoration: BoxDecoration( + color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4), + borderRadius: BorderRadius.circular(2), + ), + ), + ), + ], + + // Service selector section + Padding( + padding: const EdgeInsets.fromLTRB(24, 16, 24, 8), + child: Text( + 'Download From', + style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold), + ), + ), + + // Built-in services + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Wrap( + spacing: 8, + runSpacing: 8, + children: [ + // Built-in services + for (final service in _builtInServices) + _ServiceChip( + label: service.label, + isSelected: _selectedService == service.id, + onTap: () => setState(() => _selectedService = service.id), + ), + // Extension services + for (final ext in downloadExtensions) + _ServiceChip( + label: ext.displayName, + isSelected: _selectedService == ext.id, + onTap: () => setState(() => _selectedService = ext.id), + iconPath: ext.iconPath, + ), + ], + ), + ), + + // Quality selector section + Padding( + padding: const EdgeInsets.fromLTRB(24, 16, 24, 8), + child: Text( + 'Select Quality', + style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold), + ), + ), + + // Disclaimer for built-in services + if (_builtInServices.any((s) => s.id == _selectedService)) + Padding( + padding: const EdgeInsets.fromLTRB(24, 0, 24, 12), + child: Text( + 'Actual quality depends on track availability. Hi-Res may not be available for all tracks.', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + fontStyle: FontStyle.italic, + ), + ), + ), + + // Quality options + for (final quality in qualityOptions) + _QualityOption( + title: quality.label, + subtitle: quality.description ?? '', + icon: _getQualityIcon(quality.id), + onTap: () { + Navigator.pop(context); + widget.onSelect(quality.id, _selectedService); + }, + ), + + const SizedBox(height: 16), + ], + ), + ), + ); + } + + IconData _getQualityIcon(String qualityId) { + switch (qualityId.toUpperCase()) { + case 'HI_RES_LOSSLESS': + return Icons.four_k; + case 'HI_RES': + return Icons.high_quality; + case 'LOSSLESS': + return Icons.music_note; + case 'MP3_320': + case 'MP3': + return Icons.audiotrack; + case 'OPUS': + case 'OPUS_128': + return Icons.graphic_eq; + default: + return Icons.music_note; + } + } +} + + +class _QualityOption extends StatelessWidget { + final String title; + final String subtitle; + final IconData icon; + final VoidCallback onTap; + + const _QualityOption({ + required this.title, + required this.subtitle, + required this.icon, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + return ListTile( + contentPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 4), + leading: Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: colorScheme.primaryContainer, + borderRadius: BorderRadius.circular(12), + ), + child: Icon(icon, color: colorScheme.onPrimaryContainer, size: 20), + ), + title: Text(title, style: const TextStyle(fontWeight: FontWeight.w500)), + subtitle: subtitle.isNotEmpty + ? Text(subtitle, style: TextStyle(color: colorScheme.onSurfaceVariant)) + : null, + onTap: onTap, + ); + } +} + +class _ServiceChip extends StatelessWidget { + final String label; + final bool isSelected; + final VoidCallback onTap; + final String? iconPath; + + const _ServiceChip({ + required this.label, + required this.isSelected, + required this.onTap, + this.iconPath, + }); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + return GestureDetector( + onTap: onTap, + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + decoration: BoxDecoration( + color: isSelected ? colorScheme.primaryContainer : colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(12), + border: isSelected ? null : Border.all(color: colorScheme.outlineVariant.withValues(alpha: 0.5)), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (iconPath != null) ...[ + ClipRRect( + borderRadius: BorderRadius.circular(4), + child: Image.file( + File(iconPath!), + width: 18, + height: 18, + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) => Icon( + Icons.extension, + size: 18, + color: isSelected ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant, + ), + ), + ), + const SizedBox(width: 6), + ], + Text( + label, + style: TextStyle( + fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal, + color: isSelected ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + ); + } +} + +class _TrackInfoHeader extends StatefulWidget { + final String trackName; + final String? artistName; + final String? coverUrl; + + const _TrackInfoHeader({ + required this.trackName, + this.artistName, + this.coverUrl, + }); + + @override + State<_TrackInfoHeader> createState() => _TrackInfoHeaderState(); +} + +class _TrackInfoHeaderState extends State<_TrackInfoHeader> { + bool _expanded = false; + bool _isOverflowing = false; + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + return Material( + color: Colors.transparent, + child: InkWell( + onTap: _isOverflowing ? () => setState(() => _expanded = !_expanded) : null, + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(28), + topRight: Radius.circular(28), + ), + child: Column( + children: [ + const SizedBox(height: 8), + Container( + width: 40, + height: 4, + decoration: BoxDecoration( + color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4), + borderRadius: BorderRadius.circular(2), + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 12), + child: Row( + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(8), + child: widget.coverUrl != null + ? Image.network( + widget.coverUrl!, + width: 56, + height: 56, + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) => Container( + width: 56, + height: 56, + color: colorScheme.surfaceContainerHighest, + child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant), + ), + ) + : Container( + width: 56, + height: 56, + color: colorScheme.surfaceContainerHighest, + child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant), + ), + ), + const SizedBox(width: 12), + Expanded( + child: LayoutBuilder( + builder: (context, constraints) { + final titleStyle = Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600); + final titleSpan = TextSpan(text: widget.trackName, style: titleStyle); + final titlePainter = TextPainter( + text: titleSpan, + maxLines: 1, + textDirection: TextDirection.ltr, + )..layout(maxWidth: constraints.maxWidth); + final titleOverflows = titlePainter.didExceedMaxLines; + + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted && _isOverflowing != titleOverflows) { + setState(() => _isOverflowing = titleOverflows); + } + }); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + widget.trackName, + style: titleStyle, + maxLines: _expanded ? 10 : 1, + overflow: _expanded ? TextOverflow.visible : TextOverflow.ellipsis, + ), + if (widget.artistName != null) ...[ + const SizedBox(height: 2), + Text( + widget.artistName!, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + maxLines: _expanded ? 3 : 1, + overflow: _expanded ? TextOverflow.visible : TextOverflow.ellipsis, + ), + ], + ], + ); + }, + ), + ), + if (_isOverflowing || _expanded) + Icon( + _expanded ? Icons.expand_less : Icons.expand_more, + color: colorScheme.onSurfaceVariant, + size: 20, + ), + ], + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/widgets/settings_group.dart b/lib/widgets/settings_group.dart index 31706889..f178f407 100644 --- a/lib/widgets/settings_group.dart +++ b/lib/widgets/settings_group.dart @@ -133,6 +133,7 @@ class SettingsSwitchItem extends StatelessWidget { final bool value; final ValueChanged? onChanged; final bool showDivider; + final bool enabled; const SettingsSwitchItem({ super.key, @@ -142,53 +143,60 @@ class SettingsSwitchItem extends StatelessWidget { required this.value, this.onChanged, this.showDivider = true, + this.enabled = true, }); @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; + final isDisabled = !enabled || onChanged == null; return Column( mainAxisSize: MainAxisSize.min, children: [ - InkWell( - onTap: onChanged != null ? () => onChanged!(!value) : null, - splashColor: colorScheme.primary.withValues(alpha: 0.12), - highlightColor: colorScheme.primary.withValues(alpha: 0.08), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), - child: Row( - children: [ - if (icon != null) ...[ - Icon(icon, color: colorScheme.onSurfaceVariant, size: 24), - const SizedBox(width: 16), - ], - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - title, - style: Theme.of(context).textTheme.bodyLarge, - ), - if (subtitle != null) ...[ - const SizedBox(height: 2), + Opacity( + opacity: isDisabled ? 0.5 : 1.0, + child: InkWell( + onTap: isDisabled ? null : () => onChanged!(!value), + splashColor: colorScheme.primary.withValues(alpha: 0.12), + highlightColor: colorScheme.primary.withValues(alpha: 0.08), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), + child: Row( + children: [ + if (icon != null) ...[ + Icon(icon, color: isDisabled ? colorScheme.outline : colorScheme.onSurfaceVariant, size: 24), + const SizedBox(width: 16), + ], + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ Text( - subtitle!, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: colorScheme.onSurfaceVariant, + title, + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + color: isDisabled ? colorScheme.outline : null, ), ), + if (subtitle != null) ...[ + const SizedBox(height: 2), + Text( + subtitle!, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: isDisabled ? colorScheme.outline : colorScheme.onSurfaceVariant, + ), + ), + ], ], - ], + ), ), - ), - const SizedBox(width: 8), - Switch( - value: value, - onChanged: onChanged, - ), - ], + const SizedBox(width: 8), + Switch( + value: value, + onChanged: isDisabled ? null : onChanged, + ), + ], + ), ), ), ), diff --git a/pubspec.yaml b/pubspec.yaml index 3eec9e85..1879f639 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: spotiflac_android description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music publish_to: "none" -version: 2.2.7+49 +version: 3.0.0-alpha.1+50 environment: sdk: ^3.10.0 diff --git a/pubspec_ios.yaml b/pubspec_ios.yaml index 8e383a2e..22b3ab62 100644 --- a/pubspec_ios.yaml +++ b/pubspec_ios.yaml @@ -1,7 +1,7 @@ name: spotiflac_android description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music publish_to: "none" -version: 2.2.7+49 +version: 3.0.0-alpha.1+50 environment: sdk: ^3.10.0 From 3dbd131e49a18c33e97fc1d6d12493b35910938b Mon Sep 17 00:00:00 2001 From: zarzet Date: Mon, 12 Jan 2026 01:02:16 +0700 Subject: [PATCH 02/35] fix: iOS extension auth function names (use ByID suffix) --- .../extension_feature_request.yml | 117 ++++++++++++++++++ ios/Runner/AppDelegate.swift | 10 +- 2 files changed, 121 insertions(+), 6 deletions(-) create mode 100644 .github/ISSUE_TEMPLATE/extension_feature_request.yml diff --git a/.github/ISSUE_TEMPLATE/extension_feature_request.yml b/.github/ISSUE_TEMPLATE/extension_feature_request.yml new file mode 100644 index 00000000..8748a780 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/extension_feature_request.yml @@ -0,0 +1,117 @@ +name: Extension API Feature Request +description: Request new API features or capabilities for extension development +title: "[Extension API]: " +labels: ["enhancement", "extension-api"] +body: + - type: markdown + attributes: + value: | + Thanks for helping improve the SpotiFLAC Extension API! + This form is for extension developers who need new features or capabilities that don't exist yet. + + - type: checkboxes + id: checklist + attributes: + label: Checklist + description: Please confirm the following before submitting + options: + - label: I have read the [Extension Development Guide](https://github.com/zarzet/SpotiFLAC-Mobile/blob/main/docs/EXTENSION_DEVELOPMENT.md) + required: true + - label: I have searched existing issues and this API feature hasn't been requested yet + required: true + + - type: textarea + id: extension_goal + attributes: + label: What are you trying to build? + description: Describe the extension or feature you're developing + placeholder: "I'm building an extension that downloads from [service name] / provides metadata from [source]..." + validations: + required: true + + - type: textarea + id: current_limitation + attributes: + label: Current API Limitation + description: What's missing or limiting in the current extension API? + placeholder: | + The current API doesn't support: + - [missing feature 1] + - [missing feature 2] + + This prevents me from... + validations: + required: true + + - type: textarea + id: proposed_api + attributes: + label: Proposed API / Feature + description: Describe the API or feature you'd like to see added + placeholder: | + I would like to have: + - A new function `api.newFeature()` that does X + - A new manifest field `newOption` that enables Y + - Access to Z capability... + validations: + required: true + + - type: textarea + id: use_case + attributes: + label: Use Case Example + description: Provide a code example of how you would use this feature + placeholder: | + ```javascript + // Example usage in extension code + function download(request, progressCallback) { + const result = api.proposedFeature(params); + // ... + } + ``` + validations: + required: false + + - type: dropdown + id: api_category + attributes: + label: API Category + description: What category does this feature fall under? + options: + - HTTP/Network API + - File System API + - Storage API + - FFmpeg/Audio Processing + - Manifest Options + - Runtime Functions + - UI Integration + - Authentication + - Other + validations: + required: true + + - type: dropdown + id: priority + attributes: + label: How critical is this for your extension? + options: + - Blocker - Cannot build my extension without this + - High - Major functionality depends on this + - Medium - Would significantly improve my extension + - Low - Nice to have + validations: + required: true + + - type: textarea + id: workaround + attributes: + label: Current Workaround + description: Are you using any workaround currently? If so, describe it. + placeholder: "Currently I'm working around this by..." + + - type: textarea + id: additional + attributes: + label: Additional Context + description: Add any other context, links to similar APIs, or examples from other platforms + placeholder: "Similar feature in other platforms: ..." diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index 47381420..e4db3473 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -414,8 +414,7 @@ import Gobackend // Import Go framework let args = call.arguments as! [String: Any] let extensionId = args["extension_id"] as! String let authCode = args["auth_code"] as! String - GobackendSetExtensionAuthCode(extensionId, authCode, &error) - if let error = error { throw error } + GobackendSetExtensionAuthCodeByID(extensionId, authCode) return nil case "setExtensionTokens": @@ -424,20 +423,19 @@ import Gobackend // Import Go framework let accessToken = args["access_token"] as! String let refreshToken = args["refresh_token"] as? String ?? "" let expiresIn = args["expires_in"] as? Int ?? 0 - GobackendSetExtensionTokens(extensionId, accessToken, refreshToken, Int(expiresIn), &error) - if let error = error { throw error } + GobackendSetExtensionTokensByID(extensionId, accessToken, refreshToken, Int(expiresIn)) return nil case "clearExtensionPendingAuth": let args = call.arguments as! [String: Any] let extensionId = args["extension_id"] as! String - GobackendClearExtensionPendingAuth(extensionId) + GobackendClearExtensionPendingAuthByID(extensionId) return nil case "isExtensionAuthenticated": let args = call.arguments as! [String: Any] let extensionId = args["extension_id"] as! String - let response = GobackendIsExtensionAuthenticated(extensionId) + let response = GobackendIsExtensionAuthenticatedByID(extensionId) return response case "getAllPendingAuthRequests": From 93b40471430488b5cce6cb667035749b52e78eca Mon Sep 17 00:00:00 2001 From: zarzet Date: Mon, 12 Jan 2026 01:56:16 +0700 Subject: [PATCH 03/35] fix: persist extension enabled state and clear search provider when disabled - Save enabled state to settings store when extension is enabled/disabled - Restore enabled state from settings store when extension is loaded - Clear searchProvider setting when the extension is disabled - Update search hint to check if extension is still enabled --- go_backend/extension_manager.go | 16 ++++++++++++++++ lib/providers/extension_provider.dart | 10 ++++++++++ lib/screens/home_tab.dart | 11 ++++++++--- 3 files changed, 34 insertions(+), 3 deletions(-) diff --git a/go_backend/extension_manager.go b/go_backend/extension_manager.go index ade22a35..2270b70e 100644 --- a/go_backend/extension_manager.go +++ b/go_backend/extension_manager.go @@ -369,6 +369,13 @@ func (m *ExtensionManager) SetExtensionEnabled(extensionID string, enabled bool) ext.Enabled = enabled GoLog("[Extension] %s %s\n", extensionID, map[bool]string{true: "enabled", false: "disabled"}[enabled]) + + // Persist enabled state to settings store + store := GetExtensionSettingsStore() + if err := store.Set(extensionID, "_enabled", enabled); err != nil { + GoLog("[Extension] Failed to persist enabled state for %s: %v\n", extensionID, err) + } + return nil } @@ -457,6 +464,15 @@ func (m *ExtensionManager) loadExtensionFromDirectory(dirPath string) (*LoadedEx SourceDir: dirPath, } + // Restore enabled state from settings store + store := GetExtensionSettingsStore() + if enabledVal, err := store.Get(manifest.Name, "_enabled"); err == nil { + if enabled, ok := enabledVal.(bool); ok { + ext.Enabled = enabled + GoLog("[Extension] Restored enabled state for %s: %v\n", manifest.Name, enabled) + } + } + // Initialize Goja VM if err := m.initializeVM(ext); err != nil { ext.Error = err.Error() diff --git a/lib/providers/extension_provider.dart b/lib/providers/extension_provider.dart index c3b150a4..b6aa8a70 100644 --- a/lib/providers/extension_provider.dart +++ b/lib/providers/extension_provider.dart @@ -1,6 +1,7 @@ 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/settings_provider.dart'; final _log = AppLogger('ExtensionProvider'); @@ -528,6 +529,15 @@ class ExtensionNotifier extends Notifier { }).toList(); state = state.copyWith(extensions: extensions); + + // If disabling an extension that is the current search provider, clear it + if (!enabled) { + final settings = ref.read(settingsProvider); + if (settings.searchProvider == extensionId) { + ref.read(settingsProvider.notifier).setSearchProvider(null); + _log.d('Cleared search provider because extension $extensionId was disabled'); + } + } } catch (e) { _log.e('Failed to set extension enabled: $e'); state = state.copyWith(error: e.toString()); diff --git a/lib/screens/home_tab.dart b/lib/screens/home_tab.dart index 44b6457d..280457a9 100644 --- a/lib/screens/home_tab.dart +++ b/lib/screens/home_tab.dart @@ -779,10 +779,15 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient if (searchProvider != null && searchProvider.isNotEmpty) { final extState = ref.read(extensionProvider); final ext = extState.extensions.where((e) => e.id == searchProvider).firstOrNull; - if (ext?.searchBehavior?.placeholder != null) { - return ext!.searchBehavior!.placeholder!; + // Only show extension placeholder if extension exists AND is enabled + if (ext != null && ext.enabled) { + if (ext.searchBehavior?.placeholder != null) { + return ext.searchBehavior!.placeholder!; + } + return 'Search with ${ext.displayName}...'; } - return 'Search with ${ext?.displayName ?? 'extension'}...'; + // Extension not found or disabled - clear the search provider setting + // and return default hint } return 'Paste Spotify URL or search...'; } From 26d84041c707bedb57e4da865640c639652e9ef8 Mon Sep 17 00:00:00 2001 From: zarzet Date: Mon, 12 Jan 2026 01:58:44 +0700 Subject: [PATCH 04/35] fix: initialize extension system at app start for proper search hint - Move extension system initialization to main.dart _EagerInitialization - Show default search hint until extension system is initialized - Watch extension state changes to update search hint dynamically --- lib/main.dart | 37 ++++++++++++++++++++++++++++++++++--- lib/screens/home_tab.dart | 11 ++++++++++- 2 files changed, 44 insertions(+), 4 deletions(-) diff --git a/lib/main.dart b/lib/main.dart index dbdb3dfd..e0ec75e4 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -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 _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; } } diff --git a/lib/screens/home_tab.dart b/lib/screens/home_tab.dart index 280457a9..81adbf54 100644 --- a/lib/screens/home_tab.dart +++ b/lib/screens/home_tab.dart @@ -320,6 +320,10 @@ class _HomeTabState extends ConsumerState 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 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) { From 2b9357cb6d0f211ce4a31968bd6693a099828d5b Mon Sep 17 00:00:00 2001 From: zarzet Date: Mon, 12 Jan 2026 02:10:40 +0700 Subject: [PATCH 05/35] feat: remove default Spotify credentials, require user's own API key - Remove hardcoded Spotify client ID/secret from Go backend - Spotify now requires user to provide their own credentials - Deezer remains free (no credentials required) - Update UI to show 'Free' badge for Deezer, 'API Key' for Spotify - Show warning card when Spotify selected without credentials - Add hasSpotifyCredentials check to platform bridge --- .../kotlin/com/zarz/spotiflac/MainActivity.kt | 6 + go_backend/exports.go | 53 ++++-- go_backend/spotify.go | 156 ++++++++++-------- ios/Runner/AppDelegate.swift | 4 + lib/providers/settings_provider.dart | 10 +- .../settings/options_settings_page.dart | 84 +++++++--- lib/screens/setup_screen.dart | 5 +- lib/services/platform_bridge.dart | 8 +- 8 files changed, 207 insertions(+), 119 deletions(-) diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt index 46650c87..78be4902 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt @@ -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("tracks") ?: "[]" withContext(Dispatchers.IO) { diff --git a/go_backend/exports.go b/go_backend/exports.go index 656fa28b..19025dbc 100644 --- a/go_backend/exports.go +++ b/go_backend/exports.go @@ -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 diff --git a/go_backend/spotify.go b/go_backend/spotify.go index 3e2d866c..ad8f56aa 100644 --- a/go_backend/spotify.go +++ b/go_backend/spotify.go @@ -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 @@ -331,14 +349,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 } @@ -373,7 +391,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 +406,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 } @@ -438,7 +456,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 +552,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 +581,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 +620,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 +644,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 +686,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 +713,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 +773,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 +959,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", diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index e4db3473..042c2be8 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -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() diff --git a/lib/providers/settings_provider.dart b/lib/providers/settings_provider.dart index 1d7fbf0a..be6a785a 100644 --- a/lib/providers/settings_provider.dart +++ b/lib/providers/settings_provider.dart @@ -60,18 +60,16 @@ class SettingsNotifier extends Notifier { /// Apply current Spotify credentials to Go backend Future _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) { diff --git a/lib/screens/settings/options_settings_page.dart b/lib/screens/settings/options_settings_page.dart index 78f1f749..cbd0ebe7 100644 --- a/lib/screens/settings/options_settings_page.dart +++ b/lib/screens/settings/options_settings_page.dart @@ -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, @@ -820,6 +832,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 +848,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 +894,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 +949,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, + ), + ), + ), + ], ], ), ), diff --git a/lib/screens/setup_screen.dart b/lib/screens/setup_screen.dart index 3014da53..261f28c1 100644 --- a/lib/screens/setup_screen.dart +++ b/lib/screens/setup_screen.dart @@ -380,11 +380,10 @@ class _SetupScreenState extends ConsumerState { _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'); } diff --git a/lib/services/platform_bridge.dart b/lib/services/platform_bridge.dart index a5f58e38..03038ec6 100644 --- a/lib/services/platform_bridge.dart +++ b/lib/services/platform_bridge.dart @@ -331,7 +331,6 @@ class PlatformBridge { } /// Set custom Spotify API credentials - /// Pass empty strings to use default credentials static Future 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 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 From bcd718b17844df29351a402a1ef2dcdb55104147 Mon Sep 17 00:00:00 2001 From: zarzet Date: Mon, 12 Jan 2026 02:19:05 +0700 Subject: [PATCH 06/35] fix: reset settings when extension is disabled - Reset metadata source to Deezer when search provider extension is disabled - Reset default service to Tidal when download provider extension is disabled - Check extension enabled state in Options page (Primary Provider) - Check extension enabled state in Download Settings (Service selector) - Show extension download providers in service selector when enabled --- lib/providers/extension_provider.dart | 26 ++++-- .../settings/download_settings_page.dart | 89 ++++++++++++++----- .../settings/options_settings_page.dart | 14 +-- 3 files changed, 95 insertions(+), 34 deletions(-) diff --git a/lib/providers/extension_provider.dart b/lib/providers/extension_provider.dart index b6aa8a70..b7366dda 100644 --- a/lib/providers/extension_provider.dart +++ b/lib/providers/extension_provider.dart @@ -520,22 +520,34 @@ class ExtensionNotifier extends Notifier { 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) { diff --git a/lib/screens/settings/download_settings_page.dart b/lib/screens/settings/download_settings_page.dart index e1db9669..35ddc8af 100644 --- a/lib/screens/settings/download_settings_page.dart +++ b/lib/screens/settings/download_settings_page.dart @@ -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 { @@ -596,7 +597,7 @@ class DownloadSettingsPage extends ConsumerWidget { } } -class _ServiceSelector extends StatelessWidget { +class _ServiceSelector extends ConsumerWidget { final String currentService; final ValueChanged onChanged; const _ServiceSelector({ @@ -605,31 +606,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()), + ], + ], + ), + ], ], ), ); diff --git a/lib/screens/settings/options_settings_page.dart b/lib/screens/settings/options_settings_page.dart index cbd0ebe7..512e612e 100644 --- a/lib/screens/settings/options_settings_page.dart +++ b/lib/screens/settings/options_settings_page.dart @@ -794,14 +794,18 @@ class _MetadataSourceSelector extends ConsumerWidget { final settings = ref.watch(settingsProvider); final extState = ref.watch(extensionProvider); - // Check if extension search provider is active - final hasExtensionSearch = settings.searchProvider != null && - settings.searchProvider!.isNotEmpty; + // Check if extension search provider is active AND enabled + Extension? activeExtension; + if (settings.searchProvider != null && settings.searchProvider!.isNotEmpty) { + activeExtension = extState.extensions + .where((e) => e.id == settings.searchProvider && e.enabled) + .firstOrNull; + } + final hasExtensionSearch = activeExtension != null; String? extensionName; if (hasExtensionSearch) { - final ext = extState.extensions.where((e) => e.id == settings.searchProvider).firstOrNull; - extensionName = ext?.displayName ?? settings.searchProvider; + extensionName = activeExtension.displayName; } return Padding( From 35532b0c7378e69ab1a0bba77bb18be13a4215ac Mon Sep 17 00:00:00 2001 From: zarzet Date: Mon, 12 Jan 2026 06:37:18 +0700 Subject: [PATCH 07/35] feat(extension): Enhanced HTTP API for YouTube Music support - Add http.put(), http.delete(), http.patch() shortcut methods - Add persistent cookie jar per extension - Add http.clearCookies() to clear session - Fix User-Agent header respect (no longer overwritten) - Return multi-value headers as arrays (Set-Cookie support) - Auto-stringify objects in POST/PUT/PATCH body - Add response.ok and response.status properties - Update documentation with YouTube Music example --- CHANGELOG.md | 45 ++++ go_backend/extension_runtime.go | 375 ++++++++++++++++++++++++++++++-- lib/constants/app_info.dart | 4 +- pubspec.yaml | 2 +- pubspec_ios.yaml | 2 +- 5 files changed, 407 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b216840d..997ecf26 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,50 @@ # Changelog +## [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 diff --git a/go_backend/extension_runtime.go b/go_backend/extension_runtime.go index 5ecb4264..34c5d6d9 100644 --- a/go_backend/extension_runtime.go +++ b/go_backend/extension_runtime.go @@ -101,24 +101,57 @@ type ExtensionRuntime struct { manifest *ExtensionManifest settings map[string]interface{} httpClient *http.Client + cookieJar http.CookieJar dataDir string vm *goja.Runtime } // NewExtensionRuntime creates a new runtime for an extension func NewExtensionRuntime(ext *LoadedExtension) *ExtensionRuntime { + // Create a cookie jar for this extension + jar, _ := newSimpleCookieJar() + + client := &http.Client{ + Timeout: 30 * time.Second, + Jar: jar, + } + return &ExtensionRuntime{ extensionID: ext.ID, manifest: ext.Manifest, settings: make(map[string]interface{}), - httpClient: &http.Client{ - Timeout: 30 * time.Second, - }, - dataDir: ext.DataDir, - vm: ext.VM, + httpClient: client, + cookieJar: jar, + dataDir: ext.DataDir, + vm: ext.VM, } } +// simpleCookieJar is a simple in-memory cookie jar +type simpleCookieJar struct { + cookies map[string][]*http.Cookie + mu sync.RWMutex +} + +func newSimpleCookieJar() (*simpleCookieJar, error) { + return &simpleCookieJar{ + cookies: make(map[string][]*http.Cookie), + }, nil +} + +func (j *simpleCookieJar) SetCookies(u *url.URL, cookies []*http.Cookie) { + j.mu.Lock() + defer j.mu.Unlock() + key := u.Host + j.cookies[key] = append(j.cookies[key], cookies...) +} + +func (j *simpleCookieJar) Cookies(u *url.URL) []*http.Cookie { + j.mu.RLock() + defer j.mu.RUnlock() + return j.cookies[u.Host] +} + // SetSettings updates the runtime settings func (r *ExtensionRuntime) SetSettings(settings map[string]interface{}) { r.settings = settings @@ -132,6 +165,11 @@ func (r *ExtensionRuntime) RegisterAPIs(vm *goja.Runtime) { httpObj := vm.NewObject() httpObj.Set("get", r.httpGet) httpObj.Set("post", r.httpPost) + httpObj.Set("put", r.httpPut) + httpObj.Set("delete", r.httpDelete) + httpObj.Set("patch", r.httpPatch) + httpObj.Set("request", r.httpRequest) // Generic HTTP request (GET, POST, PUT, DELETE, etc.) + httpObj.Set("clearCookies", r.httpClearCookies) vm.Set("http", httpObj) // Storage API @@ -274,11 +312,14 @@ func (r *ExtensionRuntime) httpGet(call goja.FunctionCall) goja.Value { }) } - // Set headers + // Set headers - user headers first for k, v := range headers { req.Header.Set(k, v) } - req.Header.Set("User-Agent", "Spotiflac-Extension/1.0") + // Only set default User-Agent if not provided by extension + if req.Header.Get("User-Agent") == "" { + req.Header.Set("User-Agent", "Spotiflac-Extension/1.0") + } // Execute request resp, err := r.httpClient.Do(req) @@ -297,16 +338,20 @@ func (r *ExtensionRuntime) httpGet(call goja.FunctionCall) goja.Value { }) } - // Extract response headers - respHeaders := make(map[string]string) + // Extract response headers - return all values as arrays for multi-value headers (cookies, etc.) + respHeaders := make(map[string]interface{}) for k, v := range resp.Header { - if len(v) > 0 { + if len(v) == 1 { respHeaders[k] = v[0] + } else { + respHeaders[k] = v // Return as array if multiple values } } return r.vm.ToValue(map[string]interface{}{ "statusCode": resp.StatusCode, + "status": resp.StatusCode, // Alias for convenience + "ok": resp.StatusCode >= 200 && resp.StatusCode < 300, "body": string(body), "headers": respHeaders, }) @@ -330,10 +375,26 @@ func (r *ExtensionRuntime) httpPost(call goja.FunctionCall) goja.Value { }) } - // Get body if provided + // Get body if provided - support both string and object var bodyStr string if len(call.Arguments) > 1 && !goja.IsUndefined(call.Arguments[1]) && !goja.IsNull(call.Arguments[1]) { - bodyStr = call.Arguments[1].String() + bodyArg := call.Arguments[1].Export() + switch v := bodyArg.(type) { + case string: + bodyStr = v + case map[string]interface{}, []interface{}: + // Auto-stringify objects and arrays to JSON + jsonBytes, err := json.Marshal(v) + if err != nil { + return r.vm.ToValue(map[string]interface{}{ + "error": fmt.Sprintf("failed to stringify body: %v", err), + }) + } + bodyStr = string(jsonBytes) + default: + // Fallback to string conversion + bodyStr = call.Arguments[1].String() + } } // Get headers if provided @@ -355,11 +416,14 @@ func (r *ExtensionRuntime) httpPost(call goja.FunctionCall) goja.Value { }) } - // Set headers + // Set headers - user headers first for k, v := range headers { req.Header.Set(k, v) } - req.Header.Set("User-Agent", "Spotiflac-Extension/1.0") + // Only set defaults if not provided by extension + if req.Header.Get("User-Agent") == "" { + req.Header.Set("User-Agent", "Spotiflac-Extension/1.0") + } if req.Header.Get("Content-Type") == "" { req.Header.Set("Content-Type", "application/json") } @@ -381,21 +445,298 @@ func (r *ExtensionRuntime) httpPost(call goja.FunctionCall) goja.Value { }) } - // Extract response headers - respHeaders := make(map[string]string) + // Extract response headers - return all values as arrays for multi-value headers + respHeaders := make(map[string]interface{}) for k, v := range resp.Header { - if len(v) > 0 { + if len(v) == 1 { respHeaders[k] = v[0] + } else { + respHeaders[k] = v // Return as array if multiple values } } return r.vm.ToValue(map[string]interface{}{ "statusCode": resp.StatusCode, + "status": resp.StatusCode, // Alias for convenience + "ok": resp.StatusCode >= 200 && resp.StatusCode < 300, "body": string(body), "headers": respHeaders, }) } +// httpRequest performs a generic HTTP request (GET, POST, PUT, DELETE, etc.) +// Usage: http.request(url, options) where options = { method, body, headers } +func (r *ExtensionRuntime) httpRequest(call goja.FunctionCall) goja.Value { + if len(call.Arguments) < 1 { + return r.vm.ToValue(map[string]interface{}{ + "error": "URL is required", + }) + } + + urlStr := call.Arguments[0].String() + + // Validate domain + if err := r.validateDomain(urlStr); err != nil { + GoLog("[Extension:%s] HTTP blocked: %v\n", r.extensionID, err) + return r.vm.ToValue(map[string]interface{}{ + "error": err.Error(), + }) + } + + // Default options + method := "GET" + var bodyStr string + headers := make(map[string]string) + + // Parse options if provided + if len(call.Arguments) > 1 && !goja.IsUndefined(call.Arguments[1]) && !goja.IsNull(call.Arguments[1]) { + optionsObj := call.Arguments[1].Export() + if opts, ok := optionsObj.(map[string]interface{}); ok { + // Get method + if m, ok := opts["method"].(string); ok { + method = strings.ToUpper(m) + } + + // Get body - support both string and object + if bodyArg, ok := opts["body"]; ok && bodyArg != nil { + switch v := bodyArg.(type) { + case string: + bodyStr = v + case map[string]interface{}, []interface{}: + // Auto-stringify objects and arrays to JSON + jsonBytes, err := json.Marshal(v) + if err != nil { + return r.vm.ToValue(map[string]interface{}{ + "error": fmt.Sprintf("failed to stringify body: %v", err), + }) + } + bodyStr = string(jsonBytes) + default: + bodyStr = fmt.Sprintf("%v", v) + } + } + + // Get headers + if h, ok := opts["headers"].(map[string]interface{}); ok { + for k, v := range h { + headers[k] = fmt.Sprintf("%v", v) + } + } + } + } + + // Create request + var reqBody io.Reader + if bodyStr != "" { + reqBody = strings.NewReader(bodyStr) + } + + req, err := http.NewRequest(method, urlStr, reqBody) + if err != nil { + return r.vm.ToValue(map[string]interface{}{ + "error": err.Error(), + }) + } + + // Set headers - user headers first + for k, v := range headers { + req.Header.Set(k, v) + } + // Only set defaults if not provided by extension + if req.Header.Get("User-Agent") == "" { + req.Header.Set("User-Agent", "Spotiflac-Extension/1.0") + } + if bodyStr != "" && req.Header.Get("Content-Type") == "" { + req.Header.Set("Content-Type", "application/json") + } + + // Execute request + resp, err := r.httpClient.Do(req) + if err != nil { + return r.vm.ToValue(map[string]interface{}{ + "error": err.Error(), + }) + } + defer resp.Body.Close() + + // Read body + body, err := io.ReadAll(resp.Body) + if err != nil { + return r.vm.ToValue(map[string]interface{}{ + "error": err.Error(), + }) + } + + // Extract response headers - return all values as arrays for multi-value headers + respHeaders := make(map[string]interface{}) + for k, v := range resp.Header { + if len(v) == 1 { + respHeaders[k] = v[0] + } else { + respHeaders[k] = v // Return as array if multiple values + } + } + + // Return response with helper properties + return r.vm.ToValue(map[string]interface{}{ + "statusCode": resp.StatusCode, + "status": resp.StatusCode, // Alias for convenience + "ok": resp.StatusCode >= 200 && resp.StatusCode < 300, + "body": string(body), + "headers": respHeaders, + }) +} + +// httpPut performs a PUT request (shortcut for http.request with method: "PUT") +func (r *ExtensionRuntime) httpPut(call goja.FunctionCall) goja.Value { + return r.httpMethodShortcut("PUT", call) +} + +// httpDelete performs a DELETE request (shortcut for http.request with method: "DELETE") +func (r *ExtensionRuntime) httpDelete(call goja.FunctionCall) goja.Value { + return r.httpMethodShortcut("DELETE", call) +} + +// httpPatch performs a PATCH request (shortcut for http.request with method: "PATCH") +func (r *ExtensionRuntime) httpPatch(call goja.FunctionCall) goja.Value { + return r.httpMethodShortcut("PATCH", call) +} + +// httpMethodShortcut is a helper for PUT/DELETE/PATCH shortcuts +// Signature: http.put(url, body, headers) / http.delete(url, headers) / http.patch(url, body, headers) +func (r *ExtensionRuntime) httpMethodShortcut(method string, call goja.FunctionCall) goja.Value { + if len(call.Arguments) < 1 { + return r.vm.ToValue(map[string]interface{}{ + "error": "URL is required", + }) + } + + urlStr := call.Arguments[0].String() + + // Validate domain + if err := r.validateDomain(urlStr); err != nil { + GoLog("[Extension:%s] HTTP blocked: %v\n", r.extensionID, err) + return r.vm.ToValue(map[string]interface{}{ + "error": err.Error(), + }) + } + + var bodyStr string + headers := make(map[string]string) + + // For DELETE, second arg is headers; for PUT/PATCH, second arg is body + if method == "DELETE" { + // http.delete(url, headers) + if len(call.Arguments) > 1 && !goja.IsUndefined(call.Arguments[1]) && !goja.IsNull(call.Arguments[1]) { + headersObj := call.Arguments[1].Export() + if h, ok := headersObj.(map[string]interface{}); ok { + for k, v := range h { + headers[k] = fmt.Sprintf("%v", v) + } + } + } + } else { + // http.put(url, body, headers) / http.patch(url, body, headers) + if len(call.Arguments) > 1 && !goja.IsUndefined(call.Arguments[1]) && !goja.IsNull(call.Arguments[1]) { + bodyArg := call.Arguments[1].Export() + switch v := bodyArg.(type) { + case string: + bodyStr = v + case map[string]interface{}, []interface{}: + jsonBytes, err := json.Marshal(v) + if err != nil { + return r.vm.ToValue(map[string]interface{}{ + "error": fmt.Sprintf("failed to stringify body: %v", err), + }) + } + bodyStr = string(jsonBytes) + default: + bodyStr = call.Arguments[1].String() + } + } + + if len(call.Arguments) > 2 && !goja.IsUndefined(call.Arguments[2]) && !goja.IsNull(call.Arguments[2]) { + headersObj := call.Arguments[2].Export() + if h, ok := headersObj.(map[string]interface{}); ok { + for k, v := range h { + headers[k] = fmt.Sprintf("%v", v) + } + } + } + } + + // Create request + var reqBody io.Reader + if bodyStr != "" { + reqBody = strings.NewReader(bodyStr) + } + + req, err := http.NewRequest(method, urlStr, reqBody) + if err != nil { + return r.vm.ToValue(map[string]interface{}{ + "error": err.Error(), + }) + } + + // Set headers - user headers first + for k, v := range headers { + req.Header.Set(k, v) + } + if req.Header.Get("User-Agent") == "" { + req.Header.Set("User-Agent", "Spotiflac-Extension/1.0") + } + if bodyStr != "" && req.Header.Get("Content-Type") == "" { + req.Header.Set("Content-Type", "application/json") + } + + // Execute request + resp, err := r.httpClient.Do(req) + if err != nil { + return r.vm.ToValue(map[string]interface{}{ + "error": err.Error(), + }) + } + defer resp.Body.Close() + + // Read body + body, err := io.ReadAll(resp.Body) + if err != nil { + return r.vm.ToValue(map[string]interface{}{ + "error": err.Error(), + }) + } + + // Extract response headers + respHeaders := make(map[string]interface{}) + for k, v := range resp.Header { + if len(v) == 1 { + respHeaders[k] = v[0] + } else { + respHeaders[k] = v + } + } + + return r.vm.ToValue(map[string]interface{}{ + "statusCode": resp.StatusCode, + "status": resp.StatusCode, + "ok": resp.StatusCode >= 200 && resp.StatusCode < 300, + "body": string(body), + "headers": respHeaders, + }) +} + +// httpClearCookies clears all cookies for this extension +func (r *ExtensionRuntime) httpClearCookies(call goja.FunctionCall) goja.Value { + if jar, ok := r.cookieJar.(*simpleCookieJar); ok { + jar.mu.Lock() + jar.cookies = make(map[string][]*http.Cookie) + jar.mu.Unlock() + GoLog("[Extension:%s] Cookies cleared\n", r.extensionID) + return r.vm.ToValue(true) + } + return r.vm.ToValue(false) +} + // ==================== File API (Sandboxed) ==================== // validatePath checks if the path is within the extension's data directory diff --git a/lib/constants/app_info.dart b/lib/constants/app_info.dart index 4e8196f5..f8507aa5 100644 --- a/lib/constants/app_info.dart +++ b/lib/constants/app_info.dart @@ -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.2'; + static const String buildNumber = '51'; static const String fullVersion = '$version+$buildNumber'; diff --git a/pubspec.yaml b/pubspec.yaml index 1879f639..776d8764 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: spotiflac_android description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music publish_to: "none" -version: 3.0.0-alpha.1+50 +version: 3.0.0-alpha.2+51 environment: sdk: ^3.10.0 diff --git a/pubspec_ios.yaml b/pubspec_ios.yaml index 22b3ab62..d0d9087a 100644 --- a/pubspec_ios.yaml +++ b/pubspec_ios.yaml @@ -1,7 +1,7 @@ name: spotiflac_android description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music publish_to: "none" -version: 3.0.0-alpha.1+50 +version: 3.0.0-alpha.2+51 environment: sdk: ^3.10.0 From c37410b5de0590e56c6614837b76fe331493d60d Mon Sep 17 00:00:00 2001 From: zarzet Date: Mon, 12 Jan 2026 18:27:38 +0700 Subject: [PATCH 08/35] feat: add Separate Singles Folder option - Add albumType field to Track model with isSingle getter - Add separateSingles setting in AppSettings - Modify _buildOutputDir() to organize into Albums/ and Singles/ folders - Add UI toggle in download settings page - Parse album_type/record_type from Spotify and Deezer APIs When enabled, singles are saved to a separate 'Singles' folder --- go_backend/deezer.go | 8 +++ go_backend/spotify.go | 5 ++ lib/models/settings.dart | 4 ++ lib/models/settings.g.dart | 2 + lib/models/track.dart | 5 ++ lib/models/track.g.dart | 2 + lib/providers/download_queue_provider.dart | 57 ++++++++++++++----- lib/providers/settings_provider.dart | 5 ++ lib/providers/track_provider.dart | 1 + .../settings/download_settings_page.dart | 36 ++++++++---- 10 files changed, 99 insertions(+), 26 deletions(-) diff --git a/go_backend/deezer.go b/go_backend/deezer.go index dce19752..42231e26 100644 --- a/go_backend/deezer.go +++ b/go_backend/deezer.go @@ -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, }) } diff --git a/go_backend/spotify.go b/go_backend/spotify.go index ad8f56aa..37846633 100644 --- a/go_backend/spotify.go +++ b/go_backend/spotify.go @@ -158,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 @@ -177,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 @@ -301,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 { @@ -381,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, }) } @@ -448,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, }) } diff --git a/lib/models/settings.dart b/lib/models/settings.dart index 598b42dc..0e11a65b 100644 --- a/lib/models/settings.dart +++ b/lib/models/settings.dart @@ -27,6 +27,7 @@ class AppSettings { final bool enableLogging; // Enable detailed logging for debugging final bool useExtensionProviders; // Use extension providers for downloads when available final String? searchProvider; // null/empty = default (Deezer/Spotify), otherwise extension ID + final bool separateSingles; // Separate singles/EPs into their own folder const AppSettings({ this.defaultService = 'tidal', @@ -52,6 +53,7 @@ class AppSettings { this.enableLogging = false, // Default: disabled for performance this.useExtensionProviders = true, // Default: use extensions when available this.searchProvider, // Default: null (use Deezer/Spotify) + this.separateSingles = false, // Default: disabled }); AppSettings copyWith({ @@ -78,6 +80,7 @@ class AppSettings { bool? enableLogging, bool? useExtensionProviders, String? searchProvider, + bool? separateSingles, }) { return AppSettings( defaultService: defaultService ?? this.defaultService, @@ -103,6 +106,7 @@ class AppSettings { enableLogging: enableLogging ?? this.enableLogging, useExtensionProviders: useExtensionProviders ?? this.useExtensionProviders, searchProvider: searchProvider ?? this.searchProvider, + separateSingles: separateSingles ?? this.separateSingles, ); } diff --git a/lib/models/settings.g.dart b/lib/models/settings.g.dart index 2dae4431..899ad5c3 100644 --- a/lib/models/settings.g.dart +++ b/lib/models/settings.g.dart @@ -31,6 +31,7 @@ AppSettings _$AppSettingsFromJson(Map json) => AppSettings( enableLogging: json['enableLogging'] as bool? ?? false, useExtensionProviders: json['useExtensionProviders'] as bool? ?? true, searchProvider: json['searchProvider'] as String?, + separateSingles: json['separateSingles'] as bool? ?? false, ); Map _$AppSettingsToJson(AppSettings instance) => @@ -58,4 +59,5 @@ Map _$AppSettingsToJson(AppSettings instance) => 'enableLogging': instance.enableLogging, 'useExtensionProviders': instance.useExtensionProviders, 'searchProvider': instance.searchProvider, + 'separateSingles': instance.separateSingles, }; diff --git a/lib/models/track.dart b/lib/models/track.dart index 080d045a..ac579b50 100644 --- a/lib/models/track.dart +++ b/lib/models/track.dart @@ -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 json) => _$TrackFromJson(json); Map toJson() => _$TrackToJson(this); diff --git a/lib/models/track.g.dart b/lib/models/track.g.dart index 5a866a4e..0836a5b2 100644 --- a/lib/models/track.g.dart +++ b/lib/models/track.g.dart @@ -25,6 +25,7 @@ Track _$TrackFromJson(Map json) => Track( json['availability'] as Map, ), source: json['source'] as String?, + albumType: json['albumType'] as String?, ); Map _$TrackToJson(Track instance) => { @@ -42,6 +43,7 @@ Map _$TrackToJson(Track instance) => { 'deezerId': instance.deezerId, 'availability': instance.availability, 'source': instance.source, + 'albumType': instance.albumType, }; ServiceAvailability _$ServiceAvailabilityFromJson(Map json) => diff --git a/lib/providers/download_queue_provider.dart b/lib/providers/download_queue_provider.dart index 65fbb7ef..6bf6d6ce 100644 --- a/lib/providers/download_queue_provider.dart +++ b/lib/providers/download_queue_provider.dart @@ -668,35 +668,55 @@ class DownloadQueueNotifier extends Notifier { state = state.copyWith(outputDir: dir); } - /// Build output directory based on folder organization setting - Future _buildOutputDir(Track track, String folderOrganization) async { + /// Build output directory based on folder organization setting and separateSingles + Future _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; } @@ -714,6 +734,14 @@ class DownloadQueueNotifier extends Notifier { 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 @@ -1417,6 +1445,7 @@ class DownloadQueueNotifier extends Notifier { final outputDir = await _buildOutputDir( trackToDownload, settings.folderOrganization, + separateSingles: settings.separateSingles, ); // Use quality override if set, otherwise use default from settings diff --git a/lib/providers/settings_provider.dart b/lib/providers/settings_provider.dart index 42b5bde3..5251b1e6 100644 --- a/lib/providers/settings_provider.dart +++ b/lib/providers/settings_provider.dart @@ -211,6 +211,11 @@ class SettingsNotifier extends Notifier { state = state.copyWith(useExtensionProviders: enabled); _saveSettings(); } + + void setSeparateSingles(bool enabled) { + state = state.copyWith(separateSingles: enabled); + _saveSettings(); + } } final settingsProvider = NotifierProvider( diff --git a/lib/providers/track_provider.dart b/lib/providers/track_provider.dart index 9ed80280..fe6ee1f0 100644 --- a/lib/providers/track_provider.dart +++ b/lib/providers/track_provider.dart @@ -466,6 +466,7 @@ class TrackNotifier extends Notifier { 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(), ); } diff --git a/lib/screens/settings/download_settings_page.dart b/lib/screens/settings/download_settings_page.dart index b3c5e97a..c712621d 100644 --- a/lib/screens/settings/download_settings_page.dart +++ b/lib/screens/settings/download_settings_page.dart @@ -185,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, + ), ], ), ), From c4bea124fb530dea029e06d6b58c52a189dce5ef Mon Sep 17 00:00:00 2001 From: zarzet Date: Mon, 12 Jan 2026 18:32:59 +0700 Subject: [PATCH 09/35] perf: parallel API calls for Tidal and Qobuz download URLs - Tidal: Request download URL from all 8 APIs simultaneously - Qobuz: Request download URL from all 2 APIs simultaneously - First successful response wins ('siapa cepat dia dapat') - Significantly reduces download URL fetch time - Amazon remains sequential due to rate limiting requirements This improves download speed by eliminating sequential API fallback delays. --- go_backend/qobuz.go | 126 +++++++++++++++++++++++++++++++++++++- go_backend/tidal.go | 145 +++++++++++++++++++++++++++++++++++++++----- 2 files changed, 255 insertions(+), 16 deletions(-) diff --git a/go_backend/qobuz.go b/go_backend/qobuz.go index 9b57bb3b..b4611857 100644 --- a/go_backend/qobuz.go +++ b/go_backend/qobuz.go @@ -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 } diff --git a/go_backend/tidal.go b/go_backend/tidal.go index ad1d04e1..4e4cca08 100644 --- a/go_backend/tidal.go +++ b/go_backend/tidal.go @@ -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) } From 9247a775fad85a934fe4b7d54edb2a8081dd1344 Mon Sep 17 00:00:00 2001 From: zarzet Date: Mon, 12 Jan 2026 21:18:04 +0700 Subject: [PATCH 10/35] feat(extension): add browser-like polyfills for easier library porting - Add fetch() API with json(), text(), arrayBuffer() methods - Add atob()/btoa() global Base64 functions - Add TextEncoder/TextDecoder classes for UTF-8 encoding - Add URL/URLSearchParams classes for URL parsing - Update documentation with polyfill usage examples - All polyfills work within sandbox security model --- CHANGELOG.md | 6 + go_backend/extension_runtime.go | 493 ++++++++++++++++++++++++++++++++ 2 files changed, 499 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 558512ea..b3af73e0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,12 @@ - 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 diff --git a/go_backend/extension_runtime.go b/go_backend/extension_runtime.go index 34c5d6d9..cc099e1f 100644 --- a/go_backend/extension_runtime.go +++ b/go_backend/extension_runtime.go @@ -249,6 +249,25 @@ func (r *ExtensionRuntime) RegisterAPIs(vm *goja.Runtime) { gobackendObj := vm.NewObject() gobackendObj.Set("sanitizeFilename", r.sanitizeFilenameWrapper) vm.Set("gobackend", gobackendObj) + + // ==================== Browser-like Polyfills ==================== + // These make porting browser/Node.js libraries easier + + // Global fetch() - Promise-style HTTP API (browser-compatible) + vm.Set("fetch", r.fetchPolyfill) + + // Global atob/btoa - Base64 encoding (browser-compatible) + vm.Set("atob", r.atobPolyfill) + vm.Set("btoa", r.btoaPolyfill) + + // TextEncoder/TextDecoder constructors + r.registerTextEncoderDecoder(vm) + + // URL class for URL parsing + r.registerURLClass(vm) + + // JSON global (browser-compatible) + r.registerJSONGlobal(vm) } // ==================== HTTP API (Sandboxed) ==================== @@ -2274,3 +2293,477 @@ func normalizeStringForMatching(s string) string { return strings.TrimSpace(s) } + +// ==================== Browser-like Polyfills ==================== +// These polyfills make porting browser/Node.js libraries easier +// without compromising sandbox security + +// fetchPolyfill implements browser-compatible fetch() API +// Returns a Promise-like object with json(), text() methods +func (r *ExtensionRuntime) fetchPolyfill(call goja.FunctionCall) goja.Value { + if len(call.Arguments) < 1 { + return r.createFetchError("URL is required") + } + + urlStr := call.Arguments[0].String() + + // Validate domain + if err := r.validateDomain(urlStr); err != nil { + GoLog("[Extension:%s] fetch blocked: %v\n", r.extensionID, err) + return r.createFetchError(err.Error()) + } + + // Parse options + method := "GET" + var bodyStr string + headers := make(map[string]string) + + if len(call.Arguments) > 1 && !goja.IsUndefined(call.Arguments[1]) && !goja.IsNull(call.Arguments[1]) { + optionsObj := call.Arguments[1].Export() + if opts, ok := optionsObj.(map[string]interface{}); ok { + // Method + if m, ok := opts["method"].(string); ok { + method = strings.ToUpper(m) + } + + // Body - support string, object (auto-stringify), or nil + if bodyArg, ok := opts["body"]; ok && bodyArg != nil { + switch v := bodyArg.(type) { + case string: + bodyStr = v + case map[string]interface{}, []interface{}: + jsonBytes, err := json.Marshal(v) + if err != nil { + return r.createFetchError(fmt.Sprintf("failed to stringify body: %v", err)) + } + bodyStr = string(jsonBytes) + default: + bodyStr = fmt.Sprintf("%v", v) + } + } + + // Headers + if h, ok := opts["headers"]; ok && h != nil { + switch hv := h.(type) { + case map[string]interface{}: + for k, v := range hv { + headers[k] = fmt.Sprintf("%v", v) + } + } + } + } + } + + // Create HTTP request + var reqBody io.Reader + if bodyStr != "" { + reqBody = strings.NewReader(bodyStr) + } + + req, err := http.NewRequest(method, urlStr, reqBody) + if err != nil { + return r.createFetchError(err.Error()) + } + + // Set headers - user headers first + for k, v := range headers { + req.Header.Set(k, v) + } + // Set defaults if not provided + if req.Header.Get("User-Agent") == "" { + req.Header.Set("User-Agent", "SpotiFLAC-Extension/1.0") + } + if bodyStr != "" && req.Header.Get("Content-Type") == "" { + req.Header.Set("Content-Type", "application/json") + } + + // Execute request + resp, err := r.httpClient.Do(req) + if err != nil { + return r.createFetchError(err.Error()) + } + defer resp.Body.Close() + + // Read body + body, err := io.ReadAll(resp.Body) + if err != nil { + return r.createFetchError(err.Error()) + } + + // Extract response headers + respHeaders := make(map[string]interface{}) + for k, v := range resp.Header { + if len(v) == 1 { + respHeaders[k] = v[0] + } else { + respHeaders[k] = v + } + } + + // Create Response object (browser-compatible) + responseObj := r.vm.NewObject() + responseObj.Set("ok", resp.StatusCode >= 200 && resp.StatusCode < 300) + responseObj.Set("status", resp.StatusCode) + responseObj.Set("statusText", http.StatusText(resp.StatusCode)) + responseObj.Set("headers", respHeaders) + responseObj.Set("url", urlStr) + + // Store body for methods + bodyString := string(body) + + // text() method - returns body as string + responseObj.Set("text", func(call goja.FunctionCall) goja.Value { + return r.vm.ToValue(bodyString) + }) + + // json() method - parses body as JSON + responseObj.Set("json", func(call goja.FunctionCall) goja.Value { + var result interface{} + if err := json.Unmarshal(body, &result); err != nil { + GoLog("[Extension:%s] fetch json() parse error: %v\n", r.extensionID, err) + return goja.Undefined() + } + return r.vm.ToValue(result) + }) + + // arrayBuffer() method - returns body as array (simplified) + responseObj.Set("arrayBuffer", func(call goja.FunctionCall) goja.Value { + // Return as array of bytes + byteArray := make([]interface{}, len(body)) + for i, b := range body { + byteArray[i] = int(b) + } + return r.vm.ToValue(byteArray) + }) + + return responseObj +} + +// createFetchError creates a fetch error response +func (r *ExtensionRuntime) createFetchError(message string) goja.Value { + errorObj := r.vm.NewObject() + errorObj.Set("ok", false) + errorObj.Set("status", 0) + errorObj.Set("statusText", "Network Error") + errorObj.Set("error", message) + errorObj.Set("text", func(call goja.FunctionCall) goja.Value { + return r.vm.ToValue("") + }) + errorObj.Set("json", func(call goja.FunctionCall) goja.Value { + return goja.Undefined() + }) + return errorObj +} + +// atobPolyfill implements browser atob() - decode base64 to string +func (r *ExtensionRuntime) atobPolyfill(call goja.FunctionCall) goja.Value { + if len(call.Arguments) < 1 { + return r.vm.ToValue("") + } + input := call.Arguments[0].String() + decoded, err := base64.StdEncoding.DecodeString(input) + if err != nil { + // Try URL-safe base64 + decoded, err = base64.URLEncoding.DecodeString(input) + if err != nil { + GoLog("[Extension:%s] atob decode error: %v\n", r.extensionID, err) + return r.vm.ToValue("") + } + } + return r.vm.ToValue(string(decoded)) +} + +// btoaPolyfill implements browser btoa() - encode string to base64 +func (r *ExtensionRuntime) btoaPolyfill(call goja.FunctionCall) goja.Value { + if len(call.Arguments) < 1 { + return r.vm.ToValue("") + } + input := call.Arguments[0].String() + return r.vm.ToValue(base64.StdEncoding.EncodeToString([]byte(input))) +} + +// registerTextEncoderDecoder registers TextEncoder and TextDecoder classes +func (r *ExtensionRuntime) registerTextEncoderDecoder(vm *goja.Runtime) { + // TextEncoder constructor + vm.Set("TextEncoder", func(call goja.ConstructorCall) *goja.Object { + encoder := call.This + encoder.Set("encoding", "utf-8") + + // encode() method - string to Uint8Array + encoder.Set("encode", func(call goja.FunctionCall) goja.Value { + if len(call.Arguments) < 1 { + return vm.ToValue([]byte{}) + } + input := call.Arguments[0].String() + bytes := []byte(input) + + // Return as array (Uint8Array-like) + result := make([]interface{}, len(bytes)) + for i, b := range bytes { + result[i] = int(b) + } + return vm.ToValue(result) + }) + + // encodeInto() method + encoder.Set("encodeInto", func(call goja.FunctionCall) goja.Value { + // Simplified implementation + if len(call.Arguments) < 2 { + return vm.ToValue(map[string]interface{}{"read": 0, "written": 0}) + } + input := call.Arguments[0].String() + return vm.ToValue(map[string]interface{}{ + "read": len(input), + "written": len([]byte(input)), + }) + }) + + return nil + }) + + // TextDecoder constructor + vm.Set("TextDecoder", func(call goja.ConstructorCall) *goja.Object { + decoder := call.This + + // Get encoding from arguments (default: utf-8) + encoding := "utf-8" + if len(call.Arguments) > 0 && !goja.IsUndefined(call.Arguments[0]) { + encoding = call.Arguments[0].String() + } + decoder.Set("encoding", encoding) + decoder.Set("fatal", false) + decoder.Set("ignoreBOM", false) + + // decode() method - Uint8Array to string + decoder.Set("decode", func(call goja.FunctionCall) goja.Value { + if len(call.Arguments) < 1 { + return vm.ToValue("") + } + + // Handle different input types + input := call.Arguments[0].Export() + var bytes []byte + + switch v := input.(type) { + case []byte: + bytes = v + case []interface{}: + bytes = make([]byte, len(v)) + for i, val := range v { + switch n := val.(type) { + case int64: + bytes[i] = byte(n) + case float64: + bytes[i] = byte(n) + case int: + bytes[i] = byte(n) + } + } + case string: + // Already a string, just return it + return vm.ToValue(v) + default: + return vm.ToValue("") + } + + return vm.ToValue(string(bytes)) + }) + + return nil + }) +} + +// registerURLClass registers the URL class for URL parsing +func (r *ExtensionRuntime) registerURLClass(vm *goja.Runtime) { + vm.Set("URL", func(call goja.ConstructorCall) *goja.Object { + urlObj := call.This + + if len(call.Arguments) < 1 { + urlObj.Set("href", "") + return nil + } + + urlStr := call.Arguments[0].String() + + // Handle relative URLs with base + if len(call.Arguments) > 1 && !goja.IsUndefined(call.Arguments[1]) { + baseStr := call.Arguments[1].String() + baseURL, err := url.Parse(baseStr) + if err == nil { + relURL, err := url.Parse(urlStr) + if err == nil { + urlStr = baseURL.ResolveReference(relURL).String() + } + } + } + + parsed, err := url.Parse(urlStr) + if err != nil { + urlObj.Set("href", urlStr) + return nil + } + + // Set URL properties + urlObj.Set("href", parsed.String()) + urlObj.Set("protocol", parsed.Scheme+":") + urlObj.Set("host", parsed.Host) + urlObj.Set("hostname", parsed.Hostname()) + urlObj.Set("port", parsed.Port()) + urlObj.Set("pathname", parsed.Path) + urlObj.Set("search", "") + if parsed.RawQuery != "" { + urlObj.Set("search", "?"+parsed.RawQuery) + } + urlObj.Set("hash", "") + if parsed.Fragment != "" { + urlObj.Set("hash", "#"+parsed.Fragment) + } + urlObj.Set("origin", parsed.Scheme+"://"+parsed.Host) + urlObj.Set("username", parsed.User.Username()) + password, _ := parsed.User.Password() + urlObj.Set("password", password) + + // searchParams object + searchParams := vm.NewObject() + queryValues := parsed.Query() + + searchParams.Set("get", func(call goja.FunctionCall) goja.Value { + if len(call.Arguments) < 1 { + return goja.Null() + } + key := call.Arguments[0].String() + if val := queryValues.Get(key); val != "" { + return vm.ToValue(val) + } + return goja.Null() + }) + + searchParams.Set("getAll", func(call goja.FunctionCall) goja.Value { + if len(call.Arguments) < 1 { + return vm.ToValue([]string{}) + } + key := call.Arguments[0].String() + return vm.ToValue(queryValues[key]) + }) + + searchParams.Set("has", func(call goja.FunctionCall) goja.Value { + if len(call.Arguments) < 1 { + return vm.ToValue(false) + } + key := call.Arguments[0].String() + return vm.ToValue(queryValues.Has(key)) + }) + + searchParams.Set("toString", func(call goja.FunctionCall) goja.Value { + return vm.ToValue(queryValues.Encode()) + }) + + urlObj.Set("searchParams", searchParams) + + // toString method + urlObj.Set("toString", func(call goja.FunctionCall) goja.Value { + return vm.ToValue(parsed.String()) + }) + + // toJSON method + urlObj.Set("toJSON", func(call goja.FunctionCall) goja.Value { + return vm.ToValue(parsed.String()) + }) + + return nil + }) + + // URLSearchParams constructor + vm.Set("URLSearchParams", func(call goja.ConstructorCall) *goja.Object { + paramsObj := call.This + values := url.Values{} + + // Parse initial value if provided + if len(call.Arguments) > 0 && !goja.IsUndefined(call.Arguments[0]) { + init := call.Arguments[0].Export() + switch v := init.(type) { + case string: + // Parse query string + parsed, _ := url.ParseQuery(strings.TrimPrefix(v, "?")) + values = parsed + case map[string]interface{}: + for k, val := range v { + values.Set(k, fmt.Sprintf("%v", val)) + } + } + } + + paramsObj.Set("append", func(call goja.FunctionCall) goja.Value { + if len(call.Arguments) >= 2 { + values.Add(call.Arguments[0].String(), call.Arguments[1].String()) + } + return goja.Undefined() + }) + + paramsObj.Set("delete", func(call goja.FunctionCall) goja.Value { + if len(call.Arguments) >= 1 { + values.Del(call.Arguments[0].String()) + } + return goja.Undefined() + }) + + paramsObj.Set("get", func(call goja.FunctionCall) goja.Value { + if len(call.Arguments) < 1 { + return goja.Null() + } + if val := values.Get(call.Arguments[0].String()); val != "" { + return vm.ToValue(val) + } + return goja.Null() + }) + + paramsObj.Set("getAll", func(call goja.FunctionCall) goja.Value { + if len(call.Arguments) < 1 { + return vm.ToValue([]string{}) + } + return vm.ToValue(values[call.Arguments[0].String()]) + }) + + paramsObj.Set("has", func(call goja.FunctionCall) goja.Value { + if len(call.Arguments) < 1 { + return vm.ToValue(false) + } + return vm.ToValue(values.Has(call.Arguments[0].String())) + }) + + paramsObj.Set("set", func(call goja.FunctionCall) goja.Value { + if len(call.Arguments) >= 2 { + values.Set(call.Arguments[0].String(), call.Arguments[1].String()) + } + return goja.Undefined() + }) + + paramsObj.Set("toString", func(call goja.FunctionCall) goja.Value { + return vm.ToValue(values.Encode()) + }) + + return nil + }) +} + +// registerJSONGlobal ensures JSON global is properly set up +func (r *ExtensionRuntime) registerJSONGlobal(vm *goja.Runtime) { + // JSON is already built-in to Goja, but we can enhance it + // This ensures JSON.parse and JSON.stringify work as expected + + // The built-in JSON object should already work, but let's verify + // and add any missing functionality if needed + jsonScript := ` + if (typeof JSON === 'undefined') { + var JSON = { + parse: function(text) { + return utils.parseJSON(text); + }, + stringify: function(value, replacer, space) { + return utils.stringifyJSON(value); + } + }; + } + ` + _, _ = vm.RunString(jsonScript) +} From 4966a8461493cabb751e8e672e5c5e68b5b56fe1 Mon Sep 17 00:00:00 2001 From: zarzet Date: Mon, 12 Jan 2026 22:02:29 +0700 Subject: [PATCH 11/35] chore: bump version to 3.0.0-alpha.3 --- CHANGELOG.md | 2 +- lib/constants/app_info.dart | 4 ++-- pubspec.yaml | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b3af73e0..0ec3548f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## [3.0.0-alpha.3] - Upcoming +## [3.0.0-alpha.3] - 2026-01-12 ### Added diff --git a/lib/constants/app_info.dart b/lib/constants/app_info.dart index f8507aa5..6ecdd9f1 100644 --- a/lib/constants/app_info.dart +++ b/lib/constants/app_info.dart @@ -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.2'; - static const String buildNumber = '51'; + static const String version = '3.0.0-alpha.3'; + static const String buildNumber = '52'; static const String fullVersion = '$version+$buildNumber'; diff --git a/pubspec.yaml b/pubspec.yaml index 776d8764..a80ef90d 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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.2+51 +version: 3.0.0-alpha.3+52 environment: sdk: ^3.10.0 From 523b1edc44287c1b9162217ef0ef29ec79f3c9fe Mon Sep 17 00:00:00 2001 From: zarzet Date: Mon, 12 Jan 2026 22:22:25 +0700 Subject: [PATCH 12/35] feat(extension): add custom URL handler support for extensions - Add URLHandlerConfig to extension manifest (Go) - Add HandleURL method to extension providers (Go) - Add export functions for URL handling (Go) - Add URLHandler class to extension_provider.dart (Flutter) - Add platform bridge methods for URL handling (Flutter) - Update fetchFromUrl to check extension URL handlers first - Add Android/iOS native handlers for extension URL routing - Update CHANGELOG with new feature --- CHANGELOG.md | 20 +++ .../kotlin/com/zarz/spotiflac/MainActivity.kt | 21 +++ go_backend/exports.go | 121 ++++++++++++++++++ go_backend/extension_manifest.go | 30 +++++ go_backend/extension_providers.go | 105 +++++++++++++++ ios/Runner/AppDelegate.swift | 19 +++ lib/providers/extension_provider.dart | 38 ++++++ lib/providers/track_provider.dart | 39 ++++++ lib/services/platform_bridge.dart | 34 +++++ 9 files changed, 427 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0ec3548f..54f52102 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,25 @@ # Changelog +## [3.0.0-alpha.4] - Upcoming + +### Added + +- **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 + +### 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 + +--- + ## [3.0.0-alpha.3] - 2026-01-12 ### Added diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt index 78be4902..aa777954 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt @@ -551,6 +551,27 @@ class MainActivity: FlutterActivity() { } result.success(response) } + // Extension URL Handler API + "handleURLWithExtension" -> { + val url = call.argument("url") ?: "" + val response = withContext(Dispatchers.IO) { + Gobackend.handleURLWithExtensionJSON(url) + } + result.success(response) + } + "findURLHandler" -> { + val url = call.argument("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("file_path") ?: "" diff --git a/go_backend/exports.go b/go_backend/exports.go index b71cc2e0..0e6f2afc 100644 --- a/go_backend/exports.go +++ b/go_backend/exports.go @@ -1506,6 +1506,127 @@ 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() + result, extensionID, err := manager.HandleURLWithExtension(url) + if err != nil { + return "", err + } + + // 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 diff --git a/go_backend/extension_manifest.go b/go_backend/extension_manifest.go index 60b5e1fd..667ecce5 100644 --- a/go_backend/extension_manifest.go +++ b/go_backend/extension_manifest.go @@ -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 { diff --git a/go_backend/extension_providers.go b/go_backend/extension_providers.go index 5eb68360..193e7d4c 100644 --- a/go_backend/extension_providers.go +++ b/go_backend/extension_providers.go @@ -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,48 @@ 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 +} + +// HandleURLWithExtension tries to handle a URL with any matching extension +func (m *ExtensionManager) HandleURLWithExtension(url string) (*ExtURLHandleResult, string, 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 nil, handler.extension.ID, err + } + + return result, handler.extension.ID, nil +} + // GetPostProcessingProviders returns all extensions that provide post-processing func (m *ExtensionManager) GetPostProcessingProviders() []*ExtensionProviderWrapper { m.mu.RLock() diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index 042c2be8..fa352449 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -484,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] diff --git a/lib/providers/extension_provider.dart b/lib/providers/extension_provider.dart index b7366dda..ecba3f30 100644 --- a/lib/providers/extension_provider.dart +++ b/lib/providers/extension_provider.dart @@ -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) : null, + urlHandler: json['url_handler'] != null + ? URLHandler.fromJson(json['url_handler'] as Map) + : null, trackMatching: json['track_matching'] != null ? TrackMatching.fromJson(json['track_matching'] as Map) : 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 patterns; + + const URLHandler({ + required this.enabled, + this.patterns = const [], + }); + + factory URLHandler.fromJson(Map json) { + return URLHandler( + enabled: json['enabled'] as bool? ?? false, + patterns: (json['patterns'] as List?)?.cast() ?? [], + ); + } + + /// 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; diff --git a/lib/providers/track_provider.dart b/lib/providers/track_provider.dart index fe6ee1f0..dbf190ea 100644 --- a/lib/providers/track_provider.dart +++ b/lib/providers/track_provider.dart @@ -131,6 +131,45 @@ class TrackNotifier extends Notifier { 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; + 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; + final tracks = trackList.map((t) => _parseSearchTrack(t as Map, 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; + } + } + } + + // No extension handler found, try Spotify URL parsing final parsed = await PlatformBridge.parseSpotifyUrl(url); if (!_isRequestValid(requestId)) return; // Request cancelled diff --git a/lib/services/platform_bridge.dart b/lib/services/platform_bridge.dart index 03038ec6..7810006c 100644 --- a/lib/services/platform_bridge.dart +++ b/lib/services/platform_bridge.dart @@ -753,6 +753,40 @@ class PlatformBridge { return list.map((e) => e as Map).toList(); } + // ==================== EXTENSION URL HANDLER ==================== + + /// Handle a URL with any matching extension + /// Returns null if no extension can handle the URL + static Future?> 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; + } 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 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>> getURLHandlers() async { + final result = await _channel.invokeMethod('getURLHandlers'); + final list = jsonDecode(result as String) as List; + return list.map((e) => e as Map).toList(); + } + // ==================== EXTENSION POST-PROCESSING ==================== /// Run post-processing hooks on a file From 4afc14dee84804c888dab35d8fea868531fb0cfb Mon Sep 17 00:00:00 2001 From: zarzet Date: Mon, 12 Jan 2026 23:18:47 +0700 Subject: [PATCH 13/35] chore: increase log buffer size from 500 to 1000 entries --- go_backend/logbuffer.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/go_backend/logbuffer.go b/go_backend/logbuffer.go index e39ddd9c..fafadccf 100644 --- a/go_backend/logbuffer.go +++ b/go_backend/logbuffer.go @@ -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) } }) From 0cab01780dd4b5812d2e1c7237359e1d78f70f24 Mon Sep 17 00:00:00 2001 From: zarzet Date: Mon, 12 Jan 2026 23:43:57 +0700 Subject: [PATCH 14/35] fix: gomobile compatibility for HandleURLWithExtension return type --- go_backend/exports.go | 10 +++++++++- go_backend/extension_providers.go | 21 +++++++++++++++++---- 2 files changed, 26 insertions(+), 5 deletions(-) diff --git a/go_backend/exports.go b/go_backend/exports.go index 0e6f2afc..1943b566 100644 --- a/go_backend/exports.go +++ b/go_backend/exports.go @@ -1512,11 +1512,19 @@ func GetSearchProvidersJSON() (string, error) { // Returns JSON with type, tracks, album info, etc. func HandleURLWithExtensionJSON(url string) (string, error) { manager := GetExtensionManager() - result, extensionID, err := manager.HandleURLWithExtension(url) + 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, diff --git a/go_backend/extension_providers.go b/go_backend/extension_providers.go index 193e7d4c..b24c5645 100644 --- a/go_backend/extension_providers.go +++ b/go_backend/extension_providers.go @@ -1210,19 +1210,32 @@ func (m *ExtensionManager) FindURLHandler(url string) *ExtensionProviderWrapper 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 -func (m *ExtensionManager) HandleURLWithExtension(url string) (*ExtURLHandleResult, string, error) { +// 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) + return nil, fmt.Errorf("no extension found to handle URL: %s", url) } result, err := handler.HandleURL(url) if err != nil { - return nil, handler.extension.ID, err + return &ExtURLHandleResultWithExtID{ + Result: nil, + ExtensionID: handler.extension.ID, + }, err } - return result, handler.extension.ID, nil + return &ExtURLHandleResultWithExtID{ + Result: result, + ExtensionID: handler.extension.ID, + }, nil } // GetPostProcessingProviders returns all extensions that provide post-processing From a38d66fd414451819ff528ce4367887280c31761 Mon Sep 17 00:00:00 2001 From: zarzet Date: Tue, 13 Jan 2026 00:00:17 +0700 Subject: [PATCH 15/35] feat: add Extension Store for browsing and installing extensions --- CHANGELOG.md | 8 + .../kotlin/com/zarz/spotiflac/MainActivity.kt | 43 ++ go_backend/exports.go | 97 ++++ go_backend/extension_store.go | 384 +++++++++++++ lib/providers/store_provider.dart | 286 ++++++++++ lib/screens/main_shell.dart | 7 + lib/screens/store_tab.dart | 537 ++++++++++++++++++ lib/services/platform_bridge.dart | 52 ++ 8 files changed, 1414 insertions(+) create mode 100644 go_backend/extension_store.go create mode 100644 lib/providers/store_provider.dart create mode 100644 lib/screens/store_tab.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 54f52102..7c6bd231 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,14 @@ ### 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"] }` diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt index aa777954..6a0fb6b5 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt @@ -587,6 +587,49 @@ class MainActivity: FlutterActivity() { } result.success(response) } + // Extension Store + "initExtensionStore" -> { + val cacheDir = call.argument("cache_dir") ?: "" + withContext(Dispatchers.IO) { + Gobackend.initExtensionStoreJSON(cacheDir) + } + result.success(null) + } + "getStoreExtensions" -> { + val forceRefresh = call.argument("force_refresh") ?: false + val response = withContext(Dispatchers.IO) { + Gobackend.getStoreExtensionsJSON(forceRefresh) + } + result.success(response) + } + "searchStoreExtensions" -> { + val query = call.argument("query") ?: "" + val category = call.argument("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("extension_id") ?: "" + val destDir = call.argument("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) { diff --git a/go_backend/exports.go b/go_backend/exports.go index 1943b566..cda85cc4 100644 --- a/go_backend/exports.go +++ b/go_backend/exports.go @@ -1692,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", 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 +} diff --git a/go_backend/extension_store.go b/go_backend/extension_store.go new file mode 100644 index 00000000..ed0da274 --- /dev/null +++ b/go_backend/extension_store.go @@ -0,0 +1,384 @@ +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"` + 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"` +} + +// StoreRegistry represents the extension registry +type StoreRegistry struct { + Version int `json:"version"` + UpdatedAt string `json:"updated_at"` + Extensions []StoreExtension `json:"extensions"` +} + +// StoreExtensionWithStatus adds installation status to StoreExtension +type StoreExtensionWithStatus struct { + StoreExtension + IsInstalled bool `json:"is_installed"` + InstalledVersion string `json:"installed_version,omitempty"` + HasUpdate bool `json:"has_update"` +} + +// 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() ([]StoreExtensionWithStatus, 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([]StoreExtensionWithStatus, len(registry.Extensions)) + for i, ext := range registry.Extensions { + status := StoreExtensionWithStatus{ + StoreExtension: ext, + } + + if installedVersion, ok := installed[ext.ID]; ok { + status.IsInstalled = true + status.InstalledVersion = installedVersion + status.HasUpdate = compareVersions(ext.Version, installedVersion) > 0 + } + + result[i] = status + } + + 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.DisplayName, ext.DownloadURL) + + client := &http.Client{Timeout: 5 * time.Minute} + resp, err := client.Get(ext.DownloadURL) + 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.DisplayName, 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) ([]StoreExtensionWithStatus, error) { + extensions, err := s.GetExtensionsWithStatus() + if err != nil { + return nil, err + } + + if query == "" && category == "" { + return extensions, nil + } + + var result []StoreExtensionWithStatus + 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 +} diff --git a/lib/providers/store_provider.dart b/lib/providers/store_provider.dart new file mode 100644 index 00000000..eec5e7f6 --- /dev/null +++ b/lib/providers/store_provider.dart @@ -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 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 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 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?)?.cast() ?? [], + 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 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? 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 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 { + @override + StoreState build() { + return const StoreState(); + } + + /// Initialize the store + Future 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 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 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 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.new, +); diff --git a/lib/screens/main_shell.dart b/lib/screens/main_shell.dart index 92b016c0..81156c0a 100644 --- a/lib/screens/main_shell.dart +++ b/lib/screens/main_shell.dart @@ -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'; @@ -204,6 +205,7 @@ class _MainShellState extends ConsumerState { physics: const BouncingScrollPhysics(), children: const [ HomeTab(), + StoreTab(), QueueTab(), SettingsTab(), ], @@ -221,6 +223,11 @@ class _MainShellState extends ConsumerState { selectedIcon: Icon(Icons.home), label: 'Home', ), + const NavigationDestination( + icon: Icon(Icons.store_outlined), + selectedIcon: Icon(Icons.store), + label: 'Store', + ), NavigationDestination( icon: Badge( isLabelVisible: queueState > 0, diff --git a/lib/screens/store_tab.dart b/lib/screens/store_tab.dart new file mode 100644 index 00000000..6af8497c --- /dev/null +++ b/lib/screens/store_tab.dart @@ -0,0 +1,537 @@ +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 createState() => _StoreTabState(); +} + +class _StoreTabState extends ConsumerState { + final _searchController = TextEditingController(); + bool _isInitialized = false; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) => _initialize()); + } + + Future _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 _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 _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 + Container( + width: 44, + height: 44, + decoration: BoxDecoration( + color: extension.isInstalled + ? colorScheme.primaryContainer + : colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(12), + ), + child: 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), + ), + ], + ); + } +} diff --git a/lib/services/platform_bridge.dart b/lib/services/platform_bridge.dart index 7810006c..43a21fb5 100644 --- a/lib/services/platform_bridge.dart +++ b/lib/services/platform_bridge.dart @@ -807,4 +807,56 @@ class PlatformBridge { final list = jsonDecode(result as String) as List; return list.map((e) => e as Map).toList(); } + + // ==================== EXTENSION STORE ==================== + + /// Initialize extension store + static Future 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>> 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; + return list.map((e) => e as Map).toList(); + } + + /// Search extensions in store + static Future>> 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; + return list.map((e) => e as Map).toList(); + } + + /// Get store categories + static Future> getStoreCategories() async { + final result = await _channel.invokeMethod('getStoreCategories'); + final list = jsonDecode(result as String) as List; + return list.cast(); + } + + /// Download extension from store + static Future 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 clearStoreCache() async { + _log.d('clearStoreCache'); + await _channel.invokeMethod('clearStoreCache'); + } } From 8daff4d0a4a620de810a466c75fb2ff51f3c38d5 Mon Sep 17 00:00:00 2001 From: zarzet Date: Tue, 13 Jan 2026 01:01:43 +0700 Subject: [PATCH 16/35] feat: improve Extension Store with custom icons and various fixes - Support custom extension icons from registry (iconUrl field) - Support both camelCase and snake_case in registry JSON - Fix download file extension (.spotiflac-ext) - New extensions start disabled by default - Preserve enabled state on extension upgrade - Add toggle to show/hide Store tab in Settings > Options - Reorder tabs: Home, History, Store, Settings --- go_backend/exports.go | 2 +- go_backend/extension_manager.go | 11 +- go_backend/extension_store.go | 113 ++++++++++++++---- lib/models/settings.dart | 4 + lib/models/settings.g.dart | 2 + lib/providers/settings_provider.dart | 5 + lib/screens/main_shell.dart | 92 ++++++++------ .../settings/options_settings_page.dart | 9 ++ lib/screens/store_tab.dart | 44 +++++-- 9 files changed, 210 insertions(+), 72 deletions(-) diff --git a/go_backend/exports.go b/go_backend/exports.go index cda85cc4..30a07c80 100644 --- a/go_backend/exports.go +++ b/go_backend/exports.go @@ -1770,7 +1770,7 @@ func DownloadStoreExtensionJSON(extensionID, destDir string) (string, error) { return "", fmt.Errorf("extension store not initialized") } - destPath := fmt.Sprintf("%s/%s.spotiflac", destDir, extensionID) + destPath := fmt.Sprintf("%s/%s.spotiflac-ext", destDir, extensionID) err := store.DownloadExtension(extensionID, destPath) if err != nil { return "", err diff --git a/go_backend/extension_manager.go b/go_backend/extension_manager.go index 2270b70e..045708a6 100644 --- a/go_backend/extension_manager.go +++ b/go_backend/extension_manager.go @@ -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, } diff --git a/go_backend/extension_store.go b/go_backend/extension_store.go index ed0da274..2a2e1097 100644 --- a/go_backend/extension_store.go +++ b/go_backend/extension_store.go @@ -24,17 +24,57 @@ const ( type StoreExtension struct { ID string `json:"id"` Name string `json:"name"` - DisplayName string `json:"display_name"` + DisplayName string `json:"display_name,omitempty"` Version string `json:"version"` Author string `json:"author"` Description string `json:"description"` - DownloadURL string `json:"download_url"` + 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 @@ -44,12 +84,43 @@ type StoreRegistry struct { Extensions []StoreExtension `json:"extensions"` } -// StoreExtensionWithStatus adds installation status to StoreExtension -type StoreExtensionWithStatus struct { - StoreExtension - IsInstalled bool `json:"is_installed"` - InstalledVersion string `json:"installed_version,omitempty"` - HasUpdate bool `json:"has_update"` +// 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 @@ -198,7 +269,7 @@ func (s *ExtensionStore) FetchRegistry(forceRefresh bool) (*StoreRegistry, error } // GetExtensionsWithStatus returns extensions with installation status -func (s *ExtensionStore) GetExtensionsWithStatus() ([]StoreExtensionWithStatus, error) { +func (s *ExtensionStore) GetExtensionsWithStatus() ([]StoreExtensionResponse, error) { registry, err := s.FetchRegistry(false) if err != nil { return nil, err @@ -213,19 +284,17 @@ func (s *ExtensionStore) GetExtensionsWithStatus() ([]StoreExtensionWithStatus, } } - result := make([]StoreExtensionWithStatus, len(registry.Extensions)) + result := make([]StoreExtensionResponse, len(registry.Extensions)) for i, ext := range registry.Extensions { - status := StoreExtensionWithStatus{ - StoreExtension: ext, - } + resp := ext.ToResponse() if installedVersion, ok := installed[ext.ID]; ok { - status.IsInstalled = true - status.InstalledVersion = installedVersion - status.HasUpdate = compareVersions(ext.Version, installedVersion) > 0 + resp.IsInstalled = true + resp.InstalledVersion = installedVersion + resp.HasUpdate = compareVersions(ext.Version, installedVersion) > 0 } - result[i] = status + result[i] = resp } return result, nil @@ -250,10 +319,10 @@ func (s *ExtensionStore) DownloadExtension(extensionID string, destPath string) return fmt.Errorf("extension %s not found in store", extensionID) } - LogInfo("ExtensionStore", "Downloading %s from %s", ext.DisplayName, ext.DownloadURL) + LogInfo("ExtensionStore", "Downloading %s from %s", ext.getDisplayName(), ext.getDownloadURL()) client := &http.Client{Timeout: 5 * time.Minute} - resp, err := client.Get(ext.DownloadURL) + resp, err := client.Get(ext.getDownloadURL()) if err != nil { return fmt.Errorf("failed to download: %w", err) } @@ -276,7 +345,7 @@ func (s *ExtensionStore) DownloadExtension(extensionID string, destPath string) return fmt.Errorf("failed to write file: %w", err) } - LogInfo("ExtensionStore", "Downloaded %s to %s", ext.DisplayName, destPath) + LogInfo("ExtensionStore", "Downloaded %s to %s", ext.getDisplayName(), destPath) return nil } @@ -292,7 +361,7 @@ func (s *ExtensionStore) GetCategories() []string { } // SearchExtensions searches extensions by query -func (s *ExtensionStore) SearchExtensions(query string, category string) ([]StoreExtensionWithStatus, error) { +func (s *ExtensionStore) SearchExtensions(query string, category string) ([]StoreExtensionResponse, error) { extensions, err := s.GetExtensionsWithStatus() if err != nil { return nil, err @@ -302,7 +371,7 @@ func (s *ExtensionStore) SearchExtensions(query string, category string) ([]Stor return extensions, nil } - var result []StoreExtensionWithStatus + var result []StoreExtensionResponse queryLower := toLower(query) for _, ext := range extensions { diff --git a/lib/models/settings.dart b/lib/models/settings.dart index 0e11a65b..5c6f2b4f 100644 --- a/lib/models/settings.dart +++ b/lib/models/settings.dart @@ -28,6 +28,7 @@ class AppSettings { 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', @@ -54,6 +55,7 @@ class AppSettings { 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({ @@ -81,6 +83,7 @@ class AppSettings { bool? useExtensionProviders, String? searchProvider, bool? separateSingles, + bool? showExtensionStore, }) { return AppSettings( defaultService: defaultService ?? this.defaultService, @@ -107,6 +110,7 @@ class AppSettings { useExtensionProviders: useExtensionProviders ?? this.useExtensionProviders, searchProvider: searchProvider ?? this.searchProvider, separateSingles: separateSingles ?? this.separateSingles, + showExtensionStore: showExtensionStore ?? this.showExtensionStore, ); } diff --git a/lib/models/settings.g.dart b/lib/models/settings.g.dart index 899ad5c3..bdbb8745 100644 --- a/lib/models/settings.g.dart +++ b/lib/models/settings.g.dart @@ -32,6 +32,7 @@ AppSettings _$AppSettingsFromJson(Map json) => AppSettings( useExtensionProviders: json['useExtensionProviders'] as bool? ?? true, searchProvider: json['searchProvider'] as String?, separateSingles: json['separateSingles'] as bool? ?? false, + showExtensionStore: json['showExtensionStore'] as bool? ?? true, ); Map _$AppSettingsToJson(AppSettings instance) => @@ -60,4 +61,5 @@ Map _$AppSettingsToJson(AppSettings instance) => 'useExtensionProviders': instance.useExtensionProviders, 'searchProvider': instance.searchProvider, 'separateSingles': instance.separateSingles, + 'showExtensionStore': instance.showExtensionStore, }; diff --git a/lib/providers/settings_provider.dart b/lib/providers/settings_provider.dart index 5251b1e6..f56da477 100644 --- a/lib/providers/settings_provider.dart +++ b/lib/providers/settings_provider.dart @@ -216,6 +216,11 @@ class SettingsNotifier extends Notifier { state = state.copyWith(separateSingles: enabled); _saveSettings(); } + + void setShowExtensionStore(bool enabled) { + state = state.copyWith(showExtensionStore: enabled); + _saveSettings(); + } } final settingsProvider = NotifierProvider( diff --git a/lib/screens/main_shell.dart b/lib/screens/main_shell.dart index 81156c0a..d2b927da 100644 --- a/lib/screens/main_shell.dart +++ b/lib/screens/main_shell.dart @@ -173,6 +173,7 @@ class _MainShellState extends ConsumerState { 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; @@ -186,6 +187,57 @@ class _MainShellState extends ConsumerState { !trackState.isLoading && !isKeyboardVisible; + // Build tabs and destinations based on settings + final tabs = [ + const HomeTab(), + const QueueTab(), + if (showStore) const StoreTab(), + const SettingsTab(), + ]; + + final 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', + ), + 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 { @@ -203,50 +255,16 @@ class _MainShellState extends ConsumerState { controller: _pageController, onPageChanged: _onPageChanged, physics: const BouncingScrollPhysics(), - children: const [ - HomeTab(), - StoreTab(), - 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', - ), - const NavigationDestination( - icon: Icon(Icons.store_outlined), - selectedIcon: Icon(Icons.store), - label: 'Store', - ), - 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, ), ), ); diff --git a/lib/screens/settings/options_settings_page.dart b/lib/screens/settings/options_settings_page.dart index 512e612e..06113cbe 100644 --- a/lib/screens/settings/options_settings_page.dart +++ b/lib/screens/settings/options_settings_page.dart @@ -202,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', diff --git a/lib/screens/store_tab.dart b/lib/screens/store_tab.dart index 6af8497c..000da84a 100644 --- a/lib/screens/store_tab.dart +++ b/lib/screens/store_tab.dart @@ -410,7 +410,7 @@ class _ExtensionItem extends StatelessWidget { padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), child: Row( children: [ - // Extension icon + // Extension icon - custom or category-based Container( width: 44, height: 44, @@ -420,12 +420,42 @@ class _ExtensionItem extends StatelessWidget { : colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(12), ), - child: Icon( - _getCategoryIcon(extension.category), - color: extension.isInstalled - ? colorScheme.onPrimaryContainer - : colorScheme.onSurfaceVariant, - ), + 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 From f7d5a24d17be55bc6076e4e7d366ad1fb8c30f05 Mon Sep 17 00:00:00 2001 From: zarzet Date: Tue, 13 Jan 2026 04:17:00 +0700 Subject: [PATCH 17/35] refactor(extension): split extension_runtime.go into multiple files + add HMAC-SHA256 --- go_backend/extension_runtime.go | 2521 +-------------------- go_backend/extension_runtime_auth.go | 547 +++++ go_backend/extension_runtime_ffmpeg.go | 204 ++ go_backend/extension_runtime_file.go | 467 ++++ go_backend/extension_runtime_http.go | 499 ++++ go_backend/extension_runtime_matching.go | 151 ++ go_backend/extension_runtime_polyfills.go | 488 ++++ go_backend/extension_runtime_storage.go | 339 +++ go_backend/extension_runtime_utils.go | 313 +++ 9 files changed, 3018 insertions(+), 2511 deletions(-) create mode 100644 go_backend/extension_runtime_auth.go create mode 100644 go_backend/extension_runtime_ffmpeg.go create mode 100644 go_backend/extension_runtime_file.go create mode 100644 go_backend/extension_runtime_http.go create mode 100644 go_backend/extension_runtime_matching.go create mode 100644 go_backend/extension_runtime_polyfills.go create mode 100644 go_backend/extension_runtime_storage.go create mode 100644 go_backend/extension_runtime_utils.go diff --git a/go_backend/extension_runtime.go b/go_backend/extension_runtime.go index cc099e1f..21622721 100644 --- a/go_backend/extension_runtime.go +++ b/go_backend/extension_runtime.go @@ -2,21 +2,8 @@ package gobackend import ( - "crypto/aes" - "crypto/cipher" - "crypto/md5" - "crypto/rand" - "crypto/sha256" - "encoding/base64" - "encoding/hex" - "encoding/json" - "fmt" - "io" "net/http" "net/url" - "os" - "path/filepath" - "strings" "sync" "time" @@ -37,6 +24,9 @@ type ExtensionAuthState struct { RefreshToken string ExpiresAt time.Time IsAuthenticated bool + // PKCE support + PKCEVerifier string + PKCEChallenge string } // PendingAuthRequest holds a pending OAuth request that needs Flutter to open URL @@ -195,6 +185,11 @@ func (r *ExtensionRuntime) RegisterAPIs(vm *goja.Runtime) { authObj.Set("clearAuth", r.authClear) authObj.Set("isAuthenticated", r.authIsAuthenticated) authObj.Set("getTokens", r.authGetTokens) + // PKCE support + authObj.Set("generatePKCE", r.authGeneratePKCE) + authObj.Set("getPKCE", r.authGetPKCE) + authObj.Set("startOAuthWithPKCE", r.authStartOAuthWithPKCE) + authObj.Set("exchangeCodeWithPKCE", r.authExchangeCodeWithPKCE) vm.Set("auth", authObj) // File operations (sandboxed) @@ -229,6 +224,8 @@ func (r *ExtensionRuntime) RegisterAPIs(vm *goja.Runtime) { utilsObj.Set("base64Decode", r.base64Decode) utilsObj.Set("md5", r.md5Hash) utilsObj.Set("sha256", r.sha256Hash) + utilsObj.Set("hmacSHA256", r.hmacSHA256) + utilsObj.Set("hmacSHA256Base64", r.hmacSHA256Base64) utilsObj.Set("parseJSON", r.parseJSON) utilsObj.Set("stringifyJSON", r.stringifyJSON) // Crypto utilities for developers @@ -269,2501 +266,3 @@ func (r *ExtensionRuntime) RegisterAPIs(vm *goja.Runtime) { // JSON global (browser-compatible) r.registerJSONGlobal(vm) } - -// ==================== 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) -} - -// ==================== 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, - }) -} - -// ==================== 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) -} - -// ==================== 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[:])) -} - -// 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)) -} - -// ==================== 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)) - }) -} - -// ==================== 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) -} - -// ==================== 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) -} - -// ==================== 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 -} - -// 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), - }) -} - -// ==================== Additional File Operations ==================== - -// 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(), - }) -} - -// ==================== 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) -} - -// ==================== 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) -} - -// ==================== 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) -} diff --git a/go_backend/extension_runtime_auth.go b/go_backend/extension_runtime_auth.go new file mode 100644 index 00000000..4e5102ef --- /dev/null +++ b/go_backend/extension_runtime_auth.go @@ -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) +} diff --git a/go_backend/extension_runtime_ffmpeg.go b/go_backend/extension_runtime_ffmpeg.go new file mode 100644 index 00000000..889456bb --- /dev/null +++ b/go_backend/extension_runtime_ffmpeg.go @@ -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) +} diff --git a/go_backend/extension_runtime_file.go b/go_backend/extension_runtime_file.go new file mode 100644 index 00000000..44b4ffd0 --- /dev/null +++ b/go_backend/extension_runtime_file.go @@ -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(), + }) +} diff --git a/go_backend/extension_runtime_http.go b/go_backend/extension_runtime_http.go new file mode 100644 index 00000000..c27bd7f2 --- /dev/null +++ b/go_backend/extension_runtime_http.go @@ -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) +} diff --git a/go_backend/extension_runtime_matching.go b/go_backend/extension_runtime_matching.go new file mode 100644 index 00000000..9e56fa80 --- /dev/null +++ b/go_backend/extension_runtime_matching.go @@ -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) +} diff --git a/go_backend/extension_runtime_polyfills.go b/go_backend/extension_runtime_polyfills.go new file mode 100644 index 00000000..5293841e --- /dev/null +++ b/go_backend/extension_runtime_polyfills.go @@ -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) +} diff --git a/go_backend/extension_runtime_storage.go b/go_backend/extension_runtime_storage.go new file mode 100644 index 00000000..54882a41 --- /dev/null +++ b/go_backend/extension_runtime_storage.go @@ -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 +} diff --git a/go_backend/extension_runtime_utils.go b/go_backend/extension_runtime_utils.go new file mode 100644 index 00000000..b01693d2 --- /dev/null +++ b/go_backend/extension_runtime_utils.go @@ -0,0 +1,313 @@ +// Package gobackend provides Utility functions for extension runtime +package gobackend + +import ( + "crypto/hmac" + "crypto/md5" + "crypto/rand" + "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))) +} + +// 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)) + }) +} From 47cdb5564a5c272b1b14becb93b1ea0ba4ba939c Mon Sep 17 00:00:00 2001 From: zarzet Date: Tue, 13 Jan 2026 04:30:25 +0700 Subject: [PATCH 18/35] fix(store): refresh store after extension uninstall to update isInstalled status --- lib/screens/settings/extension_detail_page.dart | 3 +++ 1 file changed, 3 insertions(+) diff --git a/lib/screens/settings/extension_detail_page.dart b/lib/screens/settings/extension_detail_page.dart index 18c98e91..50635540 100644 --- a/lib/screens/settings/extension_detail_page.dart +++ b/lib/screens/settings/extension_detail_page.dart @@ -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 { .read(extensionProvider.notifier) .removeExtension(widget.extensionId); if (success && mounted) { + // Refresh store to update isInstalled status + ref.read(storeProvider.notifier).refresh(); Navigator.pop(this.context); } } From 0a16be4395ff9fed69f2b4055cb24612f42b722d Mon Sep 17 00:00:00 2001 From: zarzet Date: Tue, 13 Jan 2026 05:53:30 +0700 Subject: [PATCH 19/35] feat(extension): add HMAC-SHA1 utility, artist URL handler, and store refresh fix - Add utils.hmacSHA1(key, message) for cryptographic operations in extensions - Add artist type handling in track_provider for extension URL results - Fix extension store not refreshing after uninstall - Update CHANGELOG with new features and Spotify Web extension docs --- CHANGELOG.md | 21 ++++++++++ go_backend/extension_runtime.go | 1 + go_backend/extension_runtime_utils.go | 59 +++++++++++++++++++++++++++ lib/providers/track_provider.dart | 14 +++++++ 4 files changed, 95 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c6bd231..4ec67db9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,12 +19,33 @@ - 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 + - Located in `docs/extensions_example/spotify-internal/` --- diff --git a/go_backend/extension_runtime.go b/go_backend/extension_runtime.go index 21622721..77c8c311 100644 --- a/go_backend/extension_runtime.go +++ b/go_backend/extension_runtime.go @@ -226,6 +226,7 @@ func (r *ExtensionRuntime) RegisterAPIs(vm *goja.Runtime) { utilsObj.Set("sha256", r.sha256Hash) utilsObj.Set("hmacSHA256", r.hmacSHA256) utilsObj.Set("hmacSHA256Base64", r.hmacSHA256Base64) + utilsObj.Set("hmacSHA1", r.hmacSHA1) utilsObj.Set("parseJSON", r.parseJSON) utilsObj.Set("stringifyJSON", r.stringifyJSON) // Crypto utilities for developers diff --git a/go_backend/extension_runtime_utils.go b/go_backend/extension_runtime_utils.go index b01693d2..cd3819c1 100644 --- a/go_backend/extension_runtime_utils.go +++ b/go_backend/extension_runtime_utils.go @@ -5,6 +5,7 @@ import ( "crypto/hmac" "crypto/md5" "crypto/rand" + "crypto/sha1" "crypto/sha256" "encoding/base64" "encoding/hex" @@ -85,6 +86,64 @@ func (r *ExtensionRuntime) hmacSHA256Base64(call goja.FunctionCall) goja.Value { 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 { diff --git a/lib/providers/track_provider.dart b/lib/providers/track_provider.dart index dbf190ea..032836f1 100644 --- a/lib/providers/track_provider.dart +++ b/lib/providers/track_provider.dart @@ -165,6 +165,20 @@ class TrackNotifier extends Notifier { searchExtensionId: extensionId, ); return; + } else if (type == 'artist' && result['artist'] != null) { + final artistData = result['artist'] as Map; + final albumsList = artistData['albums'] as List? ?? []; + final albums = albumsList.map((a) => _parseArtistAlbum(a as Map)).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; } } } From 729ab01a5fce560868b74aa5781043b1aa5bf6df Mon Sep 17 00:00:00 2001 From: zarzet Date: Tue, 13 Jan 2026 05:54:19 +0700 Subject: [PATCH 20/35] feat(extension): add HMAC-SHA1 utility, artist URL handler, and store refresh fix - Add utils.hmacSHA1(key, message) for extensions - Add artist type handling in track_provider for extension URL results - Fix extension store not refreshing after uninstall - Update CHANGELOG with new features and Spotify Web extension --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4ec67db9..16e7ff7a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -45,7 +45,7 @@ - **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 - - Located in `docs/extensions_example/spotify-internal/` + - Available in Extension Store (3.0.0-alpha.4) --- From c7e1ffd926f5c694a69760f2b81fb3bb9cd6e7f8 Mon Sep 17 00:00:00 2001 From: zarzet Date: Tue, 13 Jan 2026 06:01:12 +0700 Subject: [PATCH 21/35] chore: bump version to 3.0.0-alpha.4 --- lib/constants/app_info.dart | 4 ++-- pubspec.yaml | 2 +- pubspec_ios.yaml | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/constants/app_info.dart b/lib/constants/app_info.dart index 6ecdd9f1..189410ba 100644 --- a/lib/constants/app_info.dart +++ b/lib/constants/app_info.dart @@ -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.3'; - static const String buildNumber = '52'; + static const String version = '3.0.0-alpha.4'; + static const String buildNumber = '53'; static const String fullVersion = '$version+$buildNumber'; diff --git a/pubspec.yaml b/pubspec.yaml index a80ef90d..8d28b9d0 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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.3+52 +version: 3.0.0-alpha.4+53 environment: sdk: ^3.10.0 diff --git a/pubspec_ios.yaml b/pubspec_ios.yaml index d0d9087a..fbee8eb3 100644 --- a/pubspec_ios.yaml +++ b/pubspec_ios.yaml @@ -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.2+51 +version: 3.0.0-alpha.4+53 environment: sdk: ^3.10.0 From 8a7815268ba76dd9f614011c7977e2bb18797580 Mon Sep 17 00:00:00 2001 From: zarzet Date: Tue, 13 Jan 2026 17:41:24 +0700 Subject: [PATCH 22/35] security: improve extension sandbox security - Add file permission requirement for extensions - Bump version to 3.0.0-beta.1 --- CHANGELOG.md | 26 +++++- go_backend/exports.go | 10 +++ go_backend/extension_manifest.go | 1 + go_backend/extension_providers.go | 73 +++++++++++++---- go_backend/extension_runtime.go | 85 +++++++++++++++++-- go_backend/extension_runtime_file.go | 70 ++++++++++++++-- go_backend/extension_runtime_http.go | 6 ++ go_backend/extension_test.go | 110 +++++++++++++++++++++++++ go_backend/extension_timeout.go | 118 +++++++++++++++++++++++++++ go_backend/qobuz.go | 2 +- lib/constants/app_info.dart | 4 +- pubspec.yaml | 2 +- pubspec_ios.yaml | 2 +- 13 files changed, 473 insertions(+), 36 deletions(-) create mode 100644 go_backend/extension_timeout.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 16e7ff7a..dd6d649d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,26 @@ # Changelog -## [3.0.0-alpha.4] - Upcoming +## [3.0.0-beta.1] - 2026-01-13 + +### Security + +- Improved extension sandbox security + +### Changed + +- **Extension Manifest**: New `file` permission required for file operations + ```json + "permissions": { + "network": ["api.example.com"], + "storage": true, + "file": true + } + ``` + Extensions that need to download files must declare `"file": true` in manifest. + +--- + +## [3.0.0-alpha.4] - 2026-01-12 ### Added @@ -15,7 +35,7 @@ - **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 + - 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 @@ -36,7 +56,7 @@ - Updated `docs/EXTENSION_DEVELOPMENT.md`: - Added Custom URL Handler section with examples - - Added `handleURL` function documentation + - Added `handleUrl` function documentation - Added URL pattern examples for YouTube, SoundCloud, Bandcamp - Added `utils.hmacSHA1` documentation with TOTP example diff --git a/go_backend/exports.go b/go_backend/exports.go index 30a07c80..0ec9c6b8 100644 --- a/go_backend/exports.go +++ b/go_backend/exports.go @@ -208,6 +208,11 @@ func DownloadTrack(requestJSON string) (string, error) { req.AlbumArtist = strings.TrimSpace(req.AlbumArtist) req.OutputDir = strings.TrimSpace(req.OutputDir) + // Add output directory to allowed download dirs for extensions + if req.OutputDir != "" { + AddAllowedDownloadDir(req.OutputDir) + } + var result DownloadResult var err error @@ -345,6 +350,11 @@ func DownloadWithFallback(requestJSON string) (string, error) { req.AlbumArtist = strings.TrimSpace(req.AlbumArtist) req.OutputDir = strings.TrimSpace(req.OutputDir) + // Add output directory to allowed download dirs for extensions + if req.OutputDir != "" { + AddAllowedDownloadDir(req.OutputDir) + } + // Build service order starting with preferred service allServices := []string{"tidal", "qobuz", "amazon"} preferredService := req.Service diff --git a/go_backend/extension_manifest.go b/go_backend/extension_manifest.go index 667ecce5..7a7a37f3 100644 --- a/go_backend/extension_manifest.go +++ b/go_backend/extension_manifest.go @@ -29,6 +29,7 @@ const ( type ExtensionPermissions struct { Network []string `json:"network"` // List of allowed domains Storage bool `json:"storage"` // Whether extension can use storage API + File bool `json:"file"` // Whether extension can use file API } // ExtensionSetting defines a configurable setting for an extension diff --git a/go_backend/extension_providers.go b/go_backend/extension_providers.go index b24c5645..e98aac57 100644 --- a/go_backend/extension_providers.go +++ b/go_backend/extension_providers.go @@ -7,6 +7,7 @@ import ( "path/filepath" "strings" "sync" + "time" "github.com/dop251/goja" ) @@ -140,8 +141,11 @@ func (p *ExtensionProviderWrapper) SearchTracks(query string, limit int) (*ExtSe })() `, query, limit) - result, err := p.vm.RunString(script) + result, err := RunWithTimeoutAndRecover(p.vm, script, DefaultJSTimeout) if err != nil { + if IsTimeoutError(err) { + return nil, fmt.Errorf("searchTracks timeout: extension took too long to respond") + } return nil, fmt.Errorf("searchTracks failed: %w", err) } @@ -188,8 +192,11 @@ func (p *ExtensionProviderWrapper) GetTrack(trackID string) (*ExtTrackMetadata, })() `, trackID) - result, err := p.vm.RunString(script) + result, err := RunWithTimeoutAndRecover(p.vm, script, DefaultJSTimeout) if err != nil { + if IsTimeoutError(err) { + return nil, fmt.Errorf("getTrack timeout: extension took too long to respond") + } return nil, fmt.Errorf("getTrack failed: %w", err) } @@ -231,8 +238,11 @@ func (p *ExtensionProviderWrapper) GetAlbum(albumID string) (*ExtAlbumMetadata, })() `, albumID) - result, err := p.vm.RunString(script) + result, err := RunWithTimeoutAndRecover(p.vm, script, DefaultJSTimeout) if err != nil { + if IsTimeoutError(err) { + return nil, fmt.Errorf("getAlbum timeout: extension took too long to respond") + } return nil, fmt.Errorf("getAlbum failed: %w", err) } @@ -277,8 +287,11 @@ func (p *ExtensionProviderWrapper) GetArtist(artistID string) (*ExtArtistMetadat })() `, artistID) - result, err := p.vm.RunString(script) + result, err := RunWithTimeoutAndRecover(p.vm, script, DefaultJSTimeout) if err != nil { + if IsTimeoutError(err) { + return nil, fmt.Errorf("getArtist timeout: extension took too long to respond") + } return nil, fmt.Errorf("getArtist failed: %w", err) } @@ -322,8 +335,11 @@ func (p *ExtensionProviderWrapper) CheckAvailability(isrc, trackName, artistName })() `, isrc, trackName, artistName) - result, err := p.vm.RunString(script) + result, err := RunWithTimeoutAndRecover(p.vm, script, DefaultJSTimeout) if err != nil { + if IsTimeoutError(err) { + return nil, fmt.Errorf("checkAvailability timeout: extension took too long to respond") + } return nil, fmt.Errorf("checkAvailability failed: %w", err) } @@ -364,8 +380,11 @@ func (p *ExtensionProviderWrapper) GetDownloadURL(trackID, quality string) (*Ext })() `, trackID, quality) - result, err := p.vm.RunString(script) + result, err := RunWithTimeoutAndRecover(p.vm, script, DefaultJSTimeout) if err != nil { + if IsTimeoutError(err) { + return nil, fmt.Errorf("getDownloadUrl timeout: extension took too long to respond") + } return nil, fmt.Errorf("getDownloadUrl failed: %w", err) } @@ -387,6 +406,9 @@ func (p *ExtensionProviderWrapper) GetDownloadURL(trackID, quality string) (*Ext return &urlResult, nil } +// ExtDownloadTimeout is longer for extension download operations (5 minutes) +const ExtDownloadTimeout = 5 * time.Minute + // Download downloads a track with progress reporting func (p *ExtensionProviderWrapper) Download(trackID, quality, outputPath string, onProgress func(percent int)) (*ExtDownloadResult, error) { if !p.extension.Manifest.IsDownloadProvider() { @@ -424,12 +446,19 @@ func (p *ExtensionProviderWrapper) Download(trackID, quality, outputPath string, })() `, trackID, quality, outputPath) - result, err := p.vm.RunString(script) + // Use longer timeout for downloads (5 minutes) + result, err := RunWithTimeoutAndRecover(p.vm, script, ExtDownloadTimeout) if err != nil { + errMsg := err.Error() + errType := "script_error" + if IsTimeoutError(err) { + errMsg = "download timeout: extension took too long to complete" + errType = "timeout" + } return &ExtDownloadResult{ Success: false, - ErrorMessage: err.Error(), - ErrorType: "script_error", + ErrorMessage: errMsg, + ErrorType: errType, }, nil } @@ -947,8 +976,11 @@ func (p *ExtensionProviderWrapper) CustomSearch(query string, options map[string })() `, query, string(optionsJSON)) - result, err := p.vm.RunString(script) + result, err := RunWithTimeoutAndRecover(p.vm, script, DefaultJSTimeout) if err != nil { + if IsTimeoutError(err) { + return nil, fmt.Errorf("customSearch timeout: extension took too long to respond") + } return nil, fmt.Errorf("customSearch failed: %w", err) } @@ -1013,8 +1045,11 @@ func (p *ExtensionProviderWrapper) HandleURL(url string) (*ExtURLHandleResult, e })() `, url) - result, err := p.vm.RunString(script) + result, err := RunWithTimeoutAndRecover(p.vm, script, DefaultJSTimeout) if err != nil { + if IsTimeoutError(err) { + return nil, fmt.Errorf("handleUrl timeout: extension took too long to respond") + } return nil, fmt.Errorf("handleUrl failed: %w", err) } @@ -1076,8 +1111,11 @@ func (p *ExtensionProviderWrapper) MatchTrack(sourceTrack map[string]interface{} })() `, string(sourceJSON), string(candidatesJSON)) - result, err := p.vm.RunString(script) + result, err := RunWithTimeoutAndRecover(p.vm, script, DefaultJSTimeout) if err != nil { + if IsTimeoutError(err) { + return nil, fmt.Errorf("matchTrack timeout: extension took too long to respond") + } return nil, fmt.Errorf("matchTrack failed: %w", err) } @@ -1111,6 +1149,9 @@ type PostProcessResult struct { SampleRate int `json:"sample_rate,omitempty"` } +// PostProcessTimeout is longer for post-processing (2 minutes) +const PostProcessTimeout = 2 * time.Minute + // PostProcess runs post-processing hooks on a downloaded file func (p *ExtensionProviderWrapper) PostProcess(filePath string, metadata map[string]interface{}, hookID string) (*PostProcessResult, error) { if !p.extension.Manifest.HasPostProcessing() { @@ -1132,11 +1173,15 @@ func (p *ExtensionProviderWrapper) PostProcess(filePath string, metadata map[str })() `, filePath, string(metadataJSON), hookID) - result, err := p.vm.RunString(script) + result, err := RunWithTimeoutAndRecover(p.vm, script, PostProcessTimeout) if err != nil { + errMsg := err.Error() + if IsTimeoutError(err) { + errMsg = "postProcess timeout: extension took too long to complete" + } return &PostProcessResult{ Success: false, - Error: err.Error(), + Error: errMsg, }, nil } diff --git a/go_backend/extension_runtime.go b/go_backend/extension_runtime.go index 77c8c311..81a8db45 100644 --- a/go_backend/extension_runtime.go +++ b/go_backend/extension_runtime.go @@ -10,6 +10,9 @@ import ( "github.com/dop251/goja" ) +// Default timeout for JS execution (30 seconds) +const DefaultJSTimeout = 30 * time.Second + // Global auth state for extensions (stores pending auth codes) var ( extensionAuthState = make(map[string]*ExtensionAuthState) @@ -101,20 +104,88 @@ func NewExtensionRuntime(ext *LoadedExtension) *ExtensionRuntime { // Create a cookie jar for this extension jar, _ := newSimpleCookieJar() - client := &http.Client{ - Timeout: 30 * time.Second, - Jar: jar, - } - - return &ExtensionRuntime{ + runtime := &ExtensionRuntime{ extensionID: ext.ID, manifest: ext.Manifest, settings: make(map[string]interface{}), - httpClient: client, cookieJar: jar, dataDir: ext.DataDir, vm: ext.VM, } + + // Create HTTP client with redirect validation to prevent SSRF via open redirect + client := &http.Client{ + Timeout: 30 * time.Second, + Jar: jar, + CheckRedirect: func(req *http.Request, via []*http.Request) error { + // Validate redirect target domain against allowed domains + domain := req.URL.Hostname() + if !ext.Manifest.IsDomainAllowed(domain) { + GoLog("[Extension:%s] Redirect blocked: domain '%s' not in allowed list\n", ext.ID, domain) + return &RedirectBlockedError{Domain: domain} + } + // Also block redirects to private/local networks (SSRF protection) + if isPrivateIP(domain) { + GoLog("[Extension:%s] Redirect blocked: private IP '%s'\n", ext.ID, domain) + return &RedirectBlockedError{Domain: domain, IsPrivate: true} + } + // Default redirect limit (10) + if len(via) >= 10 { + return http.ErrUseLastResponse + } + return nil + }, + } + runtime.httpClient = client + + return runtime +} + +// RedirectBlockedError is returned when a redirect is blocked due to domain validation +type RedirectBlockedError struct { + Domain string + IsPrivate bool +} + +func (e *RedirectBlockedError) Error() string { + if e.IsPrivate { + return "redirect blocked: private/local network access denied" + } + return "redirect blocked: domain '" + e.Domain + "' not in allowed list" +} + +// isPrivateIP checks if a hostname resolves to a private/local IP address +func isPrivateIP(host string) bool { + // Block common private network patterns + // This is a simple check - for production, consider DNS resolution + privatePatterns := []string{ + "localhost", + "127.", + "10.", + "172.16.", "172.17.", "172.18.", "172.19.", + "172.20.", "172.21.", "172.22.", "172.23.", + "172.24.", "172.25.", "172.26.", "172.27.", + "172.28.", "172.29.", "172.30.", "172.31.", + "192.168.", + "169.254.", // Link-local + "::1", // IPv6 localhost + "fc00:", // IPv6 private + "fe80:", // IPv6 link-local + } + + hostLower := host + for _, pattern := range privatePatterns { + if hostLower == pattern || len(hostLower) > len(pattern) && hostLower[:len(pattern)] == pattern { + return true + } + } + + // Also block .local domains + if len(host) > 6 && host[len(host)-6:] == ".local" { + return true + } + + return false } // simpleCookieJar is a simple in-memory cookie jar diff --git a/go_backend/extension_runtime_file.go b/go_backend/extension_runtime_file.go index 44b4ffd0..82ccec3b 100644 --- a/go_backend/extension_runtime_file.go +++ b/go_backend/extension_runtime_file.go @@ -8,25 +8,81 @@ import ( "os" "path/filepath" "strings" + "sync" "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 +// List of allowed directories for file operations (set by Go backend for download operations) +var ( + allowedDownloadDirs []string + allowedDownloadDirsMu sync.RWMutex +) + +// SetAllowedDownloadDirs sets the list of directories where extensions can write files +// This should be called by the Go backend when setting up download paths +func SetAllowedDownloadDirs(dirs []string) { + allowedDownloadDirsMu.Lock() + defer allowedDownloadDirsMu.Unlock() + allowedDownloadDirs = dirs + GoLog("[Extension] Allowed download directories set: %v\n", dirs) +} + +// AddAllowedDownloadDir adds a directory to the allowed list +func AddAllowedDownloadDir(dir string) { + allowedDownloadDirsMu.Lock() + defer allowedDownloadDirsMu.Unlock() + absDir, err := filepath.Abs(dir) + if err == nil { + allowedDownloadDirs = append(allowedDownloadDirs, absDir) + } +} + +// isPathInAllowedDirs checks if an absolute path is within any allowed directory +func isPathInAllowedDirs(absPath string) bool { + allowedDownloadDirsMu.RLock() + defer allowedDownloadDirsMu.RUnlock() + + for _, allowedDir := range allowedDownloadDirs { + if strings.HasPrefix(absPath, allowedDir) { + return true + } + } + return false +} + +// validatePath checks if the path is within the extension's sandbox +// Security: Absolute paths are BLOCKED unless they're in allowed download directories +// Extensions should use relative paths for their own data storage func (r *ExtensionRuntime) validatePath(path string) (string, error) { + // Check if extension has file permission + if !r.manifest.Permissions.File { + return "", fmt.Errorf("file access denied: extension does not have 'file' permission") + } + // 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 + // SECURITY: Block absolute paths by default + // Only allow if path is in explicitly allowed download directories if filepath.IsAbs(cleanPath) { - return cleanPath, nil + absPath, err := filepath.Abs(cleanPath) + if err != nil { + return "", fmt.Errorf("invalid path: %w", err) + } + + // Check if path is in allowed download directories + if isPathInAllowedDirs(absPath) { + return absPath, nil + } + + // Block all other absolute paths + return "", fmt.Errorf("file access denied: absolute paths are not allowed. Use relative paths within extension sandbox") } - // For relative paths, join with data directory + // For relative paths, join with data directory (extension's sandbox) fullPath := filepath.Join(r.dataDir, cleanPath) // Resolve to absolute path @@ -35,7 +91,7 @@ func (r *ExtensionRuntime) validatePath(path string) (string, error) { return "", fmt.Errorf("invalid path: %w", err) } - // Ensure path is within data directory + // Ensure path is within data directory (prevent path traversal) absDataDir, _ := filepath.Abs(r.dataDir) if !strings.HasPrefix(absPath, absDataDir) { return "", fmt.Errorf("file access denied: path '%s' is outside sandbox", path) diff --git a/go_backend/extension_runtime_http.go b/go_backend/extension_runtime_http.go index c27bd7f2..61c7b36c 100644 --- a/go_backend/extension_runtime_http.go +++ b/go_backend/extension_runtime_http.go @@ -29,6 +29,12 @@ func (r *ExtensionRuntime) validateDomain(urlStr string) error { } domain := parsed.Hostname() + + // Block private/local network access (SSRF protection) + if isPrivateIP(domain) { + return fmt.Errorf("network access denied: private/local network '%s' not allowed", domain) + } + if !r.manifest.IsDomainAllowed(domain) { return fmt.Errorf("network access denied: domain '%s' not in allowed list", domain) } diff --git a/go_backend/extension_test.go b/go_backend/extension_test.go index 5cc552a1..4045279c 100644 --- a/go_backend/extension_test.go +++ b/go_backend/extension_test.go @@ -1,6 +1,7 @@ package gobackend import ( + "path/filepath" "testing" "github.com/dop251/goja" @@ -137,6 +138,9 @@ func TestExtensionRuntime_FileSandbox(t *testing.T) { ID: "test-ext", Manifest: &ExtensionManifest{ Name: "test-ext", + Permissions: ExtensionPermissions{ + File: true, // Enable file permission for test + }, }, DataDir: tempDir, } @@ -166,6 +170,36 @@ func TestExtensionRuntime_FileSandbox(t *testing.T) { if nestedPath == "" { t.Error("Expected non-empty nested path") } + + // Test absolute path should be blocked (security fix) + // Use platform-appropriate absolute path + var absPath string + if filepath.IsAbs("C:\\Windows\\System32") { + absPath = "C:\\Windows\\System32\\test.txt" // Windows + } else { + absPath = "/etc/passwd" // Unix + } + _, err = runtime.validatePath(absPath) + if err == nil { + t.Error("Expected absolute path to be blocked") + } + + // Test that extension without file permission is blocked + extNoFile := &LoadedExtension{ + ID: "test-ext-no-file", + Manifest: &ExtensionManifest{ + Name: "test-ext-no-file", + Permissions: ExtensionPermissions{ + File: false, // No file permission + }, + }, + DataDir: tempDir, + } + runtimeNoFile := NewExtensionRuntime(extNoFile) + _, err = runtimeNoFile.validatePath("test.txt") + if err == nil { + t.Error("Expected file access to be denied without file permission") + } } func TestExtensionRuntime_UtilityFunctions(t *testing.T) { @@ -217,3 +251,79 @@ func TestExtensionRuntime_UtilityFunctions(t *testing.T) { t.Error("Expected non-empty JSON string") } } + +func TestExtensionRuntime_SSRFProtection(t *testing.T) { + // Create extension with limited network permissions + ext := &LoadedExtension{ + ID: "test-ext", + Manifest: &ExtensionManifest{ + Name: "test-ext", + Permissions: ExtensionPermissions{ + Network: []string{"api.example.com"}, + }, + }, + DataDir: t.TempDir(), + } + + runtime := NewExtensionRuntime(ext) + + // Test that private IPs are blocked (SSRF protection) + privateIPs := []string{ + "http://localhost/admin", + "http://127.0.0.1/admin", + "http://192.168.1.1/admin", + "http://10.0.0.1/admin", + "http://172.16.0.1/admin", + "http://169.254.169.254/latest/meta-data/", // AWS metadata + "http://router.local/admin", + } + + for _, url := range privateIPs { + err := runtime.validateDomain(url) + if err == nil { + t.Errorf("Expected private IP/host '%s' to be blocked", url) + } + } + + // Test that allowed public domain still works + if err := runtime.validateDomain("https://api.example.com/path"); err != nil { + t.Errorf("Expected api.example.com to be allowed, got error: %v", err) + } +} + +func TestIsPrivateIP(t *testing.T) { + tests := []struct { + host string + expected bool + }{ + // Private IPs should be blocked + {"localhost", true}, + {"127.0.0.1", true}, + {"127.0.0.2", true}, + {"10.0.0.1", true}, + {"10.255.255.255", true}, + {"172.16.0.1", true}, + {"172.31.255.255", true}, + {"192.168.0.1", true}, + {"192.168.255.255", true}, + {"169.254.169.254", true}, // AWS metadata + {"router.local", true}, + {"mydevice.local", true}, + + // Public IPs should be allowed + {"8.8.8.8", false}, + {"1.1.1.1", false}, + {"api.example.com", false}, + {"google.com", false}, + {"172.15.0.1", false}, // Just outside 172.16-31 range + {"172.32.0.1", false}, // Just outside 172.16-31 range + {"192.167.0.1", false}, // Not 192.168.x.x + } + + for _, tt := range tests { + result := isPrivateIP(tt.host) + if result != tt.expected { + t.Errorf("isPrivateIP(%s) = %v, expected %v", tt.host, result, tt.expected) + } + } +} diff --git a/go_backend/extension_timeout.go b/go_backend/extension_timeout.go new file mode 100644 index 00000000..a55f0464 --- /dev/null +++ b/go_backend/extension_timeout.go @@ -0,0 +1,118 @@ +// Package gobackend provides timeout execution for extension JS code +package gobackend + +import ( + "context" + "fmt" + "sync" + "time" + + "github.com/dop251/goja" +) + +// JSExecutionError represents an error during JS execution +type JSExecutionError struct { + Message string + IsTimeout bool +} + +func (e *JSExecutionError) Error() string { + return e.Message +} + +// RunWithTimeout executes JavaScript code with a timeout +// Returns the result value and any error (including timeout) +func RunWithTimeout(vm *goja.Runtime, script string, timeout time.Duration) (goja.Value, error) { + if timeout <= 0 { + timeout = DefaultJSTimeout + } + + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + + // Channel to receive result + type result struct { + value goja.Value + err error + } + resultCh := make(chan result, 1) + + // Track if we've interrupted + var interrupted bool + var interruptMu sync.Mutex + + // Run script in goroutine + go func() { + defer func() { + if r := recover(); r != nil { + // Check if this was our interrupt + interruptMu.Lock() + wasInterrupted := interrupted + interruptMu.Unlock() + + if wasInterrupted { + resultCh <- result{nil, &JSExecutionError{ + Message: "execution timeout exceeded", + IsTimeout: true, + }} + } else { + resultCh <- result{nil, fmt.Errorf("panic during execution: %v", r)} + } + } + }() + + val, err := vm.RunString(script) + resultCh <- result{val, err} + }() + + // Wait for result or timeout + select { + case res := <-resultCh: + return res.value, res.err + case <-ctx.Done(): + // Timeout - interrupt the VM + interruptMu.Lock() + interrupted = true + interruptMu.Unlock() + + vm.Interrupt("execution timeout") + + // Wait a bit for the goroutine to finish + select { + case res := <-resultCh: + // If we got a result after interrupt, it might be the timeout error + if res.err != nil { + return nil, res.err + } + return nil, &JSExecutionError{ + Message: "execution timeout exceeded", + IsTimeout: true, + } + case <-time.After(1 * time.Second): + // Force return timeout error + return nil, &JSExecutionError{ + Message: "execution timeout exceeded (force)", + IsTimeout: true, + } + } + } +} + +// RunWithTimeoutAndRecover runs JS with timeout and clears interrupt state after +// This should be used when you want to continue using the VM after a timeout +func RunWithTimeoutAndRecover(vm *goja.Runtime, script string, timeout time.Duration) (goja.Value, error) { + result, err := RunWithTimeout(vm, script, timeout) + + // Clear any interrupt state so VM can be reused + vm.ClearInterrupt() + + return result, err +} + +// IsTimeoutError checks if an error is a timeout error +func IsTimeoutError(err error) bool { + if jsErr, ok := err.(*JSExecutionError); ok { + return jsErr.IsTimeout + } + return false +} diff --git a/go_backend/qobuz.go b/go_backend/qobuz.go index b4611857..85644886 100644 --- a/go_backend/qobuz.go +++ b/go_backend/qobuz.go @@ -702,7 +702,7 @@ func getQobuzDownloadURLParallel(apis []string, trackID int64, quality string) ( 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)} + resultChan <- qobuzAPIResult{apiURL: api, err: fmt.Errorf("%s", errorResp.Error), duration: time.Since(reqStart)} return } diff --git a/lib/constants/app_info.dart b/lib/constants/app_info.dart index 189410ba..12129fa5 100644 --- a/lib/constants/app_info.dart +++ b/lib/constants/app_info.dart @@ -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.4'; - static const String buildNumber = '53'; + static const String version = '3.0.0-beta.1'; + static const String buildNumber = '54'; static const String fullVersion = '$version+$buildNumber'; diff --git a/pubspec.yaml b/pubspec.yaml index 8d28b9d0..348ab27c 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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.4+53 +version: 3.0.0-beta.1+54 environment: sdk: ^3.10.0 diff --git a/pubspec_ios.yaml b/pubspec_ios.yaml index fbee8eb3..a06e36bf 100644 --- a/pubspec_ios.yaml +++ b/pubspec_ios.yaml @@ -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.4+53 +version: 3.0.0-beta.1+54 environment: sdk: ^3.10.0 From 961072e2ac825c67dbc0cc4cf8ca4b3d83bca29d Mon Sep 17 00:00:00 2001 From: zarzet Date: Tue, 13 Jan 2026 17:44:14 +0700 Subject: [PATCH 23/35] security: use per-installation random salt for credential encryption --- go_backend/extension_runtime_storage.go | 58 +++++++++++++++++++++---- 1 file changed, 50 insertions(+), 8 deletions(-) diff --git a/go_backend/extension_runtime_storage.go b/go_backend/extension_runtime_storage.go index 54882a41..a44bfd33 100644 --- a/go_backend/extension_runtime_storage.go +++ b/go_backend/extension_runtime_storage.go @@ -134,12 +134,48 @@ 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[:] +// getSaltPath returns the path to the extension's encryption salt file +func (r *ExtensionRuntime) getSaltPath() string { + return filepath.Join(r.dataDir, ".cred_salt") +} + +// getOrCreateSalt gets existing salt or creates a new random one +func (r *ExtensionRuntime) getOrCreateSalt() ([]byte, error) { + saltPath := r.getSaltPath() + + // Try to read existing salt + salt, err := os.ReadFile(saltPath) + if err == nil && len(salt) == 32 { + return salt, nil + } + + // Generate new random salt (32 bytes) + salt = make([]byte, 32) + if _, err := io.ReadFull(rand.Reader, salt); err != nil { + return nil, fmt.Errorf("failed to generate salt: %w", err) + } + + // Save salt to file + if err := os.WriteFile(saltPath, salt, 0600); err != nil { + return nil, fmt.Errorf("failed to save salt: %w", err) + } + + return salt, nil +} + +// getEncryptionKey derives an encryption key from extension ID + random salt +func (r *ExtensionRuntime) getEncryptionKey() ([]byte, error) { + // Get or create per-installation random salt + salt, err := r.getOrCreateSalt() + if err != nil { + return nil, err + } + + // Combine extension ID + random salt for key derivation + // This makes each installation unique, preventing mass decryption attacks + combined := append([]byte(r.extensionID), salt...) + hash := sha256.Sum256(combined) + return hash[:], nil } // loadCredentials loads and decrypts credentials from disk @@ -154,7 +190,10 @@ func (r *ExtensionRuntime) loadCredentials() (map[string]interface{}, error) { } // Decrypt the data - key := r.getEncryptionKey() + key, err := r.getEncryptionKey() + if err != nil { + return nil, fmt.Errorf("failed to get encryption key: %w", err) + } decrypted, err := decryptAES(data, key) if err != nil { return nil, fmt.Errorf("failed to decrypt credentials: %w", err) @@ -176,7 +215,10 @@ func (r *ExtensionRuntime) saveCredentials(creds map[string]interface{}) error { } // Encrypt the data - key := r.getEncryptionKey() + key, err := r.getEncryptionKey() + if err != nil { + return fmt.Errorf("failed to get encryption key: %w", err) + } encrypted, err := encryptAES(data, key) if err != nil { return fmt.Errorf("failed to encrypt credentials: %w", err) From 13b917d1a0df103e6f4ba19a99c680b9d7c947fc Mon Sep 17 00:00:00 2001 From: zarzet Date: Tue, 13 Jan 2026 17:46:35 +0700 Subject: [PATCH 24/35] fix: preserve directory structure when extracting extension packages --- CHANGELOG.md | 5 +++++ go_backend/extension_manager.go | 36 +++++++++++++++++++++++++++------ 2 files changed, 35 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dd6d649d..b9f50f7e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ### Security - Improved extension sandbox security +- Improved credential encryption with per-installation random salt ### Changed @@ -18,6 +19,10 @@ ``` Extensions that need to download files must declare `"file": true` in manifest. +### Fixed + +- Extension packages now preserve directory structure (subdirectories supported) + --- ## [3.0.0-alpha.4] - 2026-01-12 diff --git a/go_backend/extension_manager.go b/go_backend/extension_manager.go index 045708a6..826569ad 100644 --- a/go_backend/extension_manager.go +++ b/go_backend/extension_manager.go @@ -191,14 +191,26 @@ func (m *ExtensionManager) LoadExtensionFromFile(filePath string) (*LoadedExtens return nil, fmt.Errorf("failed to create extension directory: %w", err) } - // Extract all files + // Extract all files (preserving directory structure) for _, file := range zipReader.File { if file.FileInfo().IsDir() { continue } - // Get relative path within the zip - destPath := filepath.Join(extDir, filepath.Base(file.Name)) + // Preserve relative path within the zip (support subdirectories) + // Clean the path to prevent path traversal attacks + relPath := filepath.Clean(file.Name) + if strings.HasPrefix(relPath, "..") || filepath.IsAbs(relPath) { + GoLog("[Extension] Skipping unsafe path in archive: %s\n", file.Name) + continue + } + destPath := filepath.Join(extDir, relPath) + + // Create parent directories if needed + destDir := filepath.Dir(destPath) + if err := os.MkdirAll(destDir, 0755); err != nil { + return nil, fmt.Errorf("failed to create directory %s: %w", destDir, err) + } // Create destination file destFile, err := os.Create(destPath) @@ -604,14 +616,26 @@ func (m *ExtensionManager) UpgradeExtension(filePath string) (*LoadedExtension, return nil, fmt.Errorf("failed to create extension directory: %w", err) } - // Extract all files from new package + // Extract all files from new package (preserving directory structure) for _, file := range zipReader.File { if file.FileInfo().IsDir() { continue } - // Get relative path within the zip - destPath := filepath.Join(extDir, filepath.Base(file.Name)) + // Preserve relative path within the zip (support subdirectories) + // Clean the path to prevent path traversal attacks + relPath := filepath.Clean(file.Name) + if strings.HasPrefix(relPath, "..") || filepath.IsAbs(relPath) { + GoLog("[Extension] Skipping unsafe path in archive: %s\n", file.Name) + continue + } + destPath := filepath.Join(extDir, relPath) + + // Create parent directories if needed + destDir := filepath.Dir(destPath) + if err := os.MkdirAll(destDir, 0755); err != nil { + return nil, fmt.Errorf("failed to create directory %s: %w", destDir, err) + } // Create destination file destFile, err := os.Create(destPath) From 7ca032b3f55c45842a2e2808b3a82863bf5cd8df Mon Sep 17 00:00:00 2001 From: zarzet Date: Tue, 13 Jan 2026 18:18:03 +0700 Subject: [PATCH 25/35] fix: remove unnecessary PopScope to prevent back gesture freeze Removes PopScope wrapper from settings pages that don't need it. PopScope with canPop: true was causing race condition with Android gesture navigation, freezing the app. --- CHANGELOG.md | 1 + lib/screens/settings/about_page.dart | 77 ++++++------- .../settings/appearance_settings_page.dart | 43 ++++--- .../settings/download_settings_page.dart | 87 +++++++-------- lib/screens/settings/log_screen.dart | 105 +++++++++--------- .../settings/options_settings_page.dart | 87 +++++++-------- 6 files changed, 193 insertions(+), 207 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b9f50f7e..1fcc2367 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,7 @@ ### Fixed - Extension packages now preserve directory structure (subdirectories supported) +- Back gesture freeze in settings pages on Android gesture navigation --- diff --git a/lib/screens/settings/about_page.dart b/lib/screens/settings/about_page.dart index c29298f5..c1bbc2aa 100644 --- a/lib/screens/settings/about_page.dart +++ b/lib/screens/settings/about_page.dart @@ -12,46 +12,44 @@ class AboutPage extends StatelessWidget { final colorScheme = Theme.of(context).colorScheme; final topPadding = MediaQuery.of(context).padding.top; - return PopScope( - canPop: true, - child: Scaffold( - body: CustomScrollView( - slivers: [ - // Collapsing App Bar with back button - SliverAppBar( - expandedHeight: 120 + topPadding, - collapsedHeight: kToolbarHeight, - floating: false, - pinned: true, - backgroundColor: colorScheme.surface, - surfaceTintColor: Colors.transparent, - leading: IconButton( - icon: const Icon(Icons.arrow_back), - onPressed: () => Navigator.pop(context), - ), - 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); - // When collapsed (expandRatio=0): left=56 to avoid back button - // When expanded (expandRatio=1): left=24 for normal padding - final leftPadding = 56 - (32 * expandRatio); // 56 -> 24 - return FlexibleSpaceBar( - expandedTitleScale: 1.0, - titlePadding: EdgeInsets.only(left: leftPadding, bottom: 16), - title: Text( - 'About', - style: TextStyle( - fontSize: 20 + (8 * expandRatio), // 20 -> 28 - fontWeight: FontWeight.bold, - color: colorScheme.onSurface, - ), - ), - ); - }, - ), + return Scaffold( + body: CustomScrollView( + slivers: [ + // Collapsing App Bar with back button + SliverAppBar( + expandedHeight: 120 + topPadding, + collapsedHeight: kToolbarHeight, + floating: false, + pinned: true, + backgroundColor: colorScheme.surface, + surfaceTintColor: Colors.transparent, + leading: IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () => Navigator.pop(context), ), + 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); + // When collapsed (expandRatio=0): left=56 to avoid back button + // When expanded (expandRatio=1): left=24 for normal padding + final leftPadding = 56 - (32 * expandRatio); // 56 -> 24 + return FlexibleSpaceBar( + expandedTitleScale: 1.0, + titlePadding: EdgeInsets.only(left: leftPadding, bottom: 16), + title: Text( + 'About', + style: TextStyle( + fontSize: 20 + (8 * expandRatio), // 20 -> 28 + fontWeight: FontWeight.bold, + color: colorScheme.onSurface, + ), + ), + ); + }, + ), + ), // App header card with logo and description SliverToBoxAdapter( @@ -220,7 +218,6 @@ class AboutPage extends StatelessWidget { const SliverToBoxAdapter(child: SizedBox(height: 16)), ], ), - ), ); } diff --git a/lib/screens/settings/appearance_settings_page.dart b/lib/screens/settings/appearance_settings_page.dart index 84129130..88c50de0 100644 --- a/lib/screens/settings/appearance_settings_page.dart +++ b/lib/screens/settings/appearance_settings_page.dart @@ -14,28 +14,26 @@ class AppearanceSettingsPage extends ConsumerWidget { final colorScheme = Theme.of(context).colorScheme; final topPadding = MediaQuery.of(context).padding.top; - return PopScope( - canPop: true, - child: Scaffold( - body: CustomScrollView( - slivers: [ - // Collapsing App Bar with back button - SliverAppBar( - expandedHeight: 120 + topPadding, - collapsedHeight: kToolbarHeight, - floating: false, - pinned: true, - backgroundColor: colorScheme.surface, - surfaceTintColor: Colors.transparent, - leading: IconButton( - icon: const Icon(Icons.arrow_back), - onPressed: () => Navigator.pop(context), - ), - flexibleSpace: _AppBarTitle( - title: 'Appearance', - topPadding: topPadding, - ), + return Scaffold( + body: CustomScrollView( + slivers: [ + // Collapsing App Bar with back button + SliverAppBar( + expandedHeight: 120 + topPadding, + collapsedHeight: kToolbarHeight, + floating: false, + pinned: true, + backgroundColor: colorScheme.surface, + surfaceTintColor: Colors.transparent, + leading: IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () => Navigator.pop(context), ), + flexibleSpace: _AppBarTitle( + title: 'Appearance', + topPadding: topPadding, + ), + ), // Preview Section SliverToBoxAdapter( @@ -131,8 +129,7 @@ class AppearanceSettingsPage extends ConsumerWidget { ), ], ), - ), - ); + ); } } diff --git a/lib/screens/settings/download_settings_page.dart b/lib/screens/settings/download_settings_page.dart index c712621d..0fec3ed3 100644 --- a/lib/screens/settings/download_settings_page.dart +++ b/lib/screens/settings/download_settings_page.dart @@ -22,50 +22,48 @@ class DownloadSettingsPage extends ConsumerWidget { // Check if current service is built-in (supports quality options) final isBuiltInService = _builtInServices.contains(settings.defaultService); - return PopScope( - canPop: true, - child: Scaffold( - body: CustomScrollView( - slivers: [ - // Collapsing App Bar with back button - SliverAppBar( - expandedHeight: 120 + topPadding, - collapsedHeight: kToolbarHeight, - floating: false, - pinned: true, - backgroundColor: colorScheme.surface, - surfaceTintColor: Colors.transparent, - leading: IconButton( - icon: const Icon(Icons.arrow_back), - onPressed: () => Navigator.pop(context), - ), - 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); - final leftPadding = 56 - (32 * expandRatio); // 56 -> 24 - return FlexibleSpaceBar( - expandedTitleScale: 1.0, - titlePadding: EdgeInsets.only( - left: leftPadding, - bottom: 16, - ), - title: Text( - 'Download', - style: TextStyle( - fontSize: 20 + (8 * expandRatio), // 20 -> 28 - fontWeight: FontWeight.bold, - color: colorScheme.onSurface, - ), - ), - ); - }, - ), + return Scaffold( + body: CustomScrollView( + slivers: [ + // Collapsing App Bar with back button + SliverAppBar( + expandedHeight: 120 + topPadding, + collapsedHeight: kToolbarHeight, + floating: false, + pinned: true, + backgroundColor: colorScheme.surface, + surfaceTintColor: Colors.transparent, + leading: IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () => Navigator.pop(context), ), + 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); + final leftPadding = 56 - (32 * expandRatio); // 56 -> 24 + return FlexibleSpaceBar( + expandedTitleScale: 1.0, + titlePadding: EdgeInsets.only( + left: leftPadding, + bottom: 16, + ), + title: Text( + 'Download', + style: TextStyle( + fontSize: 20 + (8 * expandRatio), // 20 -> 28 + fontWeight: FontWeight.bold, + color: colorScheme.onSurface, + ), + ), + ); + }, + ), + ), // Service section const SliverToBoxAdapter( @@ -217,8 +215,7 @@ class DownloadSettingsPage extends ConsumerWidget { const SliverToBoxAdapter(child: SizedBox(height: 32)), ], ), - ), - ); + ); } void _showFormatEditor(BuildContext context, WidgetRef ref, String current) { diff --git a/lib/screens/settings/log_screen.dart b/lib/screens/settings/log_screen.dart index 46ba3c42..5e33ec46 100644 --- a/lib/screens/settings/log_screen.dart +++ b/lib/screens/settings/log_screen.dart @@ -124,60 +124,58 @@ class _LogScreenState extends State { final topPadding = MediaQuery.of(context).padding.top; final logs = _filteredLogs; - return PopScope( - canPop: true, - child: Scaffold( - body: CustomScrollView( - controller: _scrollController, - slivers: [ - // Collapsing App Bar with back button - same as other settings pages - SliverAppBar( - expandedHeight: 120 + topPadding, - collapsedHeight: kToolbarHeight, - floating: false, - pinned: true, - backgroundColor: colorScheme.surface, - surfaceTintColor: Colors.transparent, - leading: IconButton( - icon: const Icon(Icons.arrow_back), - onPressed: () => Navigator.pop(context), + return Scaffold( + body: CustomScrollView( + controller: _scrollController, + slivers: [ + // Collapsing App Bar with back button - same as other settings pages + SliverAppBar( + expandedHeight: 120 + topPadding, + collapsedHeight: kToolbarHeight, + floating: false, + pinned: true, + backgroundColor: colorScheme.surface, + surfaceTintColor: Colors.transparent, + leading: IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () => Navigator.pop(context), + ), + actions: [ + IconButton( + icon: Icon(_autoScroll ? Icons.vertical_align_bottom : Icons.vertical_align_center), + tooltip: _autoScroll ? 'Auto-scroll ON' : 'Auto-scroll OFF', + onPressed: () => setState(() => _autoScroll = !_autoScroll), ), - actions: [ - IconButton( - icon: Icon(_autoScroll ? Icons.vertical_align_bottom : Icons.vertical_align_center), - tooltip: _autoScroll ? 'Auto-scroll ON' : 'Auto-scroll OFF', - onPressed: () => setState(() => _autoScroll = !_autoScroll), - ), - IconButton( - icon: const Icon(Icons.copy), - tooltip: 'Copy logs', - onPressed: _copyLogs, - ), - PopupMenuButton( - icon: const Icon(Icons.more_vert), - onSelected: (value) { - switch (value) { - case 'share': - _shareLogs(); - break; - case 'clear': - _clearLogs(); - break; - } - }, - itemBuilder: (context) => [ - const PopupMenuItem( - value: 'share', - child: ListTile( - leading: Icon(Icons.share), - title: Text('Share logs'), - contentPadding: EdgeInsets.zero, - ), + IconButton( + icon: const Icon(Icons.copy), + tooltip: 'Copy logs', + onPressed: _copyLogs, + ), + PopupMenuButton( + icon: const Icon(Icons.more_vert), + onSelected: (value) { + switch (value) { + case 'share': + _shareLogs(); + break; + case 'clear': + _clearLogs(); + break; + } + }, + itemBuilder: (context) => [ + const PopupMenuItem( + value: 'share', + child: ListTile( + leading: Icon(Icons.share), + title: Text('Share logs'), + contentPadding: EdgeInsets.zero, ), - const PopupMenuItem( - value: 'clear', - child: ListTile( - leading: Icon(Icons.delete_outline), + ), + const PopupMenuItem( + value: 'clear', + child: ListTile( + leading: Icon(Icons.delete_outline), title: Text('Clear logs'), contentPadding: EdgeInsets.zero, ), @@ -380,8 +378,7 @@ class _LogScreenState extends State { const SliverToBoxAdapter(child: SizedBox(height: 32)), ], ), - ), - ); + ); } } diff --git a/lib/screens/settings/options_settings_page.dart b/lib/screens/settings/options_settings_page.dart index 06113cbe..f95c5da3 100644 --- a/lib/screens/settings/options_settings_page.dart +++ b/lib/screens/settings/options_settings_page.dart @@ -17,50 +17,48 @@ class OptionsSettingsPage extends ConsumerWidget { final colorScheme = Theme.of(context).colorScheme; final topPadding = MediaQuery.of(context).padding.top; - return PopScope( - canPop: true, - child: Scaffold( - body: CustomScrollView( - slivers: [ - // Collapsing App Bar with back button - SliverAppBar( - expandedHeight: 120 + topPadding, - collapsedHeight: kToolbarHeight, - floating: false, - pinned: true, - backgroundColor: colorScheme.surface, - surfaceTintColor: Colors.transparent, - leading: IconButton( - icon: const Icon(Icons.arrow_back), - onPressed: () => Navigator.pop(context), - ), - 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); - final leftPadding = 56 - (32 * expandRatio); // 56 -> 24 - return FlexibleSpaceBar( - expandedTitleScale: 1.0, - titlePadding: EdgeInsets.only( - left: leftPadding, - bottom: 16, - ), - title: Text( - 'Options', - style: TextStyle( - fontSize: 20 + (8 * expandRatio), // 20 -> 28 - fontWeight: FontWeight.bold, - color: colorScheme.onSurface, - ), - ), - ); - }, - ), + return Scaffold( + body: CustomScrollView( + slivers: [ + // Collapsing App Bar with back button + SliverAppBar( + expandedHeight: 120 + topPadding, + collapsedHeight: kToolbarHeight, + floating: false, + pinned: true, + backgroundColor: colorScheme.surface, + surfaceTintColor: Colors.transparent, + leading: IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () => Navigator.pop(context), ), + 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); + final leftPadding = 56 - (32 * expandRatio); // 56 -> 24 + return FlexibleSpaceBar( + expandedTitleScale: 1.0, + titlePadding: EdgeInsets.only( + left: leftPadding, + bottom: 16, + ), + title: Text( + 'Options', + style: TextStyle( + fontSize: 20 + (8 * expandRatio), // 20 -> 28 + fontWeight: FontWeight.bold, + color: colorScheme.onSurface, + ), + ), + ); + }, + ), + ), // Search Source section const SliverToBoxAdapter( @@ -273,8 +271,7 @@ class OptionsSettingsPage extends ConsumerWidget { const SliverToBoxAdapter(child: SizedBox(height: 32)), ], ), - ), - ); + ); } void _showClearHistoryDialog( From 1ec190bfe79c149626b9fc41f99ac9f162478091 Mon Sep 17 00:00:00 2001 From: zarzet Date: Tue, 13 Jan 2026 20:12:35 +0700 Subject: [PATCH 26/35] fix: multiple bugfixes for v3.0.0-beta.2 --- .gitignore | 3 + CHANGELOG.md | 29 ++++++ go_backend/cover.go | 33 +++++-- go_backend/exports.go | 60 +++++++++++- go_backend/extension_providers.go | 106 +++++++++++++++++++++ go_backend/tidal.go | 2 +- lib/constants/app_info.dart | 4 +- lib/providers/download_queue_provider.dart | 6 ++ lib/screens/queue_tab.dart | 33 +++++-- lib/screens/track_metadata_screen.dart | 32 +++++-- pubspec.yaml | 2 +- pubspec_ios.yaml | 2 +- 12 files changed, 279 insertions(+), 33 deletions(-) diff --git a/.gitignore b/.gitignore index 1a452108..78436f9c 100644 --- a/.gitignore +++ b/.gitignore @@ -53,3 +53,6 @@ ios/.symlinks/ ios/Flutter/Flutter.framework/ ios/Flutter/Flutter.podspec android/app/libs/gobackend-sources.jar + +# Extension folder +extension/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 1fcc2367..b54e5018 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,34 @@ # Changelog +## [3.0.0-beta.2] - 2026-01-13 + +### Fixed + +- **Max Resolution Cover Download**: Fixed cover not upgrading to max resolution on mobile + - Added missing `spotifySize300` constant (300x300 size code) + - Mobile now correctly upgrades 300x300 → 640x640 → max resolution (~2000x2000) + - Matches PC version behavior when "Download max resolution song cover" is enabled + +- **EXISTS: Prefix in File Path**: Fixed "File not found" error in metadata screen after download + - Duplicate detection was adding `EXISTS:` prefix to file paths + - Prefix now stripped before saving to download history + - Legacy history items with prefix are handled gracefully + +- **History Error Badge**: Fixed error badge showing on history items even when file exists + - `queue_tab.dart` now strips `EXISTS:` prefix before checking file existence + - File open and delete operations also use cleaned path + +- **Extension Artist URL Handler**: Fixed artist pages showing "0 releases" from extensions + - Extension `fetchArtist` now returns correct format: `{ type: "artist", artist: { albums } }` + - Go backend `HandleURLWithExtensionJSON` now includes albums in artist response + - Added `AlbumType` field to `ExtAlbumMetadata` struct + +- **Extension Artist Name in Logs**: Fixed empty artist name in extension track logs + - Now uses `firstArtist` + `otherArtists` instead of deprecated `artists.items` + - Logs correctly show "Fetched track: {title} by {artist}" + +--- + ## [3.0.0-beta.1] - 2026-01-13 ### Security diff --git a/go_backend/cover.go b/go_backend/cover.go index 36c83a90..7e2c5f33 100644 --- a/go_backend/cover.go +++ b/go_backend/cover.go @@ -9,10 +9,20 @@ import ( // Spotify image size codes (same as PC version) const ( - spotifySize640 = "ab67616d0000b273" // 640x640 + spotifySize300 = "ab67616d00001e02" // 300x300 (small) + spotifySize640 = "ab67616d0000b273" // 640x640 (medium) spotifySizeMax = "ab67616d000082c1" // Max resolution (~2000x2000) ) +// convertSmallToMedium upgrades 300x300 cover URL to 640x640 +// Same logic as PC version for consistency +func convertSmallToMedium(imageURL string) string { + if strings.Contains(imageURL, spotifySize300) { + return strings.Replace(imageURL, spotifySize300, spotifySize640, 1) + } + return imageURL +} + // downloadCoverToMemory downloads cover art and returns as bytes (no file creation) // This avoids file permission issues on Android func downloadCoverToMemory(coverURL string, maxQuality bool) ([]byte, error) { @@ -22,11 +32,17 @@ func downloadCoverToMemory(coverURL string, maxQuality bool) ([]byte, error) { fmt.Printf("[Cover] Downloading cover from: %s\n", coverURL) - // Upgrade to max quality if requested - downloadURL := coverURL + // First upgrade small (300) to medium (640) - always do this + downloadURL := convertSmallToMedium(coverURL) + if downloadURL != coverURL { + fmt.Printf("[Cover] Upgraded 300x300 to 640x640: %s\n", downloadURL) + } + + // Then upgrade to max quality if requested if maxQuality { - downloadURL = upgradeToMaxQuality(coverURL) - if downloadURL != coverURL { + maxURL := upgradeToMaxQuality(downloadURL) + if maxURL != downloadURL { + downloadURL = maxURL fmt.Printf("[Cover] Upgraded to max quality URL: %s\n", downloadURL) } } @@ -93,9 +109,12 @@ func GetCoverFromSpotify(imageURL string, maxQuality bool) string { return "" } + // Always upgrade small to medium first + result := convertSmallToMedium(imageURL) + if maxQuality { - return upgradeToMaxQuality(imageURL) + result = upgradeToMaxQuality(result) } - return imageURL + return result } diff --git a/go_backend/exports.go b/go_backend/exports.go index 0ec9c6b8..d9cde03b 100644 --- a/go_backend/exports.go +++ b/go_backend/exports.go @@ -1440,6 +1440,41 @@ func GetAllPendingFFmpegCommandsJSON() (string, error) { // ==================== EXTENSION CUSTOM SEARCH ==================== +// EnrichTrackWithExtensionJSON enriches track metadata using the source extension +// This is called lazily before download starts, allowing extension to fetch real ISRC etc. +func EnrichTrackWithExtensionJSON(extensionID, trackJSON string) (string, error) { + manager := GetExtensionManager() + ext, err := manager.GetExtension(extensionID) + if err != nil { + // Extension not found, return original track + return trackJSON, nil + } + + if !ext.Manifest.IsMetadataProvider() { + // Not a metadata provider, return original + return trackJSON, nil + } + + var track ExtTrackMetadata + if err := json.Unmarshal([]byte(trackJSON), &track); err != nil { + return trackJSON, fmt.Errorf("failed to parse track: %w", err) + } + + provider := NewExtensionProviderWrapper(ext) + enrichedTrack, err := provider.EnrichTrack(&track) + if err != nil { + // Error enriching, return original + return trackJSON, nil + } + + jsonBytes, err := json.Marshal(enrichedTrack) + if err != nil { + return trackJSON, nil + } + + return string(jsonBytes), nil +} + // CustomSearchWithExtensionJSON performs custom search using an extension func CustomSearchWithExtensionJSON(extensionID, query string, optionsJSON string) (string, error) { manager := GetExtensionManager() @@ -1597,11 +1632,34 @@ func HandleURLWithExtensionJSON(url string) (string, error) { // Add artist info if present if result.Artist != nil { - response["artist"] = map[string]interface{}{ + artistResponse := map[string]interface{}{ "id": result.Artist.ID, "name": result.Artist.Name, "image_url": result.Artist.ImageURL, } + + // Add albums if present + if len(result.Artist.Albums) > 0 { + albums := make([]map[string]interface{}, len(result.Artist.Albums)) + for i, album := range result.Artist.Albums { + albumType := album.AlbumType + if albumType == "" { + albumType = "album" + } + albums[i] = map[string]interface{}{ + "id": album.ID, + "name": album.Name, + "artists": album.Artists, + "images": album.CoverURL, + "release_date": album.ReleaseDate, + "total_tracks": album.TotalTracks, + "album_type": albumType, + } + } + artistResponse["albums"] = albums + } + + response["artist"] = artistResponse } jsonBytes, err := json.Marshal(response) diff --git a/go_backend/extension_providers.go b/go_backend/extension_providers.go index e98aac57..336fc1d8 100644 --- a/go_backend/extension_providers.go +++ b/go_backend/extension_providers.go @@ -47,6 +47,7 @@ type ExtAlbumMetadata struct { CoverURL string `json:"cover_url,omitempty"` ReleaseDate string `json:"release_date,omitempty"` TotalTracks int `json:"total_tracks"` + AlbumType string `json:"album_type,omitempty"` Tracks []ExtTrackMetadata `json:"tracks"` ProviderID string `json:"provider_id"` } @@ -314,6 +315,72 @@ func (p *ExtensionProviderWrapper) GetArtist(artistID string) (*ExtArtistMetadat return &artist, nil } +// EnrichTrack enriches track metadata before download (e.g., fetch real ISRC) +// This is called lazily when download starts, not when playlist/album is loaded +// Extension should implement enrichTrack(track) function that returns enriched track +func (p *ExtensionProviderWrapper) EnrichTrack(track *ExtTrackMetadata) (*ExtTrackMetadata, error) { + if !p.extension.Manifest.IsMetadataProvider() { + return track, nil // Not a metadata provider, return as-is + } + + if !p.extension.Enabled { + return track, nil // Extension disabled, return as-is + } + + // Convert track to JSON for passing to JS + trackJSON, err := json.Marshal(track) + if err != nil { + GoLog("[Extension] EnrichTrack: failed to marshal track: %v\n", err) + return track, nil // Return original on error + } + + script := fmt.Sprintf(` + (function() { + if (typeof extension !== 'undefined' && typeof extension.enrichTrack === 'function') { + var track = %s; + return extension.enrichTrack(track); + } + return null; + })() + `, string(trackJSON)) + + result, err := RunWithTimeoutAndRecover(p.vm, script, DefaultJSTimeout) + if err != nil { + if IsTimeoutError(err) { + GoLog("[Extension] EnrichTrack timeout for %s\n", p.extension.ID) + } else { + GoLog("[Extension] EnrichTrack error for %s: %v\n", p.extension.ID, err) + } + return track, nil // Return original on error + } + + // If extension doesn't implement enrichTrack or returns null, return original + if result == nil || goja.IsUndefined(result) || goja.IsNull(result) { + return track, nil + } + + exported := result.Export() + jsonBytes, err := json.Marshal(exported) + if err != nil { + GoLog("[Extension] EnrichTrack: failed to marshal result: %v\n", err) + return track, nil + } + + var enrichedTrack ExtTrackMetadata + if err := json.Unmarshal(jsonBytes, &enrichedTrack); err != nil { + GoLog("[Extension] EnrichTrack: failed to parse enriched track: %v\n", err) + return track, nil + } + + // Preserve provider ID + enrichedTrack.ProviderID = track.ProviderID + + GoLog("[Extension] EnrichTrack: enriched track from %s (ISRC: %s -> %s)\n", + p.extension.ID, track.ISRC, enrichedTrack.ISRC) + + return &enrichedTrack, nil +} + // ==================== Download Provider Methods ==================== // CheckAvailability checks if a track is available for download @@ -624,6 +691,45 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro var lastErr error var skipBuiltIn bool // If source extension has skipBuiltInFallback, don't try built-in providers + // LAZY ENRICHMENT: If track came from an extension, try to enrich metadata (e.g., get real ISRC) + // This is done lazily at download time, not when playlist/album is loaded + if req.Source != "" && !isBuiltInProvider(req.Source) { + ext, err := extManager.GetExtension(req.Source) + if err == nil && ext.Enabled && ext.Error == "" && ext.Manifest.IsMetadataProvider() { + GoLog("[DownloadWithExtensionFallback] Enriching track from extension '%s'...\n", req.Source) + + provider := NewExtensionProviderWrapper(ext) + trackMeta := &ExtTrackMetadata{ + ID: req.SpotifyID, + Name: req.TrackName, + Artists: req.ArtistName, + AlbumName: req.AlbumName, + DurationMS: req.DurationMS, + ISRC: req.ISRC, + ReleaseDate: req.ReleaseDate, + TrackNumber: req.TrackNumber, + DiscNumber: req.DiscNumber, + ProviderID: req.Source, + } + + enrichedTrack, err := provider.EnrichTrack(trackMeta) + if err == nil && enrichedTrack != nil { + // Update request with enriched data + if enrichedTrack.ISRC != "" && enrichedTrack.ISRC != req.ISRC { + GoLog("[DownloadWithExtensionFallback] ISRC enriched: %s -> %s\n", req.ISRC, enrichedTrack.ISRC) + req.ISRC = enrichedTrack.ISRC + } + // Can also update other fields if needed + if enrichedTrack.Name != "" { + req.TrackName = enrichedTrack.Name + } + if enrichedTrack.Artists != "" { + req.ArtistName = enrichedTrack.Artists + } + } + } + } + // If source extension is specified, try it first before the priority list if req.Source != "" && !isBuiltInProvider(req.Source) { GoLog("[DownloadWithExtensionFallback] Track source is extension '%s', trying it first\n", req.Source) diff --git a/go_backend/tidal.go b/go_backend/tidal.go index 4e4cca08..df3d2826 100644 --- a/go_backend/tidal.go +++ b/go_backend/tidal.go @@ -1520,7 +1520,7 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) { } } - // Strategy 2: Try SongLink only if ISRC search failed (slower but more accurate) + // Strategy 2: Try SongLink if we have Spotify ID if track == nil && req.SpotifyID != "" { GoLog("[Tidal] ISRC search failed, trying SongLink...\n") var tidalURL string diff --git a/lib/constants/app_info.dart b/lib/constants/app_info.dart index 12129fa5..dd428f1e 100644 --- a/lib/constants/app_info.dart +++ b/lib/constants/app_info.dart @@ -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-beta.1'; - static const String buildNumber = '54'; + static const String version = '3.0.0-beta.2'; + static const String buildNumber = '56'; static const String fullVersion = '$version+$buildNumber'; diff --git a/lib/providers/download_queue_provider.dart b/lib/providers/download_queue_provider.dart index 69b172d1..52d67db0 100644 --- a/lib/providers/download_queue_provider.dart +++ b/lib/providers/download_queue_provider.dart @@ -1557,6 +1557,12 @@ class DownloadQueueNotifier extends Notifier { if (result['success'] == true) { var filePath = result['file_path'] as String?; + + // Strip EXISTS: prefix from duplicate detection + if (filePath != null && filePath.startsWith('EXISTS:')) { + filePath = filePath.substring(7); // Remove "EXISTS:" prefix + } + _log.i('Download success, file: $filePath'); // Get actual quality from response (if available) diff --git a/lib/screens/queue_tab.dart b/lib/screens/queue_tab.dart index fca21484..a3674035 100644 --- a/lib/screens/queue_tab.dart +++ b/lib/screens/queue_tab.dart @@ -115,7 +115,8 @@ class _QueueTabState extends ConsumerState { final item = items.where((e) => e.id == id).firstOrNull; if (item != null) { try { - final file = File(item.filePath); + final cleanPath = _cleanFilePath(item.filePath); + final file = File(cleanPath); if (await file.exists()) { await file.delete(); } @@ -135,31 +136,43 @@ class _QueueTabState extends ConsumerState { } } + /// Strip EXISTS: prefix from file path (legacy history items) + String _cleanFilePath(String? filePath) { + if (filePath == null) return ''; + if (filePath.startsWith('EXISTS:')) { + return filePath.substring(7); + } + return filePath; + } + bool _checkFileExists(String? filePath) { if (filePath == null) return false; - if (_fileExistsCache.containsKey(filePath)) { - return _fileExistsCache[filePath]!; + final cleanPath = _cleanFilePath(filePath); + if (cleanPath.isEmpty) return false; + if (_fileExistsCache.containsKey(cleanPath)) { + return _fileExistsCache[cleanPath]!; } - if (_pendingChecks.contains(filePath)) { + if (_pendingChecks.contains(cleanPath)) { return true; } if (_fileExistsCache.length >= _maxCacheSize) { _fileExistsCache.remove(_fileExistsCache.keys.first); } - _pendingChecks.add(filePath); + _pendingChecks.add(cleanPath); Future.microtask(() async { - final exists = await File(filePath).exists(); - _pendingChecks.remove(filePath); - if (mounted && _fileExistsCache[filePath] != exists) { - setState(() => _fileExistsCache[filePath] = exists); + final exists = await File(cleanPath).exists(); + _pendingChecks.remove(cleanPath); + if (mounted && _fileExistsCache[cleanPath] != exists) { + setState(() => _fileExistsCache[cleanPath] = exists); } }); return true; } Future _openFile(String filePath) async { + final cleanPath = _cleanFilePath(filePath); try { - await OpenFilex.open(filePath); + await OpenFilex.open(cleanPath); } catch (e) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( diff --git a/lib/screens/track_metadata_screen.dart b/lib/screens/track_metadata_screen.dart index fec533b8..72e52492 100644 --- a/lib/screens/track_metadata_screen.dart +++ b/lib/screens/track_metadata_screen.dart @@ -34,7 +34,13 @@ class _TrackMetadataScreenState extends ConsumerState { } Future _checkFile() async { - final file = File(widget.item.filePath); + // Strip EXISTS: prefix from legacy history items + var filePath = widget.item.filePath; + if (filePath.startsWith('EXISTS:')) { + filePath = filePath.substring(7); + } + + final file = File(filePath); final exists = await file.exists(); int? size; @@ -67,6 +73,12 @@ class _TrackMetadataScreenState extends ConsumerState { int? get discNumber => item.discNumber; String? get releaseDate => item.releaseDate; String? get isrc => item.isrc; + + // Clean filePath - strip EXISTS: prefix from legacy history items + String get cleanFilePath { + final path = item.filePath; + return path.startsWith('EXISTS:') ? path.substring(7) : path; + } int? get bitDepth => item.bitDepth; int? get sampleRate => item.sampleRate; @@ -515,7 +527,7 @@ class _TrackMetadataScreenState extends ConsumerState { } Widget _buildFileInfoCard(BuildContext context, ColorScheme colorScheme, bool fileExists, int? fileSize) { - final fileName = item.filePath.split(Platform.pathSeparator).last; + final fileName = cleanFilePath.split(Platform.pathSeparator).last; final fileExtension = fileName.contains('.') ? fileName.split('.').last.toUpperCase() : 'Unknown'; return Card( @@ -631,7 +643,7 @@ class _TrackMetadataScreenState extends ConsumerState { // File path InkWell( - onTap: () => _copyToClipboard(context, item.filePath), + onTap: () => _copyToClipboard(context, cleanFilePath), borderRadius: BorderRadius.circular(12), child: Container( padding: const EdgeInsets.all(12), @@ -643,7 +655,7 @@ class _TrackMetadataScreenState extends ConsumerState { children: [ Expanded( child: Text( - item.filePath, + cleanFilePath, style: Theme.of(context).textTheme.bodySmall?.copyWith( fontFamily: 'monospace', color: colorScheme.onSurfaceVariant, @@ -776,7 +788,7 @@ class _TrackMetadataScreenState extends ConsumerState { item.spotifyId ?? '', item.trackName, item.artistName, - filePath: _fileExists ? item.filePath : null, // Try embedded lyrics first + filePath: _fileExists ? cleanFilePath : null, // Try embedded lyrics first ).timeout( const Duration(seconds: 20), onTimeout: () => '', // Return empty string on timeout @@ -833,7 +845,7 @@ class _TrackMetadataScreenState extends ConsumerState { Expanded( flex: 2, child: FilledButton.icon( - onPressed: fileExists ? () => _openFile(context, item.filePath) : null, + onPressed: fileExists ? () => _openFile(context, cleanFilePath) : null, icon: const Icon(Icons.play_arrow), label: const Text('Play'), style: FilledButton.styleFrom( @@ -890,7 +902,7 @@ class _TrackMetadataScreenState extends ConsumerState { title: const Text('Copy file path'), onTap: () { Navigator.pop(context); - _copyToClipboard(context, item.filePath); + _copyToClipboard(context, cleanFilePath); }, ), ListTile( @@ -933,7 +945,7 @@ class _TrackMetadataScreenState extends ConsumerState { onPressed: () async { // Delete the file first try { - final file = File(item.filePath); + final file = File(cleanFilePath); if (await file.exists()) { await file.delete(); } @@ -984,7 +996,7 @@ class _TrackMetadataScreenState extends ConsumerState { } Future _shareFile(BuildContext context) async { - final file = File(item.filePath); + final file = File(cleanFilePath); if (!await file.exists()) { if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar( @@ -996,7 +1008,7 @@ class _TrackMetadataScreenState extends ConsumerState { await SharePlus.instance.share( ShareParams( - files: [XFile(item.filePath)], + files: [XFile(cleanFilePath)], text: '${item.trackName} - ${item.artistName}', ), ); diff --git a/pubspec.yaml b/pubspec.yaml index 348ab27c..a3e97e20 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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-beta.1+54 +version: 3.0.0-beta.2+56 environment: sdk: ^3.10.0 diff --git a/pubspec_ios.yaml b/pubspec_ios.yaml index a06e36bf..2864bbe2 100644 --- a/pubspec_ios.yaml +++ b/pubspec_ios.yaml @@ -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-beta.1+54 +version: 3.0.0-beta.2+56 environment: sdk: ^3.10.0 From 6a886c5276e458443f29cf9c536e34dacb1be59b Mon Sep 17 00:00:00 2001 From: zarzet Date: Tue, 13 Jan 2026 20:31:05 +0700 Subject: [PATCH 27/35] fix: handle Japanese artist name order in Tidal/Qobuz matching --- CHANGELOG.md | 5 +++++ go_backend/qobuz.go | 43 +++++++++++++++++++++++++++++++++++++++++++ go_backend/tidal.go | 43 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 91 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index b54e5018..c48744ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,11 @@ - Now uses `firstArtist` + `otherArtists` instead of deprecated `artists.items` - Logs correctly show "Fetched track: {title} by {artist}" +- **Japanese Artist Name Order**: Fixed artist mismatch for Japanese names with different order + - "Sawano Hiroyuki" vs "Hiroyuki Sawano" now correctly matches + - Added `sameWordsUnordered` check to both Tidal and Qobuz artist matching + - Handles Japanese name order (family name first) vs Western name order (given name first) + --- ## [3.0.0-beta.1] - 2026-01-13 diff --git a/go_backend/qobuz.go b/go_backend/qobuz.go index 85644886..1d18f632 100644 --- a/go_backend/qobuz.go +++ b/go_backend/qobuz.go @@ -84,6 +84,12 @@ func qobuzArtistsMatch(expectedArtist, foundArtist string) bool { return true } + // Check if same words in different order (e.g., "Sawano Hiroyuki" vs "Hiroyuki Sawano") + if qobuzSameWordsUnordered(expectedFirst, foundFirst) { + GoLog("[Qobuz] Artist names have same words in different order, assuming match: '%s' vs '%s'\n", expectedArtist, foundArtist) + return true + } + // If scripts are TRULY different (Latin vs CJK/Arabic/Cyrillic), assume match (transliteration) // Don't treat Latin Extended (Polish, French, etc.) as different script expectedLatin := qobuzIsLatinScript(expectedArtist) @@ -96,6 +102,43 @@ func qobuzArtistsMatch(expectedArtist, foundArtist string) bool { return false } +// qobuzSameWordsUnordered checks if two strings have the same words regardless of order +// Useful for Japanese names: "Sawano Hiroyuki" vs "Hiroyuki Sawano" +func qobuzSameWordsUnordered(a, b string) bool { + wordsA := strings.Fields(a) + wordsB := strings.Fields(b) + + // Must have same number of words + if len(wordsA) != len(wordsB) || len(wordsA) == 0 { + return false + } + + // Sort and compare + sortedA := make([]string, len(wordsA)) + sortedB := make([]string, len(wordsB)) + copy(sortedA, wordsA) + copy(sortedB, wordsB) + + // Simple bubble sort (usually just 2-3 words) + for i := 0; i < len(sortedA)-1; i++ { + for j := i + 1; j < len(sortedA); j++ { + if sortedA[i] > sortedA[j] { + sortedA[i], sortedA[j] = sortedA[j], sortedA[i] + } + if sortedB[i] > sortedB[j] { + sortedB[i], sortedB[j] = sortedB[j], sortedB[i] + } + } + } + + for i := range sortedA { + if sortedA[i] != sortedB[i] { + return false + } + } + return true +} + // qobuzTitlesMatch checks if track titles are similar enough func qobuzTitlesMatch(expectedTitle, foundTitle string) bool { normExpected := strings.ToLower(strings.TrimSpace(expectedTitle)) diff --git a/go_backend/tidal.go b/go_backend/tidal.go index df3d2826..30ed3321 100644 --- a/go_backend/tidal.go +++ b/go_backend/tidal.go @@ -1273,6 +1273,12 @@ func artistsMatch(spotifyArtist, tidalArtist string) bool { return true } + // Check if same words in different order (e.g., "Sawano Hiroyuki" vs "Hiroyuki Sawano") + if sameWordsUnordered(spotifyFirst, tidalFirst) { + GoLog("[Tidal] Artist names have same words in different order, assuming match: '%s' vs '%s'\n", spotifyArtist, tidalArtist) + return true + } + // If scripts are TRULY different (Latin vs CJK/Arabic/Cyrillic), assume match (transliteration) // Don't treat Latin Extended (Polish, French, etc.) as different script // This handles cases like "鈴木雅之" vs "Masayuki Suzuki" @@ -1286,6 +1292,43 @@ func artistsMatch(spotifyArtist, tidalArtist string) bool { return false } +// sameWordsUnordered checks if two strings have the same words regardless of order +// Useful for Japanese names: "Sawano Hiroyuki" vs "Hiroyuki Sawano" +func sameWordsUnordered(a, b string) bool { + wordsA := strings.Fields(a) + wordsB := strings.Fields(b) + + // Must have same number of words + if len(wordsA) != len(wordsB) || len(wordsA) == 0 { + return false + } + + // Sort and compare + sortedA := make([]string, len(wordsA)) + sortedB := make([]string, len(wordsB)) + copy(sortedA, wordsA) + copy(sortedB, wordsB) + + // Simple bubble sort (usually just 2-3 words) + for i := 0; i < len(sortedA)-1; i++ { + for j := i + 1; j < len(sortedA); j++ { + if sortedA[i] > sortedA[j] { + sortedA[i], sortedA[j] = sortedA[j], sortedA[i] + } + if sortedB[i] > sortedB[j] { + sortedB[i], sortedB[j] = sortedB[j], sortedB[i] + } + } + } + + for i := range sortedA { + if sortedA[i] != sortedB[i] { + return false + } + } + return true +} + // titlesMatch checks if track titles are similar enough func titlesMatch(expectedTitle, foundTitle string) bool { normExpected := strings.ToLower(strings.TrimSpace(expectedTitle)) From e049f9b868ac96e26ffe7d84e3bc838dffb750ea Mon Sep 17 00:00:00 2001 From: zarzet Date: Tue, 13 Jan 2026 20:55:46 +0700 Subject: [PATCH 28/35] fix: improve artist matching for multi-artist tracks and add cover logging --- CHANGELOG.md | 10 +++++++ go_backend/cover.go | 24 +++++++++++++--- go_backend/qobuz.go | 67 +++++++++++++++++++++++++++++---------------- go_backend/tidal.go | 67 +++++++++++++++++++++++++++++---------------- 4 files changed, 118 insertions(+), 50 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c48744ba..6fb47087 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,16 @@ - Added `sameWordsUnordered` check to both Tidal and Qobuz artist matching - Handles Japanese name order (family name first) vs Western name order (given name first) +- **Multi-Artist Matching**: Fixed artist mismatch for collaboration tracks + - "RADWIMPS feat. Toko Miura" now matches when Qobuz/Tidal only shows "Toko Miura" + - Split artists by separators (`, `, ` feat. `, ` ft. `, ` & `, ` and `, ` x `) + - Match if ANY expected artist matches ANY found artist + +- **Cover Download Logging**: Improved cover download logs for debugging + - Shows original URL, upgrade steps, and final URL + - Displays estimated resolution based on file size + - Logs now appear in Settings > Logs via GoLog + --- ## [3.0.0-beta.1] - 2026-01-13 diff --git a/go_backend/cover.go b/go_backend/cover.go index 7e2c5f33..d8e6bcf3 100644 --- a/go_backend/cover.go +++ b/go_backend/cover.go @@ -30,12 +30,12 @@ func downloadCoverToMemory(coverURL string, maxQuality bool) ([]byte, error) { return nil, fmt.Errorf("no cover URL provided") } - fmt.Printf("[Cover] Downloading cover from: %s\n", coverURL) + GoLog("[Cover] Original URL: %s", coverURL) // First upgrade small (300) to medium (640) - always do this downloadURL := convertSmallToMedium(coverURL) if downloadURL != coverURL { - fmt.Printf("[Cover] Upgraded 300x300 to 640x640: %s\n", downloadURL) + GoLog("[Cover] Upgraded 300x300 → 640x640") } // Then upgrade to max quality if requested @@ -43,10 +43,14 @@ func downloadCoverToMemory(coverURL string, maxQuality bool) ([]byte, error) { maxURL := upgradeToMaxQuality(downloadURL) if maxURL != downloadURL { downloadURL = maxURL - fmt.Printf("[Cover] Upgraded to max quality URL: %s\n", downloadURL) + GoLog("[Cover] Upgraded to max resolution (~2000x2000)") + } else { + GoLog("[Cover] Max resolution not available, using 640x640") } } + GoLog("[Cover] Final URL: %s", downloadURL) + client := NewHTTPClientWithTimeout(DefaultTimeout) // Create request with User-Agent (required by Spotify CDN) @@ -70,7 +74,19 @@ func downloadCoverToMemory(coverURL string, maxQuality bool) ([]byte, error) { return nil, fmt.Errorf("failed to read cover data: %w", err) } - fmt.Printf("[Cover] Downloaded %d bytes\n", len(data)) + // Calculate approximate resolution from file size + // JPEG ~2000x2000 is typically 300-600KB, 640x640 is ~50-100KB + sizeKB := len(data) / 1024 + var resolution string + if sizeKB > 200 { + resolution = "~2000x2000 (hi-res)" + } else if sizeKB > 50 { + resolution = "~640x640" + } else { + resolution = "~300x300" + } + GoLog("[Cover] Downloaded %d KB (%s)", sizeKB, resolution) + return data, nil } diff --git a/go_backend/qobuz.go b/go_backend/qobuz.go index 1d18f632..198b6262 100644 --- a/go_backend/qobuz.go +++ b/go_backend/qobuz.go @@ -64,30 +64,27 @@ func qobuzArtistsMatch(expectedArtist, foundArtist string) bool { return true } - // Check first artist (before comma or feat) - expectedFirst := strings.Split(normExpected, ",")[0] - expectedFirst = strings.Split(expectedFirst, " feat")[0] - expectedFirst = strings.Split(expectedFirst, " ft.")[0] - expectedFirst = strings.TrimSpace(expectedFirst) + // Split expected artists by common separators (comma, feat, ft., &, and) + // e.g., "RADWIMPS, Toko Miura" or "RADWIMPS feat. Toko Miura" + expectedArtists := qobuzSplitArtists(normExpected) + foundArtists := qobuzSplitArtists(normFound) - foundFirst := strings.Split(normFound, ",")[0] - foundFirst = strings.Split(foundFirst, " feat")[0] - foundFirst = strings.Split(foundFirst, " ft.")[0] - foundFirst = strings.TrimSpace(foundFirst) - - if expectedFirst == foundFirst { - return true - } - - // Check if first artist is contained in the other - if strings.Contains(expectedFirst, foundFirst) || strings.Contains(foundFirst, expectedFirst) { - return true - } - - // Check if same words in different order (e.g., "Sawano Hiroyuki" vs "Hiroyuki Sawano") - if qobuzSameWordsUnordered(expectedFirst, foundFirst) { - GoLog("[Qobuz] Artist names have same words in different order, assuming match: '%s' vs '%s'\n", expectedArtist, foundArtist) - return true + // Check if ANY expected artist matches ANY found artist + for _, exp := range expectedArtists { + for _, fnd := range foundArtists { + if exp == fnd { + return true + } + // Also check contains for partial matches + if strings.Contains(exp, fnd) || strings.Contains(fnd, exp) { + return true + } + // Check same words different order + if qobuzSameWordsUnordered(exp, fnd) { + GoLog("[Qobuz] Artist names have same words in different order: '%s' vs '%s'\n", exp, fnd) + return true + } + } } // If scripts are TRULY different (Latin vs CJK/Arabic/Cyrillic), assume match (transliteration) @@ -102,6 +99,30 @@ func qobuzArtistsMatch(expectedArtist, foundArtist string) bool { return false } +// qobuzSplitArtists splits artist string by common separators +func qobuzSplitArtists(artists string) []string { + // Replace common separators with a standard one + normalized := artists + normalized = strings.ReplaceAll(normalized, " feat. ", "|") + normalized = strings.ReplaceAll(normalized, " feat ", "|") + normalized = strings.ReplaceAll(normalized, " ft. ", "|") + normalized = strings.ReplaceAll(normalized, " ft ", "|") + normalized = strings.ReplaceAll(normalized, " & ", "|") + normalized = strings.ReplaceAll(normalized, " and ", "|") + normalized = strings.ReplaceAll(normalized, ", ", "|") + normalized = strings.ReplaceAll(normalized, " x ", "|") + + parts := strings.Split(normalized, "|") + result := make([]string, 0, len(parts)) + for _, p := range parts { + trimmed := strings.TrimSpace(p) + if trimmed != "" { + result = append(result, trimmed) + } + } + return result +} + // qobuzSameWordsUnordered checks if two strings have the same words regardless of order // Useful for Japanese names: "Sawano Hiroyuki" vs "Hiroyuki Sawano" func qobuzSameWordsUnordered(a, b string) bool { diff --git a/go_backend/tidal.go b/go_backend/tidal.go index 30ed3321..5ed6eb1d 100644 --- a/go_backend/tidal.go +++ b/go_backend/tidal.go @@ -1253,30 +1253,27 @@ func artistsMatch(spotifyArtist, tidalArtist string) bool { return true } - // Check first artist (before comma or feat) - spotifyFirst := strings.Split(normSpotify, ",")[0] - spotifyFirst = strings.Split(spotifyFirst, " feat")[0] - spotifyFirst = strings.Split(spotifyFirst, " ft.")[0] - spotifyFirst = strings.TrimSpace(spotifyFirst) + // Split artists by common separators (comma, feat, ft., &, and) + // e.g., "RADWIMPS, Toko Miura" or "RADWIMPS feat. Toko Miura" + spotifyArtists := splitArtists(normSpotify) + tidalArtists := splitArtists(normTidal) - tidalFirst := strings.Split(normTidal, ",")[0] - tidalFirst = strings.Split(tidalFirst, " feat")[0] - tidalFirst = strings.Split(tidalFirst, " ft.")[0] - tidalFirst = strings.TrimSpace(tidalFirst) - - if spotifyFirst == tidalFirst { - return true - } - - // Check if first artist is contained in the other - if strings.Contains(spotifyFirst, tidalFirst) || strings.Contains(tidalFirst, spotifyFirst) { - return true - } - - // Check if same words in different order (e.g., "Sawano Hiroyuki" vs "Hiroyuki Sawano") - if sameWordsUnordered(spotifyFirst, tidalFirst) { - GoLog("[Tidal] Artist names have same words in different order, assuming match: '%s' vs '%s'\n", spotifyArtist, tidalArtist) - return true + // Check if ANY expected artist matches ANY found artist + for _, exp := range spotifyArtists { + for _, fnd := range tidalArtists { + if exp == fnd { + return true + } + // Also check contains for partial matches + if strings.Contains(exp, fnd) || strings.Contains(fnd, exp) { + return true + } + // Check same words different order + if sameWordsUnordered(exp, fnd) { + GoLog("[Tidal] Artist names have same words in different order: '%s' vs '%s'\n", exp, fnd) + return true + } + } } // If scripts are TRULY different (Latin vs CJK/Arabic/Cyrillic), assume match (transliteration) @@ -1292,6 +1289,30 @@ func artistsMatch(spotifyArtist, tidalArtist string) bool { return false } +// splitArtists splits artist string by common separators +func splitArtists(artists string) []string { + // Replace common separators with a standard one + normalized := artists + normalized = strings.ReplaceAll(normalized, " feat. ", "|") + normalized = strings.ReplaceAll(normalized, " feat ", "|") + normalized = strings.ReplaceAll(normalized, " ft. ", "|") + normalized = strings.ReplaceAll(normalized, " ft ", "|") + normalized = strings.ReplaceAll(normalized, " & ", "|") + normalized = strings.ReplaceAll(normalized, " and ", "|") + normalized = strings.ReplaceAll(normalized, ", ", "|") + normalized = strings.ReplaceAll(normalized, " x ", "|") + + parts := strings.Split(normalized, "|") + result := make([]string, 0, len(parts)) + for _, p := range parts { + trimmed := strings.TrimSpace(p) + if trimmed != "" { + result = append(result, trimmed) + } + } + return result +} + // sameWordsUnordered checks if two strings have the same words regardless of order // Useful for Japanese names: "Sawano Hiroyuki" vs "Hiroyuki Sawano" func sameWordsUnordered(a, b string) bool { From 15acf181d1c9429fabca665a869f94a9325cfc1c Mon Sep 17 00:00:00 2001 From: zarzet Date: Tue, 13 Jan 2026 23:48:02 +0700 Subject: [PATCH 29/35] fix: back gesture freeze on Android 13+ and add album folder structure setting - Add PopScope with canPop:true to all settings pages for predictive back gesture support - Change settings navigation to use PageRouteBuilder instead of MaterialPageRoute - Add album folder structure setting (artist_album vs album_only) - Fix extension search result parsing to handle both array and object formats - Update CHANGELOG Fixes back gesture freeze issue on OnePlus and other Android 13+ devices with gesture navigation --- CHANGELOG.md | 36 ++++++++++- go_backend/cover.go | 19 +----- go_backend/extension_providers.go | 13 +++- lib/models/settings.dart | 7 ++- lib/models/settings.g.dart | 2 + lib/providers/download_queue_provider.dart | 49 +++++++++++++-- lib/providers/settings_provider.dart | 11 +++- lib/screens/home_tab.dart | 12 +++- lib/screens/settings/about_page.dart | 13 ++-- .../settings/appearance_settings_page.dart | 15 +++-- .../settings/download_settings_page.dart | 61 +++++++++++++++++-- .../settings/extension_detail_page.dart | 13 ++-- lib/screens/settings/extensions_page.dart | 9 ++- lib/screens/settings/log_screen.dart | 17 +++--- .../settings/options_settings_page.dart | 15 +++-- lib/screens/settings/settings_tab.dart | 23 ++++++- lib/screens/store_tab.dart | 4 ++ 17 files changed, 254 insertions(+), 65 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6fb47087..49930c6a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,12 +2,46 @@ ## [3.0.0-beta.2] - 2026-01-13 +### Added + +- **Album Folder Structure Setting**: Option to remove artist folder from album path + - New setting in Download Settings when "Separate Singles Folder" is enabled + - `Artist / Album` (default): `Albums/Artist Name/Album Name/` + - `Album Only`: `Albums/Album Name/` + - Requested by user who prefers flat album organization + ### Fixed +- **Back Gesture Freeze on OnePlus/Android 13+**: Fixed app freeze when using back gesture in settings + - Added `PopScope` with `canPop: true` to all settings pages + - Changed navigation to use `PageRouteBuilder` with proper slide transition + - Fixes predictive back gesture conflict on devices with gesture navigation + - Affected pages: Download, Appearance, Options, Extensions, About, Logs, Extension Detail + +- **Extension Search Result Parsing**: Fixed "cannot unmarshal array into Go value" error + - Go backend now handles both array and object formats from extensions + - Extensions returning `[{track}, {track}]` now work correctly + - Extensions returning `{tracks: [...], total: N}` still work as before + - **Max Resolution Cover Download**: Fixed cover not upgrading to max resolution on mobile - Added missing `spotifySize300` constant (300x300 size code) - Mobile now correctly upgrades 300x300 → 640x640 → max resolution (~2000x2000) - - Matches PC version behavior when "Download max resolution song cover" is enabled + - Added `_upgradeToMaxQualityCover()` helper in Flutter for M4A conversion path + - Go backend `cover.go` now directly replaces URL without HEAD verification + +- **Extension Search Provider Reset**: Fixed search provider not resetting to default when disabled + - `copyWith` in `AppSettings` couldn't set `searchProvider` to `null` + - Added `clearSearchProvider` boolean parameter to properly clear the value + - Settings menu now correctly switches back to default provider + +- **Extension Disabled Search Fallback**: Fixed error when extension is disabled but still called + - `_performSearch` now checks if extension is still enabled before calling custom search + - Automatically falls back to Deezer/Spotify search if extension was disabled + - Clears `searchProvider` setting if extension no longer available + +- **Store Tab Unmount Crash**: Fixed "Using ref when widget is unmounted" error + - Added `mounted` check after async operation in `_initialize()` + - Prevents crash when navigating away from Store tab during initialization - **EXISTS: Prefix in File Path**: Fixed "File not found" error in metadata screen after download - Duplicate detection was adding `EXISTS:` prefix to file paths diff --git a/go_backend/cover.go b/go_backend/cover.go index d8e6bcf3..af43d754 100644 --- a/go_backend/cover.go +++ b/go_backend/cover.go @@ -91,7 +91,8 @@ func downloadCoverToMemory(coverURL string, maxQuality bool) ([]byte, error) { } // upgradeToMaxQuality upgrades Spotify cover URL to maximum quality -// Uses same logic as PC version - replaces 640x640 size code with max resolution +// Same logic as PC version - directly replaces 640x640 size code with max resolution +// No HEAD verification needed - Spotify CDN always serves max resolution if available func upgradeToMaxQuality(coverURL string) string { // Spotify image URLs can be upgraded by changing the size parameter // Format: https://i.scdn.co/image/ab67616d0000b273... @@ -99,21 +100,7 @@ func upgradeToMaxQuality(coverURL string) string { // ab67616d000082c1 = Max resolution (~2000x2000) if strings.Contains(coverURL, spotifySize640) { - // Try max resolution first - maxURL := strings.Replace(coverURL, spotifySize640, spotifySizeMax, 1) - - // Verify max resolution URL is available - client := NewHTTPClientWithTimeout(DefaultTimeout) - req, err := http.NewRequest("HEAD", maxURL, nil) - if err == nil { - resp, err := DoRequestWithUserAgent(client, req) - if err == nil { - resp.Body.Close() - if resp.StatusCode == http.StatusOK { - return maxURL - } - } - } + return strings.Replace(coverURL, spotifySize640, spotifySizeMax, 1) } return coverURL diff --git a/go_backend/extension_providers.go b/go_backend/extension_providers.go index 336fc1d8..578bc2bb 100644 --- a/go_backend/extension_providers.go +++ b/go_backend/extension_providers.go @@ -162,8 +162,19 @@ func (p *ExtensionProviderWrapper) SearchTracks(query string, limit int) (*ExtSe } var searchResult ExtSearchResult + + // Try to parse as ExtSearchResult object first if err := json.Unmarshal(jsonBytes, &searchResult); err != nil { - return nil, fmt.Errorf("failed to parse search result: %w", err) + // If that fails, try parsing as array of tracks directly + var tracks []ExtTrackMetadata + if arrErr := json.Unmarshal(jsonBytes, &tracks); arrErr != nil { + return nil, fmt.Errorf("failed to parse search result: %w (also tried array: %v)", err, arrErr) + } + // Wrap array in ExtSearchResult + searchResult = ExtSearchResult{ + Tracks: tracks, + Total: len(tracks), + } } // Set provider ID on all tracks diff --git a/lib/models/settings.dart b/lib/models/settings.dart index 5c6f2b4f..5462a4a3 100644 --- a/lib/models/settings.dart +++ b/lib/models/settings.dart @@ -28,6 +28,7 @@ class AppSettings { 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 String albumFolderStructure; // artist_album or album_only final bool showExtensionStore; // Show Extension Store tab in navigation const AppSettings({ @@ -55,6 +56,7 @@ class AppSettings { this.useExtensionProviders = true, // Default: use extensions when available this.searchProvider, // Default: null (use Deezer/Spotify) this.separateSingles = false, // Default: disabled + this.albumFolderStructure = 'artist_album', // Default: Albums/Artist/Album this.showExtensionStore = true, // Default: show store }); @@ -82,7 +84,9 @@ class AppSettings { bool? enableLogging, bool? useExtensionProviders, String? searchProvider, + bool clearSearchProvider = false, // Set to true to clear searchProvider to null bool? separateSingles, + String? albumFolderStructure, bool? showExtensionStore, }) { return AppSettings( @@ -108,8 +112,9 @@ class AppSettings { metadataSource: metadataSource ?? this.metadataSource, enableLogging: enableLogging ?? this.enableLogging, useExtensionProviders: useExtensionProviders ?? this.useExtensionProviders, - searchProvider: searchProvider ?? this.searchProvider, + searchProvider: clearSearchProvider ? null : (searchProvider ?? this.searchProvider), separateSingles: separateSingles ?? this.separateSingles, + albumFolderStructure: albumFolderStructure ?? this.albumFolderStructure, showExtensionStore: showExtensionStore ?? this.showExtensionStore, ); } diff --git a/lib/models/settings.g.dart b/lib/models/settings.g.dart index bdbb8745..06cd85b7 100644 --- a/lib/models/settings.g.dart +++ b/lib/models/settings.g.dart @@ -32,6 +32,7 @@ AppSettings _$AppSettingsFromJson(Map json) => AppSettings( useExtensionProviders: json['useExtensionProviders'] as bool? ?? true, searchProvider: json['searchProvider'] as String?, separateSingles: json['separateSingles'] as bool? ?? false, + albumFolderStructure: json['albumFolderStructure'] as String? ?? 'artist_album', showExtensionStore: json['showExtensionStore'] as bool? ?? true, ); @@ -61,5 +62,6 @@ Map _$AppSettingsToJson(AppSettings instance) => 'useExtensionProviders': instance.useExtensionProviders, 'searchProvider': instance.searchProvider, 'separateSingles': instance.separateSingles, + 'albumFolderStructure': instance.albumFolderStructure, 'showExtensionStore': instance.showExtensionStore, }; diff --git a/lib/providers/download_queue_provider.dart b/lib/providers/download_queue_provider.dart index 52d67db0..ccbab4a8 100644 --- a/lib/providers/download_queue_provider.dart +++ b/lib/providers/download_queue_provider.dart @@ -669,7 +669,7 @@ class DownloadQueueNotifier extends Notifier { } /// Build output directory based on folder organization setting and separateSingles - Future _buildOutputDir(Track track, String folderOrganization, {bool separateSingles = false}) async { + Future _buildOutputDir(Track track, String folderOrganization, {bool separateSingles = false, String albumFolderStructure = 'artist_album'}) async { String baseDir = state.outputDir; // If separateSingles is enabled, use Albums/Singles structure @@ -686,10 +686,19 @@ class DownloadQueueNotifier extends Notifier { } return singlesPath; } else { - // Albums go to Albums/Artist/Album structure - final artistName = _sanitizeFolderName(track.albumArtist ?? track.artistName); + // Albums folder structure based on setting final albumName = _sanitizeFolderName(track.albumName); - final albumPath = '$baseDir${Platform.pathSeparator}Albums${Platform.pathSeparator}$artistName${Platform.pathSeparator}$albumName'; + String albumPath; + + if (albumFolderStructure == 'album_only') { + // Albums/Album structure (no artist folder) + albumPath = '$baseDir${Platform.pathSeparator}Albums${Platform.pathSeparator}$albumName'; + } else { + // Albums/Artist/Album structure (default) + final artistName = _sanitizeFolderName(track.albumArtist ?? track.artistName); + albumPath = '$baseDir${Platform.pathSeparator}Albums${Platform.pathSeparator}$artistName${Platform.pathSeparator}$albumName'; + } + final dir = Directory(albumPath); if (!await dir.exists()) { await dir.create(recursive: true); @@ -1001,13 +1010,42 @@ class DownloadQueueNotifier extends Notifier { } } + /// Upgrade Spotify cover URL to max quality (~2000x2000) + /// Same logic as Go backend cover.go + String _upgradeToMaxQualityCover(String coverUrl) { + const spotifySize300 = 'ab67616d00001e02'; // 300x300 (small) + const spotifySize640 = 'ab67616d0000b273'; // 640x640 (medium) + const spotifySizeMax = 'ab67616d000082c1'; // Max resolution (~2000x2000) + + // First upgrade small (300) to medium (640) + var result = coverUrl; + if (result.contains(spotifySize300)) { + result = result.replaceFirst(spotifySize300, spotifySize640); + } + + // Then upgrade medium (640) to max + if (result.contains(spotifySize640)) { + result = result.replaceFirst(spotifySize640, spotifySizeMax); + } + + return result; + } + /// Embed metadata and cover to a FLAC file after M4A conversion Future _embedMetadataAndCover(String flacPath, Track track) async { + final settings = ref.read(settingsProvider); + // Download cover first String? coverPath; - final coverUrl = track.coverUrl; + var coverUrl = track.coverUrl; if (coverUrl != null && coverUrl.isNotEmpty) { try { + // Upgrade cover URL to max quality if setting is enabled + if (settings.maxQualityCover) { + coverUrl = _upgradeToMaxQualityCover(coverUrl); + _log.d('Cover URL upgraded to max quality: $coverUrl'); + } + final tempDir = await getTemporaryDirectory(); final uniqueId = '${DateTime.now().millisecondsSinceEpoch}_${Random().nextInt(10000)}'; @@ -1446,6 +1484,7 @@ class DownloadQueueNotifier extends Notifier { trackToDownload, settings.folderOrganization, separateSingles: settings.separateSingles, + albumFolderStructure: settings.albumFolderStructure, ); // Use quality override if set, otherwise use default from settings diff --git a/lib/providers/settings_provider.dart b/lib/providers/settings_provider.dart index f56da477..ba34cc72 100644 --- a/lib/providers/settings_provider.dart +++ b/lib/providers/settings_provider.dart @@ -196,7 +196,11 @@ class SettingsNotifier extends Notifier { } void setSearchProvider(String? provider) { - state = state.copyWith(searchProvider: provider); + if (provider == null || provider.isEmpty) { + state = state.copyWith(clearSearchProvider: true); + } else { + state = state.copyWith(searchProvider: provider); + } _saveSettings(); } @@ -217,6 +221,11 @@ class SettingsNotifier extends Notifier { _saveSettings(); } + void setAlbumFolderStructure(String structure) { + state = state.copyWith(albumFolderStructure: structure); + _saveSettings(); + } + void setShowExtensionStore(bool enabled) { state = state.copyWith(showExtensionStore: enabled); _saveSettings(); diff --git a/lib/screens/home_tab.dart b/lib/screens/home_tab.dart index 81adbf54..846d583e 100644 --- a/lib/screens/home_tab.dart +++ b/lib/screens/home_tab.dart @@ -81,6 +81,7 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient Future _performSearch(String query) async { final settings = ref.read(settingsProvider); + final extState = ref.read(extensionProvider); final searchProvider = settings.searchProvider; // Skip if same query already searched with same provider @@ -88,11 +89,20 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient if (_lastSearchQuery == searchKey) return; _lastSearchQuery = searchKey; - if (searchProvider != null && searchProvider.isNotEmpty) { + // Check if extension search provider is set AND still enabled + final isExtensionEnabled = searchProvider != null && + searchProvider.isNotEmpty && + extState.extensions.any((e) => e.id == searchProvider && e.enabled); + + if (isExtensionEnabled) { // Use custom search from extension await ref.read(trackProvider.notifier).customSearch(searchProvider, query); } else { // Use default search (Deezer/Spotify) + // Also clear searchProvider if it was set but extension is disabled + if (searchProvider != null && searchProvider.isNotEmpty && !isExtensionEnabled) { + ref.read(settingsProvider.notifier).setSearchProvider(null); + } await ref.read(trackProvider.notifier).search(query, metadataSource: settings.metadataSource); } ref.read(settingsProvider.notifier).setHasSearchedBefore(); diff --git a/lib/screens/settings/about_page.dart b/lib/screens/settings/about_page.dart index c1bbc2aa..a17857f3 100644 --- a/lib/screens/settings/about_page.dart +++ b/lib/screens/settings/about_page.dart @@ -12,11 +12,13 @@ class AboutPage extends StatelessWidget { final colorScheme = Theme.of(context).colorScheme; final topPadding = MediaQuery.of(context).padding.top; - return Scaffold( - body: CustomScrollView( - slivers: [ - // Collapsing App Bar with back button - SliverAppBar( + return PopScope( + canPop: true, // Always allow back gesture + child: Scaffold( + body: CustomScrollView( + slivers: [ + // Collapsing App Bar with back button + SliverAppBar( expandedHeight: 120 + topPadding, collapsedHeight: kToolbarHeight, floating: false, @@ -218,6 +220,7 @@ class AboutPage extends StatelessWidget { const SliverToBoxAdapter(child: SizedBox(height: 16)), ], ), + ), ); } diff --git a/lib/screens/settings/appearance_settings_page.dart b/lib/screens/settings/appearance_settings_page.dart index 88c50de0..63e3d048 100644 --- a/lib/screens/settings/appearance_settings_page.dart +++ b/lib/screens/settings/appearance_settings_page.dart @@ -14,11 +14,13 @@ class AppearanceSettingsPage extends ConsumerWidget { final colorScheme = Theme.of(context).colorScheme; final topPadding = MediaQuery.of(context).padding.top; - return Scaffold( - body: CustomScrollView( - slivers: [ - // Collapsing App Bar with back button - SliverAppBar( + return PopScope( + canPop: true, // Always allow back gesture + child: Scaffold( + body: CustomScrollView( + slivers: [ + // Collapsing App Bar with back button + SliverAppBar( expandedHeight: 120 + topPadding, collapsedHeight: kToolbarHeight, floating: false, @@ -129,7 +131,8 @@ class AppearanceSettingsPage extends ConsumerWidget { ), ], ), - ); + ), + ); } } diff --git a/lib/screens/settings/download_settings_page.dart b/lib/screens/settings/download_settings_page.dart index 0fec3ed3..dc0e927a 100644 --- a/lib/screens/settings/download_settings_page.dart +++ b/lib/screens/settings/download_settings_page.dart @@ -22,11 +22,13 @@ class DownloadSettingsPage extends ConsumerWidget { // Check if current service is built-in (supports quality options) final isBuiltInService = _builtInServices.contains(settings.defaultService); - return Scaffold( - body: CustomScrollView( - slivers: [ - // Collapsing App Bar with back button - SliverAppBar( + return PopScope( + canPop: true, // Always allow back gesture + child: Scaffold( + body: CustomScrollView( + slivers: [ + // Collapsing App Bar with back button + SliverAppBar( expandedHeight: 120 + topPadding, collapsedHeight: kToolbarHeight, floating: false, @@ -194,6 +196,19 @@ class DownloadSettingsPage extends ConsumerWidget { .read(settingsProvider.notifier) .setSeparateSingles(value), ), + if (settings.separateSingles) + SettingsItem( + icon: Icons.folder_outlined, + title: 'Album Folder Structure', + subtitle: settings.albumFolderStructure == 'album_only' + ? 'Albums/Album Name/' + : 'Albums/Artist/Album Name/', + onTap: () => _showAlbumFolderStructurePicker( + context, + ref, + settings.albumFolderStructure, + ), + ), if (!settings.separateSingles) SettingsItem( icon: Icons.create_new_folder_outlined, @@ -215,7 +230,41 @@ class DownloadSettingsPage extends ConsumerWidget { const SliverToBoxAdapter(child: SizedBox(height: 32)), ], ), - ); + ), + ); + } + + void _showAlbumFolderStructurePicker(BuildContext context, WidgetRef ref, String current) { + showModalBottomSheet( + context: context, + builder: (context) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + leading: const Icon(Icons.folder_outlined), + title: const Text('Artist / Album'), + subtitle: const Text('Albums/Artist Name/Album Name/'), + trailing: current == 'artist_album' ? const Icon(Icons.check) : null, + onTap: () { + ref.read(settingsProvider.notifier).setAlbumFolderStructure('artist_album'); + Navigator.pop(context); + }, + ), + ListTile( + leading: const Icon(Icons.album_outlined), + title: const Text('Album Only'), + subtitle: const Text('Albums/Album Name/'), + trailing: current == 'album_only' ? const Icon(Icons.check) : null, + onTap: () { + ref.read(settingsProvider.notifier).setAlbumFolderStructure('album_only'); + Navigator.pop(context); + }, + ), + ], + ), + ), + ); } void _showFormatEditor(BuildContext context, WidgetRef ref, String current) { diff --git a/lib/screens/settings/extension_detail_page.dart b/lib/screens/settings/extension_detail_page.dart index 50635540..90a12922 100644 --- a/lib/screens/settings/extension_detail_page.dart +++ b/lib/screens/settings/extension_detail_page.dart @@ -56,11 +56,13 @@ class _ExtensionDetailPageState extends ConsumerState { final topPadding = MediaQuery.of(context).padding.top; final hasError = extension.status == 'error'; - return Scaffold( - body: CustomScrollView( - slivers: [ - // App Bar - SliverAppBar( + return PopScope( + canPop: true, // Always allow back gesture + child: Scaffold( + body: CustomScrollView( + slivers: [ + // App Bar + SliverAppBar( expandedHeight: 120 + topPadding, collapsedHeight: kToolbarHeight, floating: false, @@ -348,6 +350,7 @@ class _ExtensionDetailPageState extends ConsumerState { const SliverToBoxAdapter(child: SizedBox(height: 32)), ], ), + ), ); } diff --git a/lib/screens/settings/extensions_page.dart b/lib/screens/settings/extensions_page.dart index 6f2a6a71..b4a86143 100644 --- a/lib/screens/settings/extensions_page.dart +++ b/lib/screens/settings/extensions_page.dart @@ -45,9 +45,11 @@ class _ExtensionsPageState extends ConsumerState { final colorScheme = Theme.of(context).colorScheme; final topPadding = MediaQuery.of(context).padding.top; - return Scaffold( - body: CustomScrollView( - slivers: [ + return PopScope( + canPop: true, // Always allow back gesture + child: Scaffold( + body: CustomScrollView( + slivers: [ // App Bar SliverAppBar( expandedHeight: 120 + topPadding, @@ -248,6 +250,7 @@ class _ExtensionsPageState extends ConsumerState { ), ], ), + ), ); } diff --git a/lib/screens/settings/log_screen.dart b/lib/screens/settings/log_screen.dart index 5e33ec46..4c4ebeb7 100644 --- a/lib/screens/settings/log_screen.dart +++ b/lib/screens/settings/log_screen.dart @@ -124,12 +124,14 @@ class _LogScreenState extends State { final topPadding = MediaQuery.of(context).padding.top; final logs = _filteredLogs; - return Scaffold( - body: CustomScrollView( - controller: _scrollController, - slivers: [ - // Collapsing App Bar with back button - same as other settings pages - SliverAppBar( + return PopScope( + canPop: true, // Always allow back gesture + child: Scaffold( + body: CustomScrollView( + controller: _scrollController, + slivers: [ + // Collapsing App Bar with back button - same as other settings pages + SliverAppBar( expandedHeight: 120 + topPadding, collapsedHeight: kToolbarHeight, floating: false, @@ -378,7 +380,8 @@ class _LogScreenState extends State { const SliverToBoxAdapter(child: SizedBox(height: 32)), ], ), - ); + ), + ); } } diff --git a/lib/screens/settings/options_settings_page.dart b/lib/screens/settings/options_settings_page.dart index f95c5da3..3752656b 100644 --- a/lib/screens/settings/options_settings_page.dart +++ b/lib/screens/settings/options_settings_page.dart @@ -17,11 +17,13 @@ class OptionsSettingsPage extends ConsumerWidget { final colorScheme = Theme.of(context).colorScheme; final topPadding = MediaQuery.of(context).padding.top; - return Scaffold( - body: CustomScrollView( - slivers: [ - // Collapsing App Bar with back button - SliverAppBar( + return PopScope( + canPop: true, // Always allow back gesture + child: Scaffold( + body: CustomScrollView( + slivers: [ + // Collapsing App Bar with back button + SliverAppBar( expandedHeight: 120 + topPadding, collapsedHeight: kToolbarHeight, floating: false, @@ -271,7 +273,8 @@ class OptionsSettingsPage extends ConsumerWidget { const SliverToBoxAdapter(child: SizedBox(height: 32)), ], ), - ); + ), + ); } void _showClearHistoryDialog( diff --git a/lib/screens/settings/settings_tab.dart b/lib/screens/settings/settings_tab.dart index 1861c3d2..a589b730 100644 --- a/lib/screens/settings/settings_tab.dart +++ b/lib/screens/settings/settings_tab.dart @@ -116,6 +116,27 @@ class SettingsTab extends ConsumerWidget { } void _navigateTo(BuildContext context, Widget page) { - Navigator.of(context).push(MaterialPageRoute(builder: (_) => page)); + Navigator.of(context).push( + // Use PageRouteBuilder for better predictive back gesture support + // MaterialPageRoute can cause freeze on some devices with gesture navigation + PageRouteBuilder( + pageBuilder: (context, animation, secondaryAnimation) => page, + transitionsBuilder: (context, animation, secondaryAnimation, child) { + // Use slide transition similar to MaterialPageRoute + const begin = Offset(1.0, 0.0); + const end = Offset.zero; + const curve = Curves.easeInOut; + var tween = Tween(begin: begin, end: end).chain( + CurveTween(curve: curve), + ); + return SlideTransition( + position: animation.drive(tween), + child: child, + ); + }, + transitionDuration: const Duration(milliseconds: 300), + reverseTransitionDuration: const Duration(milliseconds: 250), + ), + ); } } diff --git a/lib/screens/store_tab.dart b/lib/screens/store_tab.dart index 000da84a..8935f3d8 100644 --- a/lib/screens/store_tab.dart +++ b/lib/screens/store_tab.dart @@ -26,6 +26,10 @@ class _StoreTabState extends ConsumerState { _isInitialized = true; final cacheDir = await getApplicationCacheDirectory(); + + // Check if widget is still mounted after async operation + if (!mounted) return; + await ref.read(storeProvider.notifier).initialize(cacheDir.path); } From 125d070cfeb48ca99d7ff989ff1733cd48d01ef2 Mon Sep 17 00:00:00 2001 From: zarzet Date: Tue, 13 Jan 2026 23:51:59 +0700 Subject: [PATCH 30/35] fix: remove duplicate --- separator in release notes Extract changelog now strips trailing --- from CHANGELOG.md sections --- .github/workflows/release.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 558361b6..d79b15f1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -345,6 +345,8 @@ jobs: CHANGELOG="See CHANGELOG.md for details." else echo "Found changelog content" + # Remove trailing --- separator if present (CHANGELOG uses --- between versions) + CHANGELOG=$(echo "$CHANGELOG" | sed '/^---$/d') fi # Save to file for multiline support From dbd7fd70bed646971d6165fe1520a1f14e052a7d Mon Sep 17 00:00:00 2001 From: zarzet Date: Wed, 14 Jan 2026 00:38:46 +0700 Subject: [PATCH 31/35] fix: remove unused function and fix bit shifting warnings - Remove unused getQobuzDownloadURLSequential (replaced by parallel version) - Fix bit shifting on byte values in metadata.go (cast to uint32 before shift) --- go_backend/metadata.go | 8 ++--- go_backend/qobuz.go | 71 ------------------------------------------ 2 files changed, 4 insertions(+), 75 deletions(-) diff --git a/go_backend/metadata.go b/go_backend/metadata.go index a6daa4bc..3001730e 100644 --- a/go_backend/metadata.go +++ b/go_backend/metadata.go @@ -498,7 +498,7 @@ func EmbedM4AMetadata(filePath string, metadata Metadata, coverData []byte) erro } // Find udta atom inside moov, or create one - moovSize := int(data[moovPos]<<24 | data[moovPos+1]<<16 | data[moovPos+2]<<8 | data[moovPos+3]) + moovSize := int(uint32(data[moovPos])<<24 | uint32(data[moovPos+1])<<16 | uint32(data[moovPos+2])<<8 | uint32(data[moovPos+3])) udtaPos := findAtom(data, "udta", moovPos+8) // Build new metadata atoms @@ -507,12 +507,12 @@ func EmbedM4AMetadata(filePath string, metadata Metadata, coverData []byte) erro var newData []byte if udtaPos >= 0 && udtaPos < moovPos+moovSize { // udta exists, find meta inside it or replace - udtaSize := int(data[udtaPos]<<24 | data[udtaPos+1]<<16 | data[udtaPos+2]<<8 | data[udtaPos+3]) + udtaSize := int(uint32(data[udtaPos])<<24 | uint32(data[udtaPos+1])<<16 | uint32(data[udtaPos+2])<<8 | uint32(data[udtaPos+3])) metaPos := findAtom(data, "meta", udtaPos+8) if metaPos >= 0 && metaPos < udtaPos+udtaSize { // Replace existing meta atom - metaSize := int(data[metaPos]<<24 | data[metaPos+1]<<16 | data[metaPos+2]<<8 | data[metaPos+3]) + metaSize := int(uint32(data[metaPos])<<24 | uint32(data[metaPos+1])<<16 | uint32(data[metaPos+2])<<8 | uint32(data[metaPos+3])) newData = append(newData, data[:metaPos]...) newData = append(newData, metaAtom...) newData = append(newData, data[metaPos+metaSize:]...) @@ -570,7 +570,7 @@ func EmbedM4AMetadata(filePath string, metadata Metadata, coverData []byte) erro // findAtom finds an atom by name starting from offset func findAtom(data []byte, name string, offset int) int { for i := offset; i < len(data)-8; { - size := int(data[i]<<24 | data[i+1]<<16 | data[i+2]<<8 | data[i+3]) + size := int(uint32(data[i])<<24 | uint32(data[i+1])<<16 | uint32(data[i+2])<<8 | uint32(data[i+3])) if size < 8 { break } diff --git a/go_backend/qobuz.go b/go_backend/qobuz.go index 198b6262..923f1ed3 100644 --- a/go_backend/qobuz.go +++ b/go_backend/qobuz.go @@ -819,77 +819,6 @@ func getQobuzDownloadURLParallel(apis []string, trackID int64, quality string) ( 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) { - if len(apis) == 0 { - return "", "", fmt.Errorf("no APIs available") - } - - client := NewHTTPClientWithTimeout(DefaultTimeout) - retryConfig := DefaultRetryConfig() - var errors []string - - for _, apiURL := range apis { - // All APIs now use same format: https://domain/api/stream?trackId={id}&quality={quality} - // The apiURL already includes the path, just append trackID and quality - reqURL := fmt.Sprintf("%s%d&quality=%s", apiURL, trackID, quality) - - GoLog("[Qobuz] Trying: %s\n", reqURL) - - req, err := http.NewRequest("GET", reqURL, nil) - if err != nil { - errors = append(errors, BuildErrorMessage(apiURL, 0, err.Error())) - continue - } - - resp, err := DoRequestWithRetry(client, req, retryConfig) - if err != nil { - errors = append(errors, BuildErrorMessage(apiURL, 0, err.Error())) - continue - } - - body, err := ReadResponseBody(resp) - resp.Body.Close() - if err != nil { - errors = append(errors, BuildErrorMessage(apiURL, resp.StatusCode, err.Error())) - continue - } - - // Check if response is HTML (error page) - if len(body) > 0 && body[0] == '<' { - errors = append(errors, BuildErrorMessage(apiURL, resp.StatusCode, "received HTML instead of JSON")) - continue - } - - // Check for error in JSON response - var errorResp struct { - Error string `json:"error"` - } - if json.Unmarshal(body, &errorResp) == nil && errorResp.Error != "" { - errors = append(errors, BuildErrorMessage(apiURL, resp.StatusCode, errorResp.Error)) - continue - } - - var result struct { - URL string `json:"url"` - } - if err := json.Unmarshal(body, &result); err != nil { - errors = append(errors, BuildErrorMessage(apiURL, resp.StatusCode, "invalid JSON: "+err.Error())) - continue - } - - if result.URL != "" { - GoLog("[Qobuz] Got download URL from: %s\n", apiURL) - return apiURL, result.URL, nil - } - - errors = append(errors, BuildErrorMessage(apiURL, resp.StatusCode, "no download URL in response")) - } - - return "", "", fmt.Errorf("all %d Qobuz APIs failed. Errors: %v", len(apis), errors) -} - // 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) { From bad95e99c8636d72c962d6117ae6668f6ba2d27a Mon Sep 17 00:00:00 2001 From: zarzet Date: Wed, 14 Jan 2026 00:39:59 +0700 Subject: [PATCH 32/35] fix: remove unused getDownloadURLSequential from tidal.go Replaced by parallel version for faster API responses --- go_backend/tidal.go | 89 --------------------------------------------- 1 file changed, 89 deletions(-) diff --git a/go_backend/tidal.go b/go_backend/tidal.go index 5ed6eb1d..a8dbdac1 100644 --- a/go_backend/tidal.go +++ b/go_backend/tidal.go @@ -770,95 +770,6 @@ func getDownloadURLParallel(apis []string, trackID int64, quality string) (strin 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) -func getDownloadURLSequential(apis []string, trackID int64, quality string) (string, TidalDownloadInfo, error) { - if len(apis) == 0 { - return "", TidalDownloadInfo{}, fmt.Errorf("no APIs available") - } - - client := NewHTTPClientWithTimeout(DefaultTimeout) - retryConfig := DefaultRetryConfig() - var errors []string - - for _, apiURL := range apis { - reqURL := fmt.Sprintf("%s/track/?id=%d&quality=%s", apiURL, trackID, quality) - GoLog("[Tidal] Trying API: %s\n", reqURL) - - req, err := http.NewRequest("GET", reqURL, nil) - if err != nil { - errors = append(errors, BuildErrorMessage(apiURL, 0, err.Error())) - continue - } - - resp, err := DoRequestWithRetry(client, req, retryConfig) - if err != nil { - GoLog("[Tidal] API error: %v\n", err) - errors = append(errors, BuildErrorMessage(apiURL, 0, err.Error())) - continue - } - - body, err := ReadResponseBody(resp) - resp.Body.Close() - if err != nil { - GoLog("[Tidal] Read body error: %v\n", err) - errors = append(errors, BuildErrorMessage(apiURL, resp.StatusCode, err.Error())) - continue - } - - // Log response preview - bodyPreview := string(body) - if len(bodyPreview) > 300 { - bodyPreview = bodyPreview[:300] + "..." - } - GoLog("[Tidal] API response (HTTP %d): %s\n", resp.StatusCode, bodyPreview) - - // Try v2 format first (object with manifest) - var v2Response TidalAPIResponseV2 - if err := json.Unmarshal(body, &v2Response); err == nil && v2Response.Data.Manifest != "" { - GoLog("[Tidal] Got v2 response from %s - Quality: %d-bit/%dHz, AssetPresentation: %s\n", - apiURL, v2Response.Data.BitDepth, v2Response.Data.SampleRate, v2Response.Data.AssetPresentation) - - // IMPORTANT: Reject PREVIEW responses - we need FULL tracks - if v2Response.Data.AssetPresentation == "PREVIEW" { - GoLog("[Tidal] ✗ Rejecting PREVIEW response from %s, trying next API...\n", apiURL) - errors = append(errors, BuildErrorMessage(apiURL, resp.StatusCode, "returned PREVIEW instead of FULL")) - continue - } - - GoLog("[Tidal] ✓ Got FULL track from %s\n", apiURL) - info := TidalDownloadInfo{ - URL: "MANIFEST:" + v2Response.Data.Manifest, - BitDepth: v2Response.Data.BitDepth, - SampleRate: v2Response.Data.SampleRate, - } - return apiURL, info, nil - } - - // 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 != "" { - // v1 format doesn't have quality info, assume 16-bit/44.1kHz - info := TidalDownloadInfo{ - URL: item.OriginalTrackURL, - BitDepth: 16, - SampleRate: 44100, - } - return apiURL, info, nil - } - } - } - - errors = append(errors, BuildErrorMessage(apiURL, resp.StatusCode, "no download URL or manifest in response")) - } - - return "", TidalDownloadInfo{}, fmt.Errorf("all %d Tidal APIs failed. Errors: %v", len(apis), errors) -} - // 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) { From 8ab52959e835c7e58a599fe01ff87105ea2bc88d Mon Sep 17 00:00:00 2001 From: zarzet Date: Wed, 14 Jan 2026 00:57:04 +0700 Subject: [PATCH 33/35] refactor: simplify parallel download result handling in tidal/qobuz --- go_backend/qobuz.go | 17 +++++++---------- go_backend/tidal.go | 17 +++++++---------- 2 files changed, 14 insertions(+), 20 deletions(-) diff --git a/go_backend/qobuz.go b/go_backend/qobuz.go index 923f1ed3..5ebd83df 100644 --- a/go_backend/qobuz.go +++ b/go_backend/qobuz.go @@ -789,12 +789,10 @@ func getQobuzDownloadURLParallel(apis []string, trackID int64, quality string) ( // 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 + if result.err == nil { GoLog("[Qobuz] [Parallel] ✓ Got response from %s in %v\n", result.apiURL, result.duration) // Drain remaining results to avoid goroutine leaks @@ -805,14 +803,13 @@ func getQobuzDownloadURLParallel(apis []string, trackID int64, quality string) ( }(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)) + return result.apiURL, result.downloadURL, 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)) diff --git a/go_backend/tidal.go b/go_backend/tidal.go index a8dbdac1..a6663827 100644 --- a/go_backend/tidal.go +++ b/go_backend/tidal.go @@ -738,13 +738,11 @@ func getDownloadURLParallel(apis []string, trackID int64, quality string) (strin // 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 { + if result.err == 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) @@ -756,14 +754,13 @@ func getDownloadURLParallel(apis []string, trackID int64, quality string) (strin }(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)) + return result.apiURL, result.info, 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)) From 9346f2d1494ccba3ef08c4381145d7f8da0fa16f Mon Sep 17 00:00:00 2001 From: zarzet Date: Wed, 14 Jan 2026 01:00:52 +0700 Subject: [PATCH 34/35] fix: bottom overflow in Folder Organization dialog --- .../settings/download_settings_page.dart | 130 +++++++++--------- 1 file changed, 68 insertions(+), 62 deletions(-) diff --git a/lib/screens/settings/download_settings_page.dart b/lib/screens/settings/download_settings_page.dart index dc0e927a..7ce58d9d 100644 --- a/lib/screens/settings/download_settings_page.dart +++ b/lib/screens/settings/download_settings_page.dart @@ -573,74 +573,80 @@ class DownloadSettingsPage extends ConsumerWidget { showModalBottomSheet( context: context, backgroundColor: colorScheme.surfaceContainerHigh, + isScrollControlled: true, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(28)), ), + constraints: BoxConstraints( + maxHeight: MediaQuery.of(context).size.height * 0.7, + ), builder: (context) => SafeArea( - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.fromLTRB(24, 24, 24, 8), - child: Text( - 'Folder Organization', - style: Theme.of( - context, - ).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold), - ), - ), - Padding( - padding: const EdgeInsets.fromLTRB(24, 0, 24, 16), - child: Text( - 'Organize downloaded files into folders', - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: colorScheme.onSurfaceVariant, + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(24, 24, 24, 8), + child: Text( + 'Folder Organization', + style: Theme.of( + context, + ).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold), ), ), - ), - _FolderOption( - title: 'None', - subtitle: 'All files in download folder', - example: 'SpotiFLAC/Track.flac', - isSelected: current == 'none', - onTap: () { - ref.read(settingsProvider.notifier).setFolderOrganization('none'); - Navigator.pop(context); - }, - ), - _FolderOption( - title: 'By Artist', - subtitle: 'Separate folder for each artist', - example: 'SpotiFLAC/Artist Name/Track.flac', - isSelected: current == 'artist', - onTap: () { - ref.read(settingsProvider.notifier).setFolderOrganization('artist'); - Navigator.pop(context); - }, - ), - _FolderOption( - title: 'By Album', - subtitle: 'Separate folder for each album', - example: 'SpotiFLAC/Album Name/Track.flac', - isSelected: current == 'album', - onTap: () { - ref.read(settingsProvider.notifier).setFolderOrganization('album'); - Navigator.pop(context); - }, - ), - _FolderOption( - title: 'By Artist & Album', - subtitle: 'Nested folders for artist and album', - example: 'SpotiFLAC/Artist/Album/Track.flac', - isSelected: current == 'artist_album', - onTap: () { - ref.read(settingsProvider.notifier).setFolderOrganization('artist_album'); - Navigator.pop(context); - }, - ), - const SizedBox(height: 16), - ], + Padding( + padding: const EdgeInsets.fromLTRB(24, 0, 24, 16), + child: Text( + 'Organize downloaded files into folders', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ), + _FolderOption( + title: 'None', + subtitle: 'All files in download folder', + example: 'SpotiFLAC/Track.flac', + isSelected: current == 'none', + onTap: () { + ref.read(settingsProvider.notifier).setFolderOrganization('none'); + Navigator.pop(context); + }, + ), + _FolderOption( + title: 'By Artist', + subtitle: 'Separate folder for each artist', + example: 'SpotiFLAC/Artist Name/Track.flac', + isSelected: current == 'artist', + onTap: () { + ref.read(settingsProvider.notifier).setFolderOrganization('artist'); + Navigator.pop(context); + }, + ), + _FolderOption( + title: 'By Album', + subtitle: 'Separate folder for each album', + example: 'SpotiFLAC/Album Name/Track.flac', + isSelected: current == 'album', + onTap: () { + ref.read(settingsProvider.notifier).setFolderOrganization('album'); + Navigator.pop(context); + }, + ), + _FolderOption( + title: 'By Artist & Album', + subtitle: 'Nested folders for artist and album', + example: 'SpotiFLAC/Artist/Album/Track.flac', + isSelected: current == 'artist_album', + onTap: () { + ref.read(settingsProvider.notifier).setFolderOrganization('artist_album'); + Navigator.pop(context); + }, + ), + const SizedBox(height: 16), + ], + ), ), ), ); From 4091a9c499b736982ae49d3d5895bdde97dd11dd Mon Sep 17 00:00:00 2001 From: zarzet Date: Wed, 14 Jan 2026 01:57:30 +0700 Subject: [PATCH 35/35] release: v3.0.0 stable with Extension System --- CHANGELOG.md | 114 ++- lib/constants/app_info.dart | 4 +- .../settings/extension_detail_page.dart | 215 +++-- .../store/extension_details_screen.dart | 751 ++++++++++++++++++ lib/screens/store_tab.dart | 413 +++++----- pubspec.yaml | 2 +- 6 files changed, 1245 insertions(+), 254 deletions(-) create mode 100644 lib/screens/store/extension_details_screen.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 49930c6a..6abce1b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,109 @@ # Changelog +## [3.0.0] - 2026-01-14 + +### 🎉 Extension System (Major Feature) + +SpotiFLAC 3.0 introduces a powerful extension system that allows third-party integrations for metadata, downloads, and more. + +#### Extension Store +- Browse and install extensions directly from the app +- New "Store" tab in bottom navigation +- Browse by category: Metadata, Download, Utility, Lyrics, Integration +- Search extensions by name, description, or tags +- One-tap install, update, and uninstall +- Offline cache for browsing without internet + +#### Extension Capabilities +- **Custom Search Providers** +- **Custom URL Handlers** +- **Custom Thumbnail Ratios**: Square (1:1), Wide (16:9), Portrait (2:3) +- **Post-Processing Hooks**: Extensions can process downloaded files +- **Quality Options**: Extensions can define custom quality settings + +#### Extension APIs +- Full HTTP support: GET, POST, PUT, DELETE, PATCH +- Persistent cookie jar per extension +- Browser-like polyfills: `fetch()`, `atob()`/`btoa()`, `TextEncoder`/`TextDecoder`, `URL`/`URLSearchParams` +- Storage API for persistent data +- File API for file operations +- HMAC-SHA1 utility for cryptographic operations + +#### Security +- Sandboxed JavaScript runtime (goja) +- Permission-based access control +- Network domain whitelisting +- Improved credential encryption with per-installation random salt + +### Added + +- **Album Folder Structure Setting**: Option to remove artist folder from album path + - `Artist / Album` (default): `Albums/Artist Name/Album Name/` + - `Album Only`: `Albums/Album Name/` + +- **Separate Singles Folder**: Organize downloads into Albums/ and Singles/ folders + - Based on `album_type` from Spotify/Deezer metadata + - Toggle in Settings > Download > Separate Singles Folder + +- **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 + +- **Back Gesture Freeze on Android 13+**: Fixed app freeze when using back gesture in settings + - Added `PopScope` with `canPop: true` to all settings pages + - Changed navigation to use `PageRouteBuilder` with proper slide transition + +- **Bottom Overflow in Folder Organization Dialog**: Fixed overflow in portrait and landscape mode + - Made dialog scrollable with max height constraint + +- **Japanese Artist Name Order**: Fixed artist mismatch for Japanese names + - "Sawano Hiroyuki" vs "Hiroyuki Sawano" now correctly matches + +- **Multi-Artist Matching**: Fixed artist mismatch for collaboration tracks + - "RADWIMPS feat. Toko Miura" now matches when service only shows "Toko Miura" + +- **Max Resolution Cover Download**: Fixed cover not upgrading to max resolution on mobile + - Mobile now correctly upgrades 300x300 → 640x640 → max resolution (~2000x2000) + +- **EXISTS: Prefix in File Path**: Fixed "File not found" error in metadata screen + - Duplicate detection prefix now stripped before saving to history + +- **Extension Search Result Parsing**: Fixed "cannot unmarshal array" error + - Go backend now handles both array and object formats from extensions + +- **Store Tab Unmount Crash**: Fixed "Using ref when widget is unmounted" error + +- **Duplicate History Entries**: Fixed duplicate entries when re-downloading same track + - Detects existing entries by Spotify ID, Deezer ID, or ISRC + +- **Permission Error Message**: Fixed download showing "Song not found" when actually permission error + - Now shows proper message: "Cannot write to folder, check storage permission" + +- **Android 13+ Storage Permission**: Fixed storage permission not working on Android 13+ + - Now requests both `MANAGE_EXTERNAL_STORAGE` and `READ_MEDIA_AUDIO` + +### Changed + +- **Extension Manifest**: New `file` permission required for file operations + ```json + "permissions": { + "network": ["api.example.com"], + "storage": true, + "file": true + } + ``` + +### Technical + +- Go backend: Simplified parallel download result handling in Tidal/Qobuz +- Go backend: Removed unused functions and fixed bit shifting warnings +- Release workflow: Fixed duplicate `---` separator in release notes + +--- + ## [3.0.0-beta.2] - 2026-01-13 ### Added @@ -321,16 +425,6 @@ - **Android Changes**: - `android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt`: Already had upgrade methods -### Documentation - -- Updated `docs/EXTENSION_DEVELOPMENT.md`: - - Added thumbnail ratio customization section - - Added extension upgrade documentation - - Added settings fields table with `secret` field - - Added new troubleshooting entries - - Updated table of contents - - Updated changelog - --- ## [2.2.8] - 2026-01-12 diff --git a/lib/constants/app_info.dart b/lib/constants/app_info.dart index dd428f1e..724afbe6 100644 --- a/lib/constants/app_info.dart +++ b/lib/constants/app_info.dart @@ -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-beta.2'; - static const String buildNumber = '56'; + static const String version = '3.0.0'; + static const String buildNumber = '57'; static const String fullVersion = '$version+$buildNumber'; diff --git a/lib/screens/settings/extension_detail_page.dart b/lib/screens/settings/extension_detail_page.dart index 90a12922..0144111e 100644 --- a/lib/screens/settings/extension_detail_page.dart +++ b/lib/screens/settings/extension_detail_page.dart @@ -188,6 +188,7 @@ class _ExtensionDetailPageState extends ConsumerState { const SizedBox(height: 16), _InfoRow(label: 'Author', value: extension.author), _InfoRow(label: 'ID', value: extension.id), + _InfoRow(label: 'Version', value: 'v${extension.version}'), if (hasError && extension.errorMessage != null) _InfoRow( label: 'Error', @@ -238,28 +239,57 @@ class _ExtensionDetailPageState extends ConsumerState { subtitle: extension.postProcessing?.hooks.isNotEmpty == true ? '${extension.postProcessing!.hooks.length} hook(s) available' : null, + ), + _CapabilityItem( + icon: Icons.link, + title: 'URL Handler', + enabled: extension.hasURLHandler, + subtitle: extension.urlHandler?.patterns.isNotEmpty == true + ? '${extension.urlHandler!.patterns.length} pattern(s)' + : null, showDivider: false, ), ], ), ), - // Search Provider Section (if extension has custom search) - if (extension.hasCustomSearch) ...[ + + + // URL Handler Section (if extension handles URLs) + if (extension.hasURLHandler && extension.urlHandler!.patterns.isNotEmpty) ...[ const SliverToBoxAdapter( - child: SettingsSectionHeader(title: 'Search Provider'), + child: SettingsSectionHeader(title: 'URL Handler'), ), SliverToBoxAdapter( child: SettingsGroup( children: [ - _SearchProviderInfo( - extension: extension, + _URLHandlerInfo( + patterns: extension.urlHandler!.patterns, ), ], ), ), ], + // Quality Options Section (for download providers) + if (extension.hasDownloadProvider && extension.qualityOptions.isNotEmpty) ...[ + const SliverToBoxAdapter( + child: SettingsSectionHeader(title: 'Quality Options'), + ), + SliverToBoxAdapter( + child: SettingsGroup( + children: extension.qualityOptions.asMap().entries.map((entry) { + final index = entry.key; + final quality = entry.value; + return _QualityOptionItem( + quality: quality, + showDivider: index < extension.qualityOptions.length - 1, + ); + }).toList(), + ), + ), + ], + // Post-Processing Hooks (if available) if (extension.hasPostProcessing && extension.postProcessing!.hooks.isNotEmpty) ...[ const SliverToBoxAdapter( @@ -820,17 +850,18 @@ class _PostProcessingHookItem extends StatelessWidget { } } -class _SearchProviderInfo extends StatelessWidget { - final Extension extension; - const _SearchProviderInfo({ - required this.extension, + +class _URLHandlerInfo extends StatelessWidget { + final List patterns; + + const _URLHandlerInfo({ + required this.patterns, }); @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; - final searchBehavior = extension.searchBehavior; return Padding( padding: const EdgeInsets.all(16), @@ -843,12 +874,12 @@ class _SearchProviderInfo extends StatelessWidget { width: 48, height: 48, decoration: BoxDecoration( - color: colorScheme.secondaryContainer, + color: colorScheme.tertiaryContainer, borderRadius: BorderRadius.circular(12), ), child: Icon( - Icons.manage_search, - color: colorScheme.onSecondaryContainer, + Icons.link, + color: colorScheme.onTertiaryContainer, size: 24, ), ), @@ -858,14 +889,14 @@ class _SearchProviderInfo extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - 'Custom Search Available', + 'Custom URL Handling', style: Theme.of(context).textTheme.titleMedium?.copyWith( fontWeight: FontWeight.w600, ), ), const SizedBox(height: 2), Text( - 'This extension provides its own search functionality', + 'This extension can handle links from these sites', style: Theme.of(context).textTheme.bodySmall?.copyWith( color: colorScheme.onSurfaceVariant, ), @@ -876,25 +907,38 @@ class _SearchProviderInfo extends StatelessWidget { ], ), const SizedBox(height: 16), - // Search placeholder info - if (searchBehavior?.placeholder != null) ...[ - _InfoTile( - icon: Icons.text_fields, - label: 'Search Hint', - value: searchBehavior!.placeholder!, - ), - const SizedBox(height: 8), - ], - // Primary search info - _InfoTile( - icon: searchBehavior?.primary == true ? Icons.star : Icons.star_border, - label: 'Priority', - value: searchBehavior?.primary == true - ? 'Primary search provider' - : 'Secondary search provider', + Wrap( + spacing: 8, + runSpacing: 8, + children: patterns.map((pattern) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(8), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.language, + size: 16, + color: colorScheme.primary, + ), + const SizedBox(width: 6), + Text( + pattern, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onSurface, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ); + }).toList(), ), const SizedBox(height: 16), - // Usage instructions Container( padding: const EdgeInsets.all(12), decoration: BoxDecoration( @@ -911,7 +955,7 @@ class _SearchProviderInfo extends StatelessWidget { const SizedBox(width: 12), Expanded( child: Text( - 'To use this search provider, tap the search bar on the Home tab and select "${extension.displayName}" from the provider chips.', + 'Share links from these sites to SpotiFLAC and this extension will handle them.', style: Theme.of(context).textTheme.bodySmall?.copyWith( color: colorScheme.onSurfaceVariant, ), @@ -926,44 +970,95 @@ class _SearchProviderInfo extends StatelessWidget { } } -class _InfoTile extends StatelessWidget { - final IconData icon; - final String label; - final String value; +class _QualityOptionItem extends StatelessWidget { + final QualityOption quality; + final bool showDivider; - const _InfoTile({ - required this.icon, - required this.label, - required this.value, + const _QualityOptionItem({ + required this.quality, + this.showDivider = true, }); @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; - return Row( + return Column( + mainAxisSize: MainAxisSize.min, children: [ - Icon( - icon, - size: 18, - color: colorScheme.onSurfaceVariant, - ), - const SizedBox(width: 8), - Text( - '$label: ', - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: colorScheme.onSurfaceVariant, + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Row( + children: [ + Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: colorScheme.secondaryContainer, + borderRadius: BorderRadius.circular(10), + ), + child: Icon( + Icons.high_quality, + color: colorScheme.onSecondaryContainer, + size: 20, + ), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + quality.label, + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + fontWeight: FontWeight.w500, + ), + ), + if (quality.description != null && quality.description!.isNotEmpty) ...[ + const SizedBox(height: 2), + Text( + quality.description!, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ], + const SizedBox(height: 4), + Text( + quality.id, + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: colorScheme.primary, + fontFamily: 'monospace', + ), + ), + ], + ), + ), + if (quality.settings.isNotEmpty) + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(8), + ), + child: Text( + '${quality.settings.length} setting${quality.settings.length > 1 ? 's' : ''}', + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ), + ], ), ), - Expanded( - child: Text( - value, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: colorScheme.onSurface, - fontWeight: FontWeight.w500, - ), + if (showDivider) + Divider( + height: 1, + thickness: 1, + indent: 72, + endIndent: 16, + color: colorScheme.outlineVariant.withValues(alpha: 0.3), ), - ), ], ); } diff --git a/lib/screens/store/extension_details_screen.dart b/lib/screens/store/extension_details_screen.dart new file mode 100644 index 00000000..4ca5b7a3 --- /dev/null +++ b/lib/screens/store/extension_details_screen.dart @@ -0,0 +1,751 @@ +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/providers/extension_provider.dart'; + +class ExtensionDetailsScreen extends ConsumerStatefulWidget { + final StoreExtension extension; + + const ExtensionDetailsScreen({super.key, required this.extension}); + + @override + ConsumerState createState() => + _ExtensionDetailsScreenState(); +} + +class _ExtensionDetailsScreenState + extends ConsumerState { + + @override + Widget build(BuildContext context) { + // Watch store provider to get latest state of this extension (e.g. if updated/installed) + final storeState = ref.watch(storeProvider); + + // Find our extension in the store state to get the latest status + // If not found in current store state (rare), fallback to widget.extension + final liveExtension = + storeState.extensions + .where((e) => e.id == widget.extension.id) + .firstOrNull ?? + widget.extension; + + final isDownloading = storeState.downloadingId == liveExtension.id; + final colorScheme = Theme.of(context).colorScheme; + + return Scaffold( + body: CustomScrollView( + slivers: [ + _buildAppBar(context, liveExtension, colorScheme), + _buildInfoCard(context, liveExtension, colorScheme, isDownloading), + _buildSectionHeader( + context, + 'About', + Icons.info_outline, + colorScheme, + ), + _buildDescription(context, liveExtension, colorScheme), + + if (liveExtension.tags.isNotEmpty) ...[ + _buildSectionHeader(context, 'Tags', Icons.tag, colorScheme), + _buildTags(context, liveExtension, colorScheme), + ], + + _buildSectionHeader( + context, + 'Information', + Icons.table_chart_outlined, + colorScheme, + ), + _buildMetadataTable(context, liveExtension, colorScheme), + + _buildSectionHeader( + context, + 'Capabilities', + Icons.extension_outlined, + colorScheme, + ), + _buildCapabilities(context, liveExtension, colorScheme), + + const SliverToBoxAdapter(child: SizedBox(height: 32)), + ], + ), + ); + } + + Widget _buildAppBar( + BuildContext context, + StoreExtension ext, + ColorScheme colorScheme, + ) { + return SliverAppBar( + expandedHeight: 200, + pinned: true, + stretch: true, + backgroundColor: colorScheme.surface, + surfaceTintColor: Colors.transparent, + flexibleSpace: FlexibleSpaceBar( + background: Center( + child: Padding( + padding: const EdgeInsets.only(top: kToolbarHeight), + child: Container( + width: 100, + height: 100, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(24), + color: colorScheme.surfaceContainerHighest, + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.1), + blurRadius: 16, + offset: const Offset(0, 4), + ), + ], + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(24), + child: ext.iconUrl != null && ext.iconUrl!.isNotEmpty + ? Image.network( + ext.iconUrl!, + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) => + _buildFallbackIcon(ext, colorScheme, 50), + ) + : _buildFallbackIcon(ext, colorScheme, 50), + ), + ), + ), + ), + ), + leading: IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () => Navigator.pop(context), + ), + ); + } + + Widget _buildFallbackIcon( + StoreExtension ext, + ColorScheme colorScheme, + double size, + ) { + return Container( + color: colorScheme.surfaceContainerHighest, + child: Icon( + _getCategoryIcon(ext.category), + size: size, + color: colorScheme.onSurfaceVariant, + ), + ); + } + + Widget _buildInfoCard( + BuildContext context, + StoreExtension ext, + ColorScheme colorScheme, + bool isDownloading, + ) { + 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: [ + Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + ext.displayName, + style: Theme.of(context).textTheme.headlineSmall + ?.copyWith( + fontWeight: FontWeight.bold, + color: colorScheme.onSurface, + ), + ), + const SizedBox(height: 4), + Text( + 'by ${ext.author}', + style: Theme.of(context).textTheme.bodyLarge + ?.copyWith(color: colorScheme.onSurfaceVariant), + ), + ], + ), + ), + ], + ), + + const SizedBox(height: 16), + + // Badges row + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + _Badge( + label: 'v${ext.version}', + color: colorScheme.secondaryContainer, + textColor: colorScheme.onSecondaryContainer, + ), + _Badge( + label: _getCategoryName(ext.category), + color: colorScheme.tertiaryContainer, + textColor: colorScheme.onTertiaryContainer, + ), + if (ext.isInstalled) + _Badge( + label: 'Installed', + color: colorScheme.primaryContainer, + textColor: colorScheme.onPrimaryContainer, + icon: Icons.check, + ), + ], + ), + + const SizedBox(height: 24), + + // Action Buttons + if (isDownloading) + Center( + child: CircularProgressIndicator( + color: colorScheme.primary, + ), + ) + else ...[ + if (ext.hasUpdate) + FilledButton.icon( + onPressed: () => _updateExtension(ext), + icon: const Icon(Icons.update), + label: Text('Update to v${ext.version}'), + style: FilledButton.styleFrom( + minimumSize: const Size.fromHeight(52), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + ), + ) + else if (ext.isInstalled) + Row( + children: [ + Expanded( + child: OutlinedButton.icon( + onPressed: null, + icon: const Icon(Icons.check), + label: const Text('Installed'), + style: OutlinedButton.styleFrom( + minimumSize: const Size(0, 52), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + ), + ), + ), + const SizedBox(width: 12), + IconButton.filled( + onPressed: () => _uninstallExtension(ext), + icon: const Icon(Icons.delete_outline), + style: IconButton.styleFrom( + backgroundColor: colorScheme.errorContainer, + foregroundColor: colorScheme.onErrorContainer, + minimumSize: const Size(52, 52), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + ), + tooltip: 'Uninstall', + ), + ], + ) + else + FilledButton.icon( + onPressed: () => _installExtension(ext), + icon: const Icon(Icons.download), + label: const Text('Install Extension'), + style: FilledButton.styleFrom( + minimumSize: const Size.fromHeight(52), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + ), + ), + ], + ], + ), + ), + ), + ), + ); + } + + Widget _buildSectionHeader( + BuildContext context, + String title, + IconData icon, + ColorScheme colorScheme, + ) { + return SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(20, 8, 20, 8), + child: Row( + children: [ + Icon(icon, size: 20, color: colorScheme.primary), + const SizedBox(width: 8), + Text( + title, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + color: colorScheme.onSurface, + ), + ), + ], + ), + ), + ); + } + + Widget _buildDescription( + BuildContext context, + StoreExtension ext, + ColorScheme colorScheme, + ) { + return SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 8), + child: Text( + ext.description, + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + height: 1.5, + color: colorScheme.onSurface, + ), + ), + ), + ); + } + + Widget _buildTags( + BuildContext context, + StoreExtension ext, + ColorScheme colorScheme, + ) { + return SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 8), + child: Wrap( + spacing: 8, + runSpacing: 8, + children: ext.tags + .map( + (tag) => Chip( + label: Text(tag), + backgroundColor: colorScheme.surfaceContainer, + labelStyle: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + side: BorderSide.none, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(20), + ), + ), + ) + .toList(), + ), + ), + ); + } + + Widget _buildMetadataTable( + BuildContext context, + StoreExtension ext, + ColorScheme colorScheme, + ) { + return SliverPadding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + sliver: SliverToBoxAdapter( + child: Card( + elevation: 0, + color: colorScheme.surfaceContainer, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + child: Column( + children: [ + _MetadataRow( + label: 'Updated', + value: ext.updatedAt.isNotEmpty + ? _formatDate(ext.updatedAt) + : '-', + colorScheme: colorScheme, + ), + _MetadataRow( + label: 'ID', + value: ext.id, + colorScheme: colorScheme, + ), + _MetadataRow( + label: 'Min App Version', + value: ext.minAppVersion ?? 'Any', + colorScheme: colorScheme, + isLast: true, + ), + ], + ), + ), + ), + ); + } + + Widget _buildCapabilities( + BuildContext context, + StoreExtension ext, + ColorScheme colorScheme, + ) { + // Determine capabilities based on category + final isMetadataProvider = ext.category == 'metadata' || ext.category == 'integration'; + final isDownloadProvider = ext.category == 'download'; + final isLyricsProvider = ext.category == 'lyrics'; + final isUtility = ext.category == 'utility'; + + return SliverPadding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + sliver: SliverToBoxAdapter( + child: Card( + elevation: 0, + color: colorScheme.surfaceContainer, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + child: Column( + children: [ + _CapabilityRow( + icon: Icons.search, + label: 'Metadata Provider', + enabled: isMetadataProvider, + colorScheme: colorScheme, + ), + _CapabilityRow( + icon: Icons.download, + label: 'Download Provider', + enabled: isDownloadProvider, + colorScheme: colorScheme, + ), + _CapabilityRow( + icon: Icons.lyrics, + label: 'Lyrics Provider', + enabled: isLyricsProvider, + colorScheme: colorScheme, + ), + _CapabilityRow( + icon: Icons.build, + label: 'Utility Functions', + enabled: isUtility, + colorScheme: colorScheme, + isLast: true, + ), + ], + ), + ), + ), + ); + } + + String _formatDate(String dateStr) { + try { + final date = DateTime.parse(dateStr); + final now = DateTime.now(); + final diff = now.difference(date); + + if (diff.inDays == 0) { + return 'Today'; + } else if (diff.inDays == 1) { + return 'Yesterday'; + } else if (diff.inDays < 7) { + return '${diff.inDays} days ago'; + } else if (diff.inDays < 30) { + return '${(diff.inDays / 7).floor()} weeks ago'; + } else if (diff.inDays < 365) { + return '${(diff.inDays / 30).floor()} months ago'; + } else { + return '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}'; + } + } catch (_) { + return dateStr.split('T').first; + } + } + + IconData _getCategoryIcon(String category) { + switch (category) { + case 'metadata': + return Icons.label_outline; + case 'download': + return Icons.download_outlined; + case 'utility': + return Icons.build_outlined; + case 'lyrics': + return Icons.lyrics_outlined; + case 'integration': + return Icons.link; + default: + return Icons.extension; + } + } + + String _getCategoryName(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; + } + } + + Future _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.' + : 'Failed to install ${ext.displayName}', + ), + behavior: SnackBarBehavior.floating, + ), + ); + } + } + + Future _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.' + : 'Failed to update ${ext.displayName}', + ), + behavior: SnackBarBehavior.floating, + ), + ); + } + } + + Future _uninstallExtension(StoreExtension ext) async { + final confirm = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Uninstall Extension?'), + content: Text('Are you sure you want to remove ${ext.displayName}?'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () => Navigator.pop(context, true), + child: Text( + 'Uninstall', + style: TextStyle(color: Theme.of(context).colorScheme.error), + ), + ), + ], + ), + ); + + if (confirm == true) { + await ref.read(extensionProvider.notifier).removeExtension(ext.id); + await ref.read(storeProvider.notifier).refresh(); + if (mounted) { + Navigator.pop(context); + } + } + } +} + +class _Badge extends StatelessWidget { + final String label; + final Color color; + final Color textColor; + final IconData? icon; + + const _Badge({ + required this.label, + required this.color, + required this.textColor, + this.icon, + }); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: color, + borderRadius: BorderRadius.circular(8), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (icon != null) ...[ + Icon(icon, size: 14, color: textColor), + const SizedBox(width: 4), + ], + Text( + label, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: textColor, + ), + ), + ], + ), + ); + } +} + +class _MetadataRow extends StatelessWidget { + final String label; + final String value; + final ColorScheme colorScheme; + final bool isLast; + + const _MetadataRow({ + required this.label, + required this.value, + required this.colorScheme, + this.isLast = false, + }); + + @override + Widget build(BuildContext context) { + return Column( + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + label, + style: TextStyle( + color: colorScheme.onSurfaceVariant, + fontSize: 14, + ), + ), + Expanded( + child: Text( + value, + textAlign: TextAlign.end, + style: TextStyle( + color: colorScheme.onSurface, + fontWeight: FontWeight.w500, + fontSize: 14, + ), + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + if (!isLast) + Divider( + height: 1, + thickness: 1, + color: colorScheme.outlineVariant.withValues(alpha: 0.3), + indent: 16, + endIndent: 16, + ), + ], + ); + } +} + +class _CapabilityRow extends StatelessWidget { + final IconData icon; + final String label; + final bool enabled; + final ColorScheme colorScheme; + final bool isLast; + + const _CapabilityRow({ + required this.icon, + required this.label, + required this.enabled, + required this.colorScheme, + this.isLast = false, + }); + + @override + Widget build(BuildContext context) { + return Column( + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Row( + children: [ + Icon( + icon, + size: 20, + color: enabled ? colorScheme.primary : colorScheme.outline, + ), + const SizedBox(width: 12), + Expanded( + child: Text( + label, + style: TextStyle( + color: colorScheme.onSurface, + fontSize: 14, + ), + ), + ), + Icon( + enabled ? Icons.check_circle : Icons.cancel_outlined, + size: 20, + color: enabled ? colorScheme.primary : colorScheme.outline, + ), + ], + ), + ), + if (!isLast) + Divider( + height: 1, + thickness: 1, + color: colorScheme.outlineVariant.withValues(alpha: 0.3), + indent: 16, + endIndent: 16, + ), + ], + ); + } +} diff --git a/lib/screens/store_tab.dart b/lib/screens/store_tab.dart index 8935f3d8..7d8e956a 100644 --- a/lib/screens/store_tab.dart +++ b/lib/screens/store_tab.dart @@ -3,6 +3,7 @@ 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'; +import 'package:spotiflac_android/screens/store/extension_details_screen.dart'; class StoreTab extends ConsumerStatefulWidget { const StoreTab({super.key}); @@ -26,10 +27,10 @@ class _StoreTabState extends ConsumerState { _isInitialized = true; final cacheDir = await getApplicationCacheDirectory(); - + // Check if widget is still mounted after async operation if (!mounted) return; - + await ref.read(storeProvider.notifier).initialize(cacheDir.path); } @@ -47,7 +48,8 @@ class _StoreTabState extends ConsumerState { return Scaffold( body: RefreshIndicator( - onRefresh: () => ref.read(storeProvider.notifier).refresh(forceRefresh: true), + onRefresh: () => + ref.read(storeProvider.notifier).refresh(forceRefresh: true), child: CustomScrollView( slivers: [ // App Bar - consistent with other tabs @@ -63,9 +65,10 @@ class _StoreTabState extends ConsumerState { builder: (context, constraints) { final maxHeight = 120 + topPadding; final minHeight = kToolbarHeight + topPadding; - final expandRatio = ((constraints.maxHeight - minHeight) / - (maxHeight - minHeight)) - .clamp(0.0, 1.0); + final expandRatio = + ((constraints.maxHeight - minHeight) / + (maxHeight - minHeight)) + .clamp(0.0, 1.0); return FlexibleSpaceBar( expandedTitleScale: 1.0, @@ -97,7 +100,9 @@ class _StoreTabState extends ConsumerState { icon: const Icon(Icons.clear), onPressed: () { _searchController.clear(); - ref.read(storeProvider.notifier).setSearchQuery(''); + ref + .read(storeProvider.notifier) + .setSearchQuery(''); }, ) : null, @@ -107,9 +112,15 @@ class _StoreTabState extends ConsumerState { ), filled: true, fillColor: Theme.of(context).brightness == Brightness.dark - ? Color.alphaBlend(Colors.white.withValues(alpha: 0.08), colorScheme.surface) + ? Color.alphaBlend( + Colors.white.withValues(alpha: 0.08), + colorScheme.surface, + ) : colorScheme.surfaceContainerHighest, - contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), ), onChanged: (value) { ref.read(storeProvider.notifier).setSearchQuery(value); @@ -123,49 +134,68 @@ class _StoreTabState extends ConsumerState { SliverToBoxAdapter( child: SingleChildScrollView( scrollDirection: Axis.horizontal, - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + 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), + 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), + 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), + 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), + 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), + 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), + isSelected: + state.selectedCategory == StoreCategory.integration, + onTap: () => ref + .read(storeProvider.notifier) + .setCategory(StoreCategory.integration), ), ], ), @@ -182,9 +212,7 @@ class _StoreTabState extends ConsumerState { child: _buildErrorState(state.error!, colorScheme), ) else if (state.filteredExtensions.isEmpty) - SliverFillRemaining( - child: _buildEmptyState(state, colorScheme), - ) + SliverFillRemaining(child: _buildEmptyState(state, colorScheme)) else ...[ // Extensions count SliverToBoxAdapter( @@ -204,15 +232,19 @@ class _StoreTabState extends ConsumerState { child: Padding( padding: const EdgeInsets.symmetric(horizontal: 16), child: SettingsGroup( - children: state.filteredExtensions.asMap().entries.map((entry) { + 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, + showDivider: + index < state.filteredExtensions.length - 1, isDownloading: state.downloadingId == ext.id, onInstall: () => _installExtension(ext), onUpdate: () => _updateExtension(ext), + onTap: () => _showExtensionDetails(ext), ); }).toList(), ), @@ -251,7 +283,8 @@ class _StoreTabState extends ConsumerState { ), const SizedBox(height: 24), FilledButton.icon( - onPressed: () => ref.read(storeProvider.notifier).refresh(forceRefresh: true), + onPressed: () => + ref.read(storeProvider.notifier).refresh(forceRefresh: true), icon: const Icon(Icons.refresh), label: const Text('Retry'), ), @@ -262,7 +295,8 @@ class _StoreTabState extends ConsumerState { } Widget _buildEmptyState(StoreState state, ColorScheme colorScheme) { - final hasFilters = state.searchQuery.isNotEmpty || state.selectedCategory != null; + final hasFilters = + state.searchQuery.isNotEmpty || state.selectedCategory != null; return Center( child: Column( @@ -295,23 +329,31 @@ class _StoreTabState extends ConsumerState { ); } + void _showExtensionDetails(StoreExtension ext) { + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => ExtensionDetailsScreen(extension: ext), + ), + ); + } + Future _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, - ); + 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}'), + content: Text( + success + ? '${ext.displayName} installed. Enable it in Settings > Extensions' + : 'Failed to install ${ext.displayName}', + ), behavior: SnackBarBehavior.floating, ), ); @@ -321,17 +363,18 @@ class _StoreTabState extends ConsumerState { Future _updateExtension(StoreExtension ext) async { final tempDir = await getTemporaryDirectory(); - final success = await ref.read(storeProvider.notifier).updateExtension( - ext.id, - tempDir.path, - ); + 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}'), + content: Text( + success + ? '${ext.displayName} updated to v${ext.version}' + : 'Failed to update ${ext.displayName}', + ), behavior: SnackBarBehavior.floating, ), ); @@ -339,7 +382,6 @@ class _StoreTabState extends ConsumerState { } } - class _CategoryChip extends StatelessWidget { final String label; final IconData icon; @@ -358,11 +400,7 @@ class _CategoryChip extends StatelessWidget { return FilterChip( label: Row( mainAxisSize: MainAxisSize.min, - children: [ - Icon(icon, size: 16), - const SizedBox(width: 6), - Text(label), - ], + children: [Icon(icon, size: 16), const SizedBox(width: 6), Text(label)], ), selected: isSelected, onSelected: (_) => onTap(), @@ -377,6 +415,7 @@ class _ExtensionItem extends StatelessWidget { final bool isDownloading; final VoidCallback onInstall; final VoidCallback onUpdate; + final VoidCallback? onTap; const _ExtensionItem({ required this.extension, @@ -384,6 +423,7 @@ class _ExtensionItem extends StatelessWidget { required this.isDownloading, required this.onInstall, required this.onUpdate, + this.onTap, }); IconData _getCategoryIcon(String category) { @@ -410,151 +450,162 @@ class _ExtensionItem extends StatelessWidget { 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( + InkWell( + onTap: onTap, + child: 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, ), - 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, - ), + ), + 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), ), - ); - }, - ) - : Icon( - _getCategoryIcon(extension.category), - color: extension.isInstalled - ? colorScheme.onPrimaryContainer - : colorScheme.onSurfaceVariant, + ), + // 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(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, - ), - ), + const SizedBox(height: 2), + Text( + 'by ${extension.author}', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, ), - // 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: 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), ), ], ), - const SizedBox(height: 2), - Text( - 'by ${extension.author}', - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: colorScheme.onSurfaceVariant, - ), + ) + else + FilledButton( + onPressed: onInstall, + style: FilledButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 12), + minimumSize: const Size(0, 36), ), - 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('Install'), ), - 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) diff --git a/pubspec.yaml b/pubspec.yaml index a3e97e20..1c4d0a3d 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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-beta.2+56 +version: 3.0.0+57 environment: sdk: ^3.10.0