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
+121
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
+30
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 {
+105
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()