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