diff --git a/go_backend/extension_manifest.go b/go_backend/extension_manifest.go index d2aebed0..e73a36fe 100644 --- a/go_backend/extension_manifest.go +++ b/go_backend/extension_manifest.go @@ -371,17 +371,57 @@ func (m *ExtensionManifest) HasURLHandler() bool { return m.URLHandler != nil && m.URLHandler.Enabled && len(m.URLHandler.Patterns) > 0 } +// MatchesURL reports whether one of the handler's patterns matches the URL. +// Web patterns are anchored to the URL's host (exact domain or subdomain, +// optional path prefix) — never matched as a raw substring, so "spotify.com" +// cannot match a URL that merely embeds it in a query parameter. Patterns +// ending in ":" (e.g. "spotify:") match custom URI schemes as prefixes. func (m *ExtensionManifest) MatchesURL(urlStr string) bool { if !m.HasURLHandler() { return false } urlStr = strings.ToLower(strings.TrimSpace(urlStr)) + parsed, parseErr := url.Parse(urlStr) + for _, pattern := range m.URLHandler.Patterns { pattern = strings.ToLower(strings.TrimSpace(pattern)) - if strings.Contains(urlStr, pattern) { - return true + if pattern == "" { + continue } + + // Scheme patterns anchor to the front of the URI. + if !strings.Contains(pattern, "/") && strings.HasSuffix(pattern, ":") { + if strings.HasPrefix(urlStr, pattern) { + return true + } + continue + } + + if parseErr != nil || parsed.Host == "" { + continue + } + host := parsed.Hostname() + urlPath := parsed.Path + if urlPath == "" { + urlPath = "/" + } + + if idx := strings.Index(pattern, "://"); idx >= 0 { + pattern = pattern[idx+3:] + } + patternHost, patternPath, hasPath := strings.Cut(pattern, "/") + if patternHost == "" { + continue + } + if host != patternHost && !strings.HasSuffix(host, "."+patternHost) { + continue + } + if hasPath && patternPath != "" && + !strings.HasPrefix(urlPath, "/"+patternPath) { + continue + } + return true } return false } diff --git a/go_backend/extension_providers.go b/go_backend/extension_providers.go index f6fe0c9f..c375af63 100644 --- a/go_backend/extension_providers.go +++ b/go_backend/extension_providers.go @@ -133,16 +133,44 @@ func (m *extensionManager) SearchTracksWithMetadataProvidersForItemID(query stri return tracks, nil } +// FindURLHandler returns the enabled handler matching the URL. When several +// extensions match (e.g. two Spotify handlers), the user's metadata provider +// priority breaks the tie deterministically instead of Go's random map +// iteration order. func (m *extensionManager) FindURLHandler(url string) *extensionProviderWrapper { m.mu.RLock() - defer m.mu.RUnlock() - + matches := make([]*loadedExtension, 0, 2) for _, ext := range m.extensions { if ext.Enabled && ext.Manifest.MatchesURL(url) && ext.Error == "" { - return newExtensionProviderWrapper(ext) + matches = append(matches, ext) } } - return nil + m.mu.RUnlock() + + if len(matches) == 0 { + return nil + } + if len(matches) > 1 { + rank := map[string]int{} + for i, id := range GetMetadataProviderPriority() { + rank[strings.ToLower(strings.TrimSpace(id))] = i + } + sort.SliceStable(matches, func(i, j int) bool { + ri, oki := rank[strings.ToLower(matches[i].ID)] + rj, okj := rank[strings.ToLower(matches[j].ID)] + switch { + case oki && okj: + return ri < rj + case oki: + return true + case okj: + return false + default: + return matches[i].ID < matches[j].ID + } + }) + } + return newExtensionProviderWrapper(matches[0]) } type ExtURLHandleResultWithExtID struct { diff --git a/go_backend/extension_providers_test.go b/go_backend/extension_providers_test.go index 6ebdb669..bd2a9a54 100644 --- a/go_backend/extension_providers_test.go +++ b/go_backend/extension_providers_test.go @@ -777,3 +777,50 @@ func TestParseExtensionAuxiliaryResults(t *testing.T) { t.Fatalf("unexpected lyrics result: %+v", lyrics) } } + +func TestMatchesURLHostAnchored(t *testing.T) { + manifest := &ExtensionManifest{ + URLHandler: &URLHandlerConfig{ + Enabled: true, + Patterns: []string{"spotify.com", "deezer.page.link", "spotify:"}, + }, + } + + for _, urlStr := range []string{ + "https://open.spotify.com/track/abc", + "https://spotify.com/track/abc", + "HTTPS://OPEN.SPOTIFY.COM/track/ABC", + "https://deezer.page.link/xyz", + "spotify:track:abc123", + } { + if !manifest.MatchesURL(urlStr) { + t.Fatalf("expected match for %q", urlStr) + } + } + + for _, urlStr := range []string{ + // The old substring matching accepted all of these. + "https://evil.example/?next=https://spotify.com/track/abc", + "https://notspotify.com/track/abc", + "https://spotify.com.evil.example/track/abc", + "https://example.com/spotify.com", + "not a url at all", + } { + if manifest.MatchesURL(urlStr) { + t.Fatalf("expected no match for %q", urlStr) + } + } + + withPath := &ExtensionManifest{ + URLHandler: &URLHandlerConfig{ + Enabled: true, + Patterns: []string{"youtube.com/watch"}, + }, + } + if !withPath.MatchesURL("https://www.youtube.com/watch?v=abc") { + t.Fatal("expected host+path prefix to match") + } + if withPath.MatchesURL("https://www.youtube.com/playlist?list=abc") { + t.Fatal("expected different path to not match") + } +}