diff --git a/CHANGELOG.md b/CHANGELOG.md index 0ec3548..54f5210 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,25 @@ # Changelog +## [3.0.0-alpha.4] - Upcoming + +### Added + +- **Custom URL Handler for Extensions**: Extensions can now register custom URL patterns + - Handle URLs from YouTube Music, SoundCloud, Bandcamp, etc. + - Manifest config: `urlHandler: { enabled: true, patterns: ["music.youtube.com"] }` + - Implement `handleURL(url)` function in extension to parse and return track metadata + - SpotiFLAC automatically routes matching URLs to the appropriate extension + - Supports share intents and paste from clipboard + +### Documentation + +- Updated `docs/EXTENSION_DEVELOPMENT.md`: + - Added Custom URL Handler section with examples + - Added `handleURL` function documentation + - Added URL pattern examples for YouTube, SoundCloud, Bandcamp + +--- + ## [3.0.0-alpha.3] - 2026-01-12 ### Added diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt index 78be490..aa77795 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt @@ -551,6 +551,27 @@ class MainActivity: FlutterActivity() { } result.success(response) } + // Extension URL Handler API + "handleURLWithExtension" -> { + val url = call.argument("url") ?: "" + val response = withContext(Dispatchers.IO) { + Gobackend.handleURLWithExtensionJSON(url) + } + result.success(response) + } + "findURLHandler" -> { + val url = call.argument("url") ?: "" + val response = withContext(Dispatchers.IO) { + Gobackend.findURLHandlerJSON(url) + } + result.success(response) + } + "getURLHandlers" -> { + val response = withContext(Dispatchers.IO) { + Gobackend.getURLHandlersJSON() + } + result.success(response) + } // Extension Post-Processing API "runPostProcessing" -> { val filePath = call.argument("file_path") ?: "" diff --git a/go_backend/exports.go b/go_backend/exports.go index b71cc2e..0e6f2af 100644 --- a/go_backend/exports.go +++ b/go_backend/exports.go @@ -1506,6 +1506,127 @@ func GetSearchProvidersJSON() (string, error) { return string(jsonBytes), nil } +// ==================== EXTENSION URL HANDLER ==================== + +// HandleURLWithExtensionJSON tries to handle a URL with any matching extension +// Returns JSON with type, tracks, album info, etc. +func HandleURLWithExtensionJSON(url string) (string, error) { + manager := GetExtensionManager() + result, extensionID, err := manager.HandleURLWithExtension(url) + if err != nil { + return "", err + } + + // Build response + response := map[string]interface{}{ + "type": result.Type, + "extension_id": extensionID, + "name": result.Name, + "cover_url": result.CoverURL, + } + + // Add track if single track + if result.Track != nil { + response["track"] = map[string]interface{}{ + "id": result.Track.ID, + "name": result.Track.Name, + "artists": result.Track.Artists, + "album_name": result.Track.AlbumName, + "album_artist": result.Track.AlbumArtist, + "duration_ms": result.Track.DurationMS, + "images": result.Track.ResolvedCoverURL(), + "release_date": result.Track.ReleaseDate, + "track_number": result.Track.TrackNumber, + "disc_number": result.Track.DiscNumber, + "isrc": result.Track.ISRC, + "provider_id": result.Track.ProviderID, + } + } + + // Add tracks if multiple + if len(result.Tracks) > 0 { + tracks := make([]map[string]interface{}, len(result.Tracks)) + for i, track := range result.Tracks { + tracks[i] = map[string]interface{}{ + "id": track.ID, + "name": track.Name, + "artists": track.Artists, + "album_name": track.AlbumName, + "album_artist": track.AlbumArtist, + "duration_ms": track.DurationMS, + "images": track.ResolvedCoverURL(), + "release_date": track.ReleaseDate, + "track_number": track.TrackNumber, + "disc_number": track.DiscNumber, + "isrc": track.ISRC, + "provider_id": track.ProviderID, + } + } + response["tracks"] = tracks + } + + // Add album info if present + if result.Album != nil { + response["album"] = map[string]interface{}{ + "id": result.Album.ID, + "name": result.Album.Name, + "artists": result.Album.Artists, + "cover_url": result.Album.CoverURL, + "release_date": result.Album.ReleaseDate, + "total_tracks": result.Album.TotalTracks, + } + } + + // Add artist info if present + if result.Artist != nil { + response["artist"] = map[string]interface{}{ + "id": result.Artist.ID, + "name": result.Artist.Name, + "image_url": result.Artist.ImageURL, + } + } + + jsonBytes, err := json.Marshal(response) + if err != nil { + return "", err + } + + return string(jsonBytes), nil +} + +// FindURLHandlerJSON finds an extension that can handle the given URL +// Returns extension ID or empty string if none found +func FindURLHandlerJSON(url string) string { + manager := GetExtensionManager() + handler := manager.FindURLHandler(url) + if handler == nil { + return "" + } + return handler.extension.ID +} + +// GetURLHandlersJSON returns all extensions that handle custom URLs +func GetURLHandlersJSON() (string, error) { + manager := GetExtensionManager() + handlers := manager.GetURLHandlers() + + result := make([]map[string]interface{}, 0, len(handlers)) + for _, h := range handlers { + result = append(result, map[string]interface{}{ + "id": h.extension.ID, + "display_name": h.extension.Manifest.DisplayName, + "patterns": h.extension.Manifest.URLHandler.Patterns, + }) + } + + jsonBytes, err := json.Marshal(result) + if err != nil { + return "", err + } + + return string(jsonBytes), nil +} + // ==================== EXTENSION POST-PROCESSING ==================== // RunPostProcessingJSON runs post-processing hooks on a file diff --git a/go_backend/extension_manifest.go b/go_backend/extension_manifest.go index 60b5e1f..667ecce 100644 --- a/go_backend/extension_manifest.go +++ b/go_backend/extension_manifest.go @@ -74,6 +74,12 @@ type SearchBehaviorConfig struct { ThumbnailHeight int `json:"thumbnailHeight,omitempty"` // Custom thumbnail height in pixels } +// URLHandlerConfig defines custom URL handling for an extension +type URLHandlerConfig struct { + Enabled bool `json:"enabled"` // Whether extension handles URLs + Patterns []string `json:"patterns,omitempty"` // URL patterns to match (e.g., "music.youtube.com", "soundcloud.com") +} + // TrackMatchingConfig defines custom track matching behavior type TrackMatchingConfig struct { CustomMatching bool `json:"customMatching"` // Whether extension handles matching @@ -113,6 +119,7 @@ type ExtensionManifest struct { SkipMetadataEnrichment bool `json:"skipMetadataEnrichment,omitempty"` // If true, don't enrich metadata from Deezer/Spotify SkipBuiltInFallback bool `json:"skipBuiltInFallback,omitempty"` // If true, don't fallback to built-in providers (tidal/qobuz/amazon) SearchBehavior *SearchBehaviorConfig `json:"searchBehavior,omitempty"` // Custom search behavior + URLHandler *URLHandlerConfig `json:"urlHandler,omitempty"` // Custom URL handling TrackMatching *TrackMatchingConfig `json:"trackMatching,omitempty"` // Custom track matching PostProcessing *PostProcessingConfig `json:"postProcessing,omitempty"` // Post-processing hooks } @@ -270,6 +277,29 @@ func (m *ExtensionManifest) HasPostProcessing() bool { return m.PostProcessing != nil && m.PostProcessing.Enabled } +// HasURLHandler returns true if extension handles custom URLs +func (m *ExtensionManifest) HasURLHandler() bool { + return m.URLHandler != nil && m.URLHandler.Enabled && len(m.URLHandler.Patterns) > 0 +} + +// MatchesURL checks if a URL matches any of the extension's URL patterns +func (m *ExtensionManifest) MatchesURL(urlStr string) bool { + if !m.HasURLHandler() { + return false + } + + // Parse URL to get host + urlStr = strings.ToLower(strings.TrimSpace(urlStr)) + for _, pattern := range m.URLHandler.Patterns { + pattern = strings.ToLower(strings.TrimSpace(pattern)) + // Check if URL contains the pattern (host match) + if strings.Contains(urlStr, pattern) { + return true + } + } + return false +} + // GetPostProcessingHooks returns all post-processing hooks func (m *ExtensionManifest) GetPostProcessingHooks() []PostProcessingHook { if m.PostProcessing == nil { diff --git a/go_backend/extension_providers.go b/go_backend/extension_providers.go index 5eb6836..193e7d4 100644 --- a/go_backend/extension_providers.go +++ b/go_backend/extension_providers.go @@ -981,6 +981,69 @@ func (p *ExtensionProviderWrapper) CustomSearch(query string, options map[string return tracks, nil } +// ==================== Custom URL Handler ==================== + +// ExtURLHandleResult represents the result of URL handling +type ExtURLHandleResult struct { + Type string `json:"type"` // "track", "album", "playlist", "artist" + Track *ExtTrackMetadata `json:"track,omitempty"` // For single track + Tracks []ExtTrackMetadata `json:"tracks,omitempty"` // For album/playlist + Album *ExtAlbumMetadata `json:"album,omitempty"` // Album info + Artist *ExtArtistMetadata `json:"artist,omitempty"` // Artist info + Name string `json:"name,omitempty"` // Playlist/album name + CoverURL string `json:"cover_url,omitempty"` // Cover image +} + +// HandleURL processes a URL using the extension's URL handler +func (p *ExtensionProviderWrapper) HandleURL(url string) (*ExtURLHandleResult, error) { + if !p.extension.Manifest.HasURLHandler() { + return nil, fmt.Errorf("extension '%s' does not support URL handling", p.extension.ID) + } + + if !p.extension.Enabled { + return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID) + } + + script := fmt.Sprintf(` + (function() { + if (typeof extension !== 'undefined' && typeof extension.handleUrl === 'function') { + return extension.handleUrl(%q); + } + return null; + })() + `, url) + + result, err := p.vm.RunString(script) + if err != nil { + return nil, fmt.Errorf("handleUrl failed: %w", err) + } + + if result == nil || goja.IsUndefined(result) || goja.IsNull(result) { + return nil, fmt.Errorf("handleUrl returned null - URL not recognized") + } + + exported := result.Export() + jsonBytes, err := json.Marshal(exported) + if err != nil { + return nil, fmt.Errorf("failed to marshal result: %w", err) + } + + var handleResult ExtURLHandleResult + if err := json.Unmarshal(jsonBytes, &handleResult); err != nil { + return nil, fmt.Errorf("failed to parse URL handle result: %w", err) + } + + // Set provider ID on tracks + if handleResult.Track != nil { + handleResult.Track.ProviderID = p.extension.ID + } + for i := range handleResult.Tracks { + handleResult.Tracks[i].ProviderID = p.extension.ID + } + + return &handleResult, nil +} + // ==================== Custom Track Matching ==================== // MatchTrackResult represents the result of custom track matching @@ -1120,6 +1183,48 @@ func (m *ExtensionManager) GetSearchProviders() []*ExtensionProviderWrapper { return providers } +// GetURLHandlers returns all extensions that handle custom URLs +func (m *ExtensionManager) GetURLHandlers() []*ExtensionProviderWrapper { + m.mu.RLock() + defer m.mu.RUnlock() + + var providers []*ExtensionProviderWrapper + for _, ext := range m.extensions { + if ext.Enabled && ext.Manifest.HasURLHandler() && ext.Error == "" { + providers = append(providers, NewExtensionProviderWrapper(ext)) + } + } + return providers +} + +// FindURLHandler finds an extension that can handle the given URL +func (m *ExtensionManager) FindURLHandler(url string) *ExtensionProviderWrapper { + m.mu.RLock() + defer m.mu.RUnlock() + + for _, ext := range m.extensions { + if ext.Enabled && ext.Manifest.MatchesURL(url) && ext.Error == "" { + return NewExtensionProviderWrapper(ext) + } + } + return nil +} + +// HandleURLWithExtension tries to handle a URL with any matching extension +func (m *ExtensionManager) HandleURLWithExtension(url string) (*ExtURLHandleResult, string, error) { + handler := m.FindURLHandler(url) + if handler == nil { + return nil, "", fmt.Errorf("no extension found to handle URL: %s", url) + } + + result, err := handler.HandleURL(url) + if err != nil { + return nil, handler.extension.ID, err + } + + return result, handler.extension.ID, nil +} + // GetPostProcessingProviders returns all extensions that provide post-processing func (m *ExtensionManager) GetPostProcessingProviders() []*ExtensionProviderWrapper { m.mu.RLock() diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index 042c2be..fa35244 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -484,6 +484,25 @@ import Gobackend // Import Go framework if let error = error { throw error } return response + // Extension URL Handler API + case "handleURLWithExtension": + let args = call.arguments as! [String: Any] + let url = args["url"] as! String + let response = GobackendHandleURLWithExtensionJSON(url, &error) + if let error = error { throw error } + return response + + case "findURLHandler": + let args = call.arguments as! [String: Any] + let url = args["url"] as! String + let response = GobackendFindURLHandlerJSON(url) + return response + + case "getURLHandlers": + let response = GobackendGetURLHandlersJSON(&error) + if let error = error { throw error } + return response + // Extension Post-Processing API case "runPostProcessing": let args = call.arguments as! [String: Any] diff --git a/lib/providers/extension_provider.dart b/lib/providers/extension_provider.dart index b7366dd..ecba3f3 100644 --- a/lib/providers/extension_provider.dart +++ b/lib/providers/extension_provider.dart @@ -24,6 +24,7 @@ class Extension { final bool hasDownloadProvider; final bool skipMetadataEnrichment; // If true, use metadata from extension instead of enriching final SearchBehavior? searchBehavior; // Custom search behavior + final URLHandler? urlHandler; // Custom URL handling final TrackMatching? trackMatching; // Custom track matching final PostProcessing? postProcessing; // Post-processing hooks @@ -45,6 +46,7 @@ class Extension { this.hasDownloadProvider = false, this.skipMetadataEnrichment = false, this.searchBehavior, + this.urlHandler, this.trackMatching, this.postProcessing, }); @@ -74,6 +76,9 @@ class Extension { searchBehavior: json['search_behavior'] != null ? SearchBehavior.fromJson(json['search_behavior'] as Map) : null, + urlHandler: json['url_handler'] != null + ? URLHandler.fromJson(json['url_handler'] as Map) + : null, trackMatching: json['track_matching'] != null ? TrackMatching.fromJson(json['track_matching'] as Map) : null, @@ -101,6 +106,7 @@ class Extension { bool? hasDownloadProvider, bool? skipMetadataEnrichment, SearchBehavior? searchBehavior, + URLHandler? urlHandler, TrackMatching? trackMatching, PostProcessing? postProcessing, }) { @@ -122,12 +128,14 @@ class Extension { hasDownloadProvider: hasDownloadProvider ?? this.hasDownloadProvider, skipMetadataEnrichment: skipMetadataEnrichment ?? this.skipMetadataEnrichment, searchBehavior: searchBehavior ?? this.searchBehavior, + urlHandler: urlHandler ?? this.urlHandler, trackMatching: trackMatching ?? this.trackMatching, postProcessing: postProcessing ?? this.postProcessing, ); } bool get hasCustomSearch => searchBehavior?.enabled ?? false; + bool get hasURLHandler => urlHandler?.enabled ?? false; bool get hasCustomMatching => trackMatching?.customMatching ?? false; bool get hasPostProcessing => postProcessing?.enabled ?? false; } @@ -226,6 +234,36 @@ class PostProcessing { } } +/// URL handler configuration for custom URL patterns +class URLHandler { + final bool enabled; + final List patterns; + + const URLHandler({ + required this.enabled, + this.patterns = const [], + }); + + factory URLHandler.fromJson(Map json) { + return URLHandler( + enabled: json['enabled'] as bool? ?? false, + patterns: (json['patterns'] as List?)?.cast() ?? [], + ); + } + + /// Check if a URL matches any of the patterns + bool matchesURL(String url) { + if (!enabled || patterns.isEmpty) return false; + final lowerUrl = url.toLowerCase(); + for (final pattern in patterns) { + if (lowerUrl.contains(pattern.toLowerCase())) { + return true; + } + } + return false; + } +} + /// A post-processing hook class PostProcessingHook { final String id; diff --git a/lib/providers/track_provider.dart b/lib/providers/track_provider.dart index fe6ee1f..dbf190e 100644 --- a/lib/providers/track_provider.dart +++ b/lib/providers/track_provider.dart @@ -131,6 +131,45 @@ class TrackNotifier extends Notifier { state = TrackState(isLoading: true, hasSearchText: state.hasSearchText); try { + // First, check if any extension can handle this URL + final extensionHandler = await PlatformBridge.findURLHandler(url); + if (extensionHandler != null) { + _log.i('Found extension URL handler: $extensionHandler for URL: $url'); + final result = await PlatformBridge.handleURLWithExtension(url); + if (!_isRequestValid(requestId)) return; + + if (result != null) { + final type = result['type'] as String?; + final extensionId = result['extension_id'] as String?; + + if (type == 'track' && result['track'] != null) { + final trackData = result['track'] as Map; + final track = _parseSearchTrack(trackData, source: extensionId); + state = TrackState( + tracks: [track], + isLoading: false, + coverUrl: track.coverUrl, + searchExtensionId: extensionId, + ); + return; + } else if ((type == 'album' || type == 'playlist') && result['tracks'] != null) { + final trackList = result['tracks'] as List; + final tracks = trackList.map((t) => _parseSearchTrack(t as Map, source: extensionId)).toList(); + state = TrackState( + tracks: tracks, + isLoading: false, + albumId: result['album']?['id'] as String?, + albumName: result['name'] as String? ?? result['album']?['name'] as String?, + playlistName: type == 'playlist' ? result['name'] as String? : null, + coverUrl: result['cover_url'] as String?, + searchExtensionId: extensionId, + ); + return; + } + } + } + + // No extension handler found, try Spotify URL parsing final parsed = await PlatformBridge.parseSpotifyUrl(url); if (!_isRequestValid(requestId)) return; // Request cancelled diff --git a/lib/services/platform_bridge.dart b/lib/services/platform_bridge.dart index 03038ec..7810006 100644 --- a/lib/services/platform_bridge.dart +++ b/lib/services/platform_bridge.dart @@ -753,6 +753,40 @@ class PlatformBridge { return list.map((e) => e as Map).toList(); } + // ==================== EXTENSION URL HANDLER ==================== + + /// Handle a URL with any matching extension + /// Returns null if no extension can handle the URL + static Future?> handleURLWithExtension(String url) async { + try { + final result = await _channel.invokeMethod('handleURLWithExtension', { + 'url': url, + }); + if (result == null || result == '') return null; + return jsonDecode(result as String) as Map; + } catch (e) { + // No extension found or error handling URL + return null; + } + } + + /// Find an extension that can handle the given URL + /// Returns extension ID or null if none found + static Future findURLHandler(String url) async { + final result = await _channel.invokeMethod('findURLHandler', { + 'url': url, + }); + if (result == null || result == '') return null; + return result as String; + } + + /// Get all extensions that handle custom URLs + static Future>> getURLHandlers() async { + final result = await _channel.invokeMethod('getURLHandlers'); + final list = jsonDecode(result as String) as List; + return list.map((e) => e as Map).toList(); + } + // ==================== EXTENSION POST-PROCESSING ==================== /// Run post-processing hooks on a file