mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-09-30 21:29:36 +02:00
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:
9 files changed
+427
No files matched your search
@@ -1,5 +1,25 @@
|
|||||||
# Changelog
|
# 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
|
## [3.0.0-alpha.3] - 2026-01-12
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -551,6 +551,27 @@ class MainActivity: FlutterActivity() {
|
|||||||
}
|
}
|
||||||
result.success(response)
|
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
|
// Extension Post-Processing API
|
||||||
"runPostProcessing" -> {
|
"runPostProcessing" -> {
|
||||||
val filePath = call.argument<String>("file_path") ?: ""
|
val filePath = call.argument<String>("file_path") ?: ""
|
||||||
|
|||||||
@@ -1506,6 +1506,127 @@ func GetSearchProvidersJSON() (string, error) {
|
|||||||
return string(jsonBytes), nil
|
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 ====================
|
// ==================== EXTENSION POST-PROCESSING ====================
|
||||||
|
|
||||||
// RunPostProcessingJSON runs post-processing hooks on a file
|
// RunPostProcessingJSON runs post-processing hooks on a file
|
||||||
|
|||||||
@@ -74,6 +74,12 @@ type SearchBehaviorConfig struct {
|
|||||||
ThumbnailHeight int `json:"thumbnailHeight,omitempty"` // Custom thumbnail height in pixels
|
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
|
// TrackMatchingConfig defines custom track matching behavior
|
||||||
type TrackMatchingConfig struct {
|
type TrackMatchingConfig struct {
|
||||||
CustomMatching bool `json:"customMatching"` // Whether extension handles matching
|
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
|
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)
|
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
|
SearchBehavior *SearchBehaviorConfig `json:"searchBehavior,omitempty"` // Custom search behavior
|
||||||
|
URLHandler *URLHandlerConfig `json:"urlHandler,omitempty"` // Custom URL handling
|
||||||
TrackMatching *TrackMatchingConfig `json:"trackMatching,omitempty"` // Custom track matching
|
TrackMatching *TrackMatchingConfig `json:"trackMatching,omitempty"` // Custom track matching
|
||||||
PostProcessing *PostProcessingConfig `json:"postProcessing,omitempty"` // Post-processing hooks
|
PostProcessing *PostProcessingConfig `json:"postProcessing,omitempty"` // Post-processing hooks
|
||||||
}
|
}
|
||||||
@@ -270,6 +277,29 @@ func (m *ExtensionManifest) HasPostProcessing() bool {
|
|||||||
return m.PostProcessing != nil && m.PostProcessing.Enabled
|
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
|
// GetPostProcessingHooks returns all post-processing hooks
|
||||||
func (m *ExtensionManifest) GetPostProcessingHooks() []PostProcessingHook {
|
func (m *ExtensionManifest) GetPostProcessingHooks() []PostProcessingHook {
|
||||||
if m.PostProcessing == nil {
|
if m.PostProcessing == nil {
|
||||||
|
|||||||
@@ -981,6 +981,69 @@ func (p *ExtensionProviderWrapper) CustomSearch(query string, options map[string
|
|||||||
return tracks, nil
|
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 ====================
|
// ==================== Custom Track Matching ====================
|
||||||
|
|
||||||
// MatchTrackResult represents the result of custom track matching
|
// MatchTrackResult represents the result of custom track matching
|
||||||
@@ -1120,6 +1183,48 @@ func (m *ExtensionManager) GetSearchProviders() []*ExtensionProviderWrapper {
|
|||||||
return providers
|
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
|
// GetPostProcessingProviders returns all extensions that provide post-processing
|
||||||
func (m *ExtensionManager) GetPostProcessingProviders() []*ExtensionProviderWrapper {
|
func (m *ExtensionManager) GetPostProcessingProviders() []*ExtensionProviderWrapper {
|
||||||
m.mu.RLock()
|
m.mu.RLock()
|
||||||
|
|||||||
@@ -484,6 +484,25 @@ import Gobackend // Import Go framework
|
|||||||
if let error = error { throw error }
|
if let error = error { throw error }
|
||||||
return response
|
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
|
// Extension Post-Processing API
|
||||||
case "runPostProcessing":
|
case "runPostProcessing":
|
||||||
let args = call.arguments as! [String: Any]
|
let args = call.arguments as! [String: Any]
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ class Extension {
|
|||||||
final bool hasDownloadProvider;
|
final bool hasDownloadProvider;
|
||||||
final bool skipMetadataEnrichment; // If true, use metadata from extension instead of enriching
|
final bool skipMetadataEnrichment; // If true, use metadata from extension instead of enriching
|
||||||
final SearchBehavior? searchBehavior; // Custom search behavior
|
final SearchBehavior? searchBehavior; // Custom search behavior
|
||||||
|
final URLHandler? urlHandler; // Custom URL handling
|
||||||
final TrackMatching? trackMatching; // Custom track matching
|
final TrackMatching? trackMatching; // Custom track matching
|
||||||
final PostProcessing? postProcessing; // Post-processing hooks
|
final PostProcessing? postProcessing; // Post-processing hooks
|
||||||
|
|
||||||
@@ -45,6 +46,7 @@ class Extension {
|
|||||||
this.hasDownloadProvider = false,
|
this.hasDownloadProvider = false,
|
||||||
this.skipMetadataEnrichment = false,
|
this.skipMetadataEnrichment = false,
|
||||||
this.searchBehavior,
|
this.searchBehavior,
|
||||||
|
this.urlHandler,
|
||||||
this.trackMatching,
|
this.trackMatching,
|
||||||
this.postProcessing,
|
this.postProcessing,
|
||||||
});
|
});
|
||||||
@@ -74,6 +76,9 @@ class Extension {
|
|||||||
searchBehavior: json['search_behavior'] != null
|
searchBehavior: json['search_behavior'] != null
|
||||||
? SearchBehavior.fromJson(json['search_behavior'] as Map<String, dynamic>)
|
? SearchBehavior.fromJson(json['search_behavior'] as Map<String, dynamic>)
|
||||||
: null,
|
: null,
|
||||||
|
urlHandler: json['url_handler'] != null
|
||||||
|
? URLHandler.fromJson(json['url_handler'] as Map<String, dynamic>)
|
||||||
|
: null,
|
||||||
trackMatching: json['track_matching'] != null
|
trackMatching: json['track_matching'] != null
|
||||||
? TrackMatching.fromJson(json['track_matching'] as Map<String, dynamic>)
|
? TrackMatching.fromJson(json['track_matching'] as Map<String, dynamic>)
|
||||||
: null,
|
: null,
|
||||||
@@ -101,6 +106,7 @@ class Extension {
|
|||||||
bool? hasDownloadProvider,
|
bool? hasDownloadProvider,
|
||||||
bool? skipMetadataEnrichment,
|
bool? skipMetadataEnrichment,
|
||||||
SearchBehavior? searchBehavior,
|
SearchBehavior? searchBehavior,
|
||||||
|
URLHandler? urlHandler,
|
||||||
TrackMatching? trackMatching,
|
TrackMatching? trackMatching,
|
||||||
PostProcessing? postProcessing,
|
PostProcessing? postProcessing,
|
||||||
}) {
|
}) {
|
||||||
@@ -122,12 +128,14 @@ class Extension {
|
|||||||
hasDownloadProvider: hasDownloadProvider ?? this.hasDownloadProvider,
|
hasDownloadProvider: hasDownloadProvider ?? this.hasDownloadProvider,
|
||||||
skipMetadataEnrichment: skipMetadataEnrichment ?? this.skipMetadataEnrichment,
|
skipMetadataEnrichment: skipMetadataEnrichment ?? this.skipMetadataEnrichment,
|
||||||
searchBehavior: searchBehavior ?? this.searchBehavior,
|
searchBehavior: searchBehavior ?? this.searchBehavior,
|
||||||
|
urlHandler: urlHandler ?? this.urlHandler,
|
||||||
trackMatching: trackMatching ?? this.trackMatching,
|
trackMatching: trackMatching ?? this.trackMatching,
|
||||||
postProcessing: postProcessing ?? this.postProcessing,
|
postProcessing: postProcessing ?? this.postProcessing,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
bool get hasCustomSearch => searchBehavior?.enabled ?? false;
|
bool get hasCustomSearch => searchBehavior?.enabled ?? false;
|
||||||
|
bool get hasURLHandler => urlHandler?.enabled ?? false;
|
||||||
bool get hasCustomMatching => trackMatching?.customMatching ?? false;
|
bool get hasCustomMatching => trackMatching?.customMatching ?? false;
|
||||||
bool get hasPostProcessing => postProcessing?.enabled ?? 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
|
/// A post-processing hook
|
||||||
class PostProcessingHook {
|
class PostProcessingHook {
|
||||||
final String id;
|
final String id;
|
||||||
|
|||||||
@@ -131,6 +131,45 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
state = TrackState(isLoading: true, hasSearchText: state.hasSearchText);
|
state = TrackState(isLoading: true, hasSearchText: state.hasSearchText);
|
||||||
|
|
||||||
try {
|
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);
|
final parsed = await PlatformBridge.parseSpotifyUrl(url);
|
||||||
if (!_isRequestValid(requestId)) return; // Request cancelled
|
if (!_isRequestValid(requestId)) return; // Request cancelled
|
||||||
|
|
||||||
|
|||||||
@@ -753,6 +753,40 @@ class PlatformBridge {
|
|||||||
return list.map((e) => e as Map<String, dynamic>).toList();
|
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 ====================
|
// ==================== EXTENSION POST-PROCESSING ====================
|
||||||
|
|
||||||
/// Run post-processing hooks on a file
|
/// Run post-processing hooks on a file
|
||||||
|
|||||||
Reference in new issue
Block a user