feat(extension): add custom URL handler support for extensions

- Add URLHandlerConfig to extension manifest (Go)
- Add HandleURL method to extension providers (Go)
- Add export functions for URL handling (Go)
- Add URLHandler class to extension_provider.dart (Flutter)
- Add platform bridge methods for URL handling (Flutter)
- Update fetchFromUrl to check extension URL handlers first
- Add Android/iOS native handlers for extension URL routing
- Update CHANGELOG with new feature
This commit is contained in:
zarzet
2026-01-12 22:22:25 +07:00
parent 4966a84614
commit 523b1edc44
9 changed files with 427 additions and 0 deletions

View File

@@ -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

View File

@@ -551,6 +551,27 @@ class MainActivity: FlutterActivity() {
}
result.success(response)
}
// Extension URL Handler API
"handleURLWithExtension" -> {
val url = call.argument<String>("url") ?: ""
val response = withContext(Dispatchers.IO) {
Gobackend.handleURLWithExtensionJSON(url)
}
result.success(response)
}
"findURLHandler" -> {
val url = call.argument<String>("url") ?: ""
val response = withContext(Dispatchers.IO) {
Gobackend.findURLHandlerJSON(url)
}
result.success(response)
}
"getURLHandlers" -> {
val response = withContext(Dispatchers.IO) {
Gobackend.getURLHandlersJSON()
}
result.success(response)
}
// Extension Post-Processing API
"runPostProcessing" -> {
val filePath = call.argument<String>("file_path") ?: ""

View File

@@ -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

View File

@@ -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 {

View File

@@ -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()

View File

@@ -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]

View File

@@ -24,6 +24,7 @@ class Extension {
final bool hasDownloadProvider;
final bool skipMetadataEnrichment; // If true, use metadata from extension instead of enriching
final SearchBehavior? searchBehavior; // Custom search behavior
final URLHandler? urlHandler; // Custom URL handling
final TrackMatching? trackMatching; // Custom track matching
final PostProcessing? postProcessing; // Post-processing hooks
@@ -45,6 +46,7 @@ class Extension {
this.hasDownloadProvider = false,
this.skipMetadataEnrichment = false,
this.searchBehavior,
this.urlHandler,
this.trackMatching,
this.postProcessing,
});
@@ -74,6 +76,9 @@ class Extension {
searchBehavior: json['search_behavior'] != null
? SearchBehavior.fromJson(json['search_behavior'] as Map<String, dynamic>)
: null,
urlHandler: json['url_handler'] != null
? URLHandler.fromJson(json['url_handler'] as Map<String, dynamic>)
: null,
trackMatching: json['track_matching'] != null
? TrackMatching.fromJson(json['track_matching'] as Map<String, dynamic>)
: null,
@@ -101,6 +106,7 @@ class Extension {
bool? hasDownloadProvider,
bool? skipMetadataEnrichment,
SearchBehavior? searchBehavior,
URLHandler? urlHandler,
TrackMatching? trackMatching,
PostProcessing? postProcessing,
}) {
@@ -122,12 +128,14 @@ class Extension {
hasDownloadProvider: hasDownloadProvider ?? this.hasDownloadProvider,
skipMetadataEnrichment: skipMetadataEnrichment ?? this.skipMetadataEnrichment,
searchBehavior: searchBehavior ?? this.searchBehavior,
urlHandler: urlHandler ?? this.urlHandler,
trackMatching: trackMatching ?? this.trackMatching,
postProcessing: postProcessing ?? this.postProcessing,
);
}
bool get hasCustomSearch => searchBehavior?.enabled ?? false;
bool get hasURLHandler => urlHandler?.enabled ?? false;
bool get hasCustomMatching => trackMatching?.customMatching ?? false;
bool get hasPostProcessing => postProcessing?.enabled ?? false;
}
@@ -226,6 +234,36 @@ class PostProcessing {
}
}
/// URL handler configuration for custom URL patterns
class URLHandler {
final bool enabled;
final List<String> patterns;
const URLHandler({
required this.enabled,
this.patterns = const [],
});
factory URLHandler.fromJson(Map<String, dynamic> json) {
return URLHandler(
enabled: json['enabled'] as bool? ?? false,
patterns: (json['patterns'] as List<dynamic>?)?.cast<String>() ?? [],
);
}
/// Check if a URL matches any of the patterns
bool matchesURL(String url) {
if (!enabled || patterns.isEmpty) return false;
final lowerUrl = url.toLowerCase();
for (final pattern in patterns) {
if (lowerUrl.contains(pattern.toLowerCase())) {
return true;
}
}
return false;
}
}
/// A post-processing hook
class PostProcessingHook {
final String id;

View File

@@ -131,6 +131,45 @@ class TrackNotifier extends Notifier<TrackState> {
state = TrackState(isLoading: true, hasSearchText: state.hasSearchText);
try {
// First, check if any extension can handle this URL
final extensionHandler = await PlatformBridge.findURLHandler(url);
if (extensionHandler != null) {
_log.i('Found extension URL handler: $extensionHandler for URL: $url');
final result = await PlatformBridge.handleURLWithExtension(url);
if (!_isRequestValid(requestId)) return;
if (result != null) {
final type = result['type'] as String?;
final extensionId = result['extension_id'] as String?;
if (type == 'track' && result['track'] != null) {
final trackData = result['track'] as Map<String, dynamic>;
final track = _parseSearchTrack(trackData, source: extensionId);
state = TrackState(
tracks: [track],
isLoading: false,
coverUrl: track.coverUrl,
searchExtensionId: extensionId,
);
return;
} else if ((type == 'album' || type == 'playlist') && result['tracks'] != null) {
final trackList = result['tracks'] as List<dynamic>;
final tracks = trackList.map((t) => _parseSearchTrack(t as Map<String, dynamic>, source: extensionId)).toList();
state = TrackState(
tracks: tracks,
isLoading: false,
albumId: result['album']?['id'] as String?,
albumName: result['name'] as String? ?? result['album']?['name'] as String?,
playlistName: type == 'playlist' ? result['name'] as String? : null,
coverUrl: result['cover_url'] as String?,
searchExtensionId: extensionId,
);
return;
}
}
}
// No extension handler found, try Spotify URL parsing
final parsed = await PlatformBridge.parseSpotifyUrl(url);
if (!_isRequestValid(requestId)) return; // Request cancelled

View File

@@ -753,6 +753,40 @@ class PlatformBridge {
return list.map((e) => e as Map<String, dynamic>).toList();
}
// ==================== EXTENSION URL HANDLER ====================
/// Handle a URL with any matching extension
/// Returns null if no extension can handle the URL
static Future<Map<String, dynamic>?> handleURLWithExtension(String url) async {
try {
final result = await _channel.invokeMethod('handleURLWithExtension', {
'url': url,
});
if (result == null || result == '') return null;
return jsonDecode(result as String) as Map<String, dynamic>;
} catch (e) {
// No extension found or error handling URL
return null;
}
}
/// Find an extension that can handle the given URL
/// Returns extension ID or null if none found
static Future<String?> findURLHandler(String url) async {
final result = await _channel.invokeMethod('findURLHandler', {
'url': url,
});
if (result == null || result == '') return null;
return result as String;
}
/// Get all extensions that handle custom URLs
static Future<List<Map<String, dynamic>>> getURLHandlers() async {
final result = await _channel.invokeMethod('getURLHandlers');
final list = jsonDecode(result as String) as List<dynamic>;
return list.map((e) => e as Map<String, dynamic>).toList();
}
// ==================== EXTENSION POST-PROCESSING ====================
/// Run post-processing hooks on a file