diff --git a/go_backend/cross_extension_share.go b/go_backend/cross_extension_share.go index 4bde91b3..640f94c1 100644 --- a/go_backend/cross_extension_share.go +++ b/go_backend/cross_extension_share.go @@ -96,7 +96,7 @@ func findCollectionForExtension( result.DisplayName = provider.extension.ID } - searchResult, err := provider.SearchTracks(query, 10) + searchResult, err := searchCollectionCandidates(provider, itemType, query) if err != nil { result.Error = err.Error() return result @@ -130,14 +130,36 @@ func findCollectionForExtension( result.Found = true result.URL = url if itemType == "artist" { - result.ItemName = best.Artists + result.ItemName = collectionArtistName(*best) } else { - result.ItemName = best.AlbumName + result.ItemName = collectionAlbumName(*best) result.ItemArtists = best.Artists } return result } +func searchCollectionCandidates(provider *extensionProviderWrapper, itemType string, query string) (*ExtSearchResult, error) { + filter := "" + switch itemType { + case "album": + filter = "albums" + case "artist": + filter = "artists" + } + + if filter != "" { + tracks, err := provider.CustomSearch(query, map[string]interface{}{ + "filter": filter, + "limit": 10, + }) + if err == nil && len(tracks) > 0 { + return &ExtSearchResult{Tracks: tracks, Total: len(tracks)}, nil + } + } + + return provider.SearchTracks(query, 10) +} + func bestAlbumTrack(tracks []ExtTrackMetadata, albumName string, artists string) *ExtTrackMetadata { targetAlbum := normalizeLooseTitle(albumName) targetArtists := normalizeLooseArtistName(artists) @@ -146,10 +168,13 @@ func bestAlbumTrack(tracks []ExtTrackMetadata, albumName string, artists string) for i := range tracks { track := tracks[i] - album := normalizeLooseTitle(track.AlbumName) + album := normalizeLooseTitle(collectionAlbumName(track)) trackArtists := normalizeLooseArtistName(track.Artists + " " + track.AlbumArtist) score := 0 + if isCollectionItemType(track, "album") { + score += 25 + } if album == targetAlbum { score += 100 } else if album != "" && targetAlbum != "" && (strings.Contains(album, targetAlbum) || strings.Contains(targetAlbum, album)) { @@ -176,8 +201,11 @@ func bestArtistTrack(tracks []ExtTrackMetadata, artistName string) *ExtTrackMeta bestIndex := -1 for i := range tracks { - artist := normalizeLooseArtistName(tracks[i].Artists) + artist := normalizeLooseArtistName(collectionArtistName(tracks[i])) score := 0 + if isCollectionItemType(tracks[i], "artist") { + score += 25 + } if artist == targetArtist { score += 100 } else if artist != "" && targetArtist != "" && (strings.Contains(artist, targetArtist) || strings.Contains(targetArtist, artist)) { @@ -201,30 +229,65 @@ func resolveCollectionShareURL(ext *loadedExtension, itemType string, track *Ext } if itemType == "album" { + if isCollectionItemType(*track, "album") { + if url := normalizeShareURL(track.ExternalURL); url != "" { + return url + } + } if url := normalizeShareURL(track.AlbumURL); url != "" { return url } if url := urlFromExternalLinks(track.ExternalLinks, "album"); url != "" { return url } - if url := templateShareURL(ext, "album", firstNonEmptyString(track.AlbumID, track.AlbumURL)); url != "" { + if url := templateShareURL(ext, "album", firstNonEmptyString(track.AlbumID, collectionID(*track, "album"), track.AlbumURL)); url != "" { return url } return "" } + if isCollectionItemType(*track, "artist") { + if url := normalizeShareURL(track.ExternalURL); url != "" { + return url + } + } if url := normalizeShareURL(track.ArtistURL); url != "" { return url } if url := urlFromExternalLinks(track.ExternalLinks, "artist"); url != "" { return url } - if url := templateShareURL(ext, "artist", track.ArtistID); url != "" { + if url := templateShareURL(ext, "artist", firstNonEmptyString(track.ArtistID, collectionID(*track, "artist"))); url != "" { return url } return "" } +func collectionAlbumName(track ExtTrackMetadata) string { + if isCollectionItemType(track, "album") { + return track.Name + } + return track.AlbumName +} + +func collectionArtistName(track ExtTrackMetadata) string { + if isCollectionItemType(track, "artist") { + return track.Name + } + return track.Artists +} + +func collectionID(track ExtTrackMetadata, itemType string) string { + if isCollectionItemType(track, itemType) { + return track.ID + } + return "" +} + +func isCollectionItemType(track ExtTrackMetadata, itemType string) bool { + return strings.EqualFold(strings.TrimSpace(track.ItemType), itemType) +} + func normalizeShareURL(value string) string { trimmed := strings.TrimSpace(value) if strings.HasPrefix(trimmed, "http://") || strings.HasPrefix(trimmed, "https://") { diff --git a/go_backend/cross_extension_share_test.go b/go_backend/cross_extension_share_test.go new file mode 100644 index 00000000..beb2a38d --- /dev/null +++ b/go_backend/cross_extension_share_test.go @@ -0,0 +1,58 @@ +package gobackend + +import "testing" + +func TestCrossExtensionShareUsesAlbumCollectionItems(t *testing.T) { + ext := &loadedExtension{ + Manifest: &ExtensionManifest{ + Capabilities: map[string]interface{}{ + "shareUrlTemplates": map[string]interface{}{ + "album": "https://music.apple.com/us/album/{id}", + }, + }, + }, + } + tracks := []ExtTrackMetadata{ + { + ID: "1440783617", + Name: "Nevermind", + Artists: "Nirvana", + ItemType: "album", + }, + } + + best := bestAlbumTrack(tracks, "Nevermind", "Nirvana") + if best == nil { + t.Fatal("expected album collection item to match") + } + if url := resolveCollectionShareURL(ext, "album", best); url != "https://music.apple.com/us/album/1440783617" { + t.Fatalf("album share URL = %q", url) + } +} + +func TestCrossExtensionShareUsesArtistCollectionItems(t *testing.T) { + ext := &loadedExtension{ + Manifest: &ExtensionManifest{ + Capabilities: map[string]interface{}{ + "shareUrlTemplates": map[string]interface{}{ + "artist": "https://music.youtube.com/browse/{id}", + }, + }, + }, + } + tracks := []ExtTrackMetadata{ + { + ID: "UCrPe3hLA51968GwxHSZ1llw", + Name: "Nirvana", + ItemType: "artist", + }, + } + + best := bestArtistTrack(tracks, "Nirvana") + if best == nil { + t.Fatal("expected artist collection item to match") + } + if url := resolveCollectionShareURL(ext, "artist", best); url != "https://music.youtube.com/browse/UCrPe3hLA51968GwxHSZ1llw" { + t.Fatalf("artist share URL = %q", url) + } +} diff --git a/go_backend/extension_health.go b/go_backend/extension_health.go index 102fe80a..85b44151 100644 --- a/go_backend/extension_health.go +++ b/go_backend/extension_health.go @@ -8,12 +8,14 @@ import ( "net/http" "net/url" "strings" + "sync" "time" ) const ( extensionHealthDefaultTimeout = 4 * time.Second extensionHealthMaxBodyBytes = 64 * 1024 + extensionHealthDefaultCache = 60 * time.Second ) type ExtensionHealthResult struct { @@ -38,6 +40,16 @@ type ExtensionHealthCheckResult struct { CheckedAt string `json:"checked_at"` } +type cachedExtensionHealthResult struct { + result ExtensionHealthResult + expiresAt time.Time +} + +var ( + extensionHealthCacheMu sync.Mutex + extensionHealthCache = map[string]cachedExtensionHealthResult{} +) + func CheckExtensionHealthJSON(extensionID string) (string, error) { manager := getExtensionManager() ext, err := manager.GetExtension(extensionID) @@ -53,6 +65,38 @@ func CheckExtensionHealthJSON(extensionID string) (string, error) { return string(bytes), nil } +func CheckExtensionHealthCached(ext *loadedExtension) ExtensionHealthResult { + if ext == nil || ext.Manifest == nil || len(ext.Manifest.ServiceHealth) == 0 { + return CheckExtensionHealth(ext) + } + + cacheKey := strings.TrimSpace(ext.ID) + if cacheKey == "" { + return CheckExtensionHealth(ext) + } + + now := time.Now() + extensionHealthCacheMu.Lock() + cached, ok := extensionHealthCache[cacheKey] + if ok && now.Before(cached.expiresAt) { + extensionHealthCacheMu.Unlock() + return cached.result + } + extensionHealthCacheMu.Unlock() + + result := CheckExtensionHealth(ext) + ttl := extensionHealthCacheTTL(ext.Manifest.ServiceHealth) + + extensionHealthCacheMu.Lock() + extensionHealthCache[cacheKey] = cachedExtensionHealthResult{ + result: result, + expiresAt: now.Add(ttl), + } + extensionHealthCacheMu.Unlock() + + return result +} + func CheckExtensionHealth(ext *loadedExtension) ExtensionHealthResult { now := time.Now().UTC().Format(time.RFC3339) result := ExtensionHealthResult{ @@ -98,6 +142,20 @@ func CheckExtensionHealth(ext *loadedExtension) ExtensionHealthResult { return result } +func extensionHealthCacheTTL(checks []ExtensionHealthCheck) time.Duration { + ttl := extensionHealthDefaultCache + for _, check := range checks { + if check.CacheTTLSeconds <= 0 { + continue + } + checkTTL := time.Duration(check.CacheTTLSeconds) * time.Second + if checkTTL < ttl { + ttl = checkTTL + } + } + return ttl +} + func runExtensionHealthCheck(manifest *ExtensionManifest, check ExtensionHealthCheck) ExtensionHealthCheckResult { method := strings.ToUpper(strings.TrimSpace(check.Method)) if method == "" { diff --git a/go_backend/extension_providers.go b/go_backend/extension_providers.go index de546808..8185db9f 100644 --- a/go_backend/extension_providers.go +++ b/go_backend/extension_providers.go @@ -382,6 +382,64 @@ func shouldStopProviderFallback(availability *ExtAvailabilityResult) bool { return availability != nil && availability.SkipFallback } +func fallbackRuntimeHealthStatus(ext *loadedExtension) string { + if ext == nil || ext.Manifest == nil || len(ext.Manifest.ServiceHealth) == 0 { + return "unknown" + } + + status := strings.ToLower(strings.TrimSpace(CheckExtensionHealthCached(ext).Status)) + switch status { + case "online", "degraded", "offline": + return status + default: + return "unknown" + } +} + +func prioritizeFallbackProvidersByHealth(priority []string, extManager *extensionManager, sourceProvider string) []string { + if len(priority) == 0 || extManager == nil { + return priority + } + + online := make([]string, 0, len(priority)) + degraded := make([]string, 0, len(priority)) + unknown := make([]string, 0, len(priority)) + + for _, rawProviderID := range priority { + providerID := strings.TrimSpace(rawProviderID) + if providerID == "" { + continue + } + if strings.EqualFold(providerID, sourceProvider) || !isExtensionFallbackAllowed(providerID) { + unknown = append(unknown, providerID) + continue + } + + ext, err := extManager.GetExtension(providerID) + if err != nil || ext == nil || !ext.Enabled || ext.Error != "" || ext.Manifest == nil || !ext.Manifest.IsDownloadProvider() { + unknown = append(unknown, providerID) + continue + } + + switch fallbackRuntimeHealthStatus(ext) { + case "online": + online = append(online, providerID) + case "degraded": + degraded = append(degraded, providerID) + case "offline": + GoLog("[DownloadWithExtensionFallback] Skipping extension provider %s (service health offline)\n", providerID) + default: + unknown = append(unknown, providerID) + } + } + + result := make([]string, 0, len(online)+len(degraded)+len(unknown)) + result = append(result, online...) + result = append(result, degraded...) + result = append(result, unknown...) + return result +} + func resolveExtensionAvailabilityReason(availability *ExtAvailabilityResult, err error) string { if availability != nil { if reason := strings.TrimSpace(availability.Reason); reason != "" { @@ -1800,7 +1858,9 @@ func isRetiredBuiltInDownloadProvider(providerID string) bool { } switch normalized { case "deezer", "qobuz", "tidal": - return true + return !hasEnabledExtensionProvider(normalized, func(manifest *ExtensionManifest) bool { + return manifest.IsDownloadProvider() + }) default: return false } @@ -1813,12 +1873,36 @@ func isRetiredBuiltInMetadataProvider(providerID string) bool { } switch normalized { case "deezer", "spotify", "qobuz", "tidal": - return true + return !hasEnabledExtensionProvider(normalized, func(manifest *ExtensionManifest) bool { + return manifest.IsMetadataProvider() + }) default: return false } } +func hasEnabledExtensionProvider(providerID string, matches func(*ExtensionManifest) bool) bool { + if providerID == "" || matches == nil { + return false + } + + manager := getExtensionManager() + manager.mu.RLock() + defer manager.mu.RUnlock() + + for id, ext := range manager.extensions { + if !strings.EqualFold(strings.TrimSpace(id), providerID) { + continue + } + if ext == nil || !ext.Enabled || ext.Error != "" || ext.Manifest == nil { + return false + } + return matches(ext.Manifest) + } + + return false +} + func SetExtensionFallbackProviderIDs(providerIDs []string) { extensionFallbackProviderIDsMu.Lock() defer extensionFallbackProviderIDsMu.Unlock() @@ -2388,6 +2472,8 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro } } + priority = prioritizeFallbackProvidersByHealth(priority, extManager, req.Source) + for _, providerID := range priority { if isDownloadCancelled(req.ItemID) { return nil, ErrDownloadCancelled diff --git a/go_backend/extension_providers_test.go b/go_backend/extension_providers_test.go index 8c47ca5a..7d994969 100644 --- a/go_backend/extension_providers_test.go +++ b/go_backend/extension_providers_test.go @@ -93,6 +93,125 @@ func TestSetProviderPriorityRemovesRetiredDeezerDownloader(t *testing.T) { } } +func TestSetProviderPriorityKeepsExtensionNamedLikeRetiredDownloader(t *testing.T) { + original := GetProviderPriority() + defer SetProviderPriority(original) + + manager := getExtensionManager() + ext := newTestLoadedExtension(t, ExtensionTypeDownloadProvider) + ext.ID = "deezer" + ext.Manifest.Name = "deezer" + + manager.mu.Lock() + previous, hadPrevious := manager.extensions[ext.ID] + manager.extensions[ext.ID] = ext + manager.mu.Unlock() + defer func() { + manager.mu.Lock() + if hadPrevious { + manager.extensions[ext.ID] = previous + } else { + delete(manager.extensions, ext.ID) + } + manager.mu.Unlock() + }() + + SetProviderPriority([]string{"deezer", "custom-ext"}) + + got := GetProviderPriority() + want := []string{"deezer", "custom-ext"} + if len(got) != len(want) { + t.Fatalf("unexpected priority length: got %v want %v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("unexpected priority at %d: got %v want %v", i, got, want) + } + } +} + +func TestPrioritizeFallbackProvidersByHealthPrefersOnlineAndSkipsOffline(t *testing.T) { + manager := getExtensionManager() + amazon := newTestLoadedExtension(t, ExtensionTypeDownloadProvider) + amazon.ID = "amazon" + amazon.Manifest.Name = "amazon" + amazon.Manifest.ServiceHealth = []ExtensionHealthCheck{{ + ID: "main", + URL: "://bad", + Required: true, + }} + + plain := newTestLoadedExtension(t, ExtensionTypeDownloadProvider) + plain.ID = "plain" + plain.Manifest.Name = "plain" + + deezer := newTestLoadedExtension(t, ExtensionTypeDownloadProvider) + deezer.ID = "deezer" + deezer.Manifest.Name = "deezer" + deezer.Manifest.ServiceHealth = []ExtensionHealthCheck{{ + ID: "main", + URL: "https://example.test/health", + }} + + manager.mu.Lock() + previousAmazon, hadAmazon := manager.extensions[amazon.ID] + previousPlain, hadPlain := manager.extensions[plain.ID] + previousDeezer, hadDeezer := manager.extensions[deezer.ID] + manager.extensions[amazon.ID] = amazon + manager.extensions[plain.ID] = plain + manager.extensions[deezer.ID] = deezer + manager.mu.Unlock() + defer func() { + manager.mu.Lock() + if hadAmazon { + manager.extensions[amazon.ID] = previousAmazon + } else { + delete(manager.extensions, amazon.ID) + } + if hadPlain { + manager.extensions[plain.ID] = previousPlain + } else { + delete(manager.extensions, plain.ID) + } + if hadDeezer { + manager.extensions[deezer.ID] = previousDeezer + } else { + delete(manager.extensions, deezer.ID) + } + manager.mu.Unlock() + + extensionHealthCacheMu.Lock() + delete(extensionHealthCache, deezer.ID) + extensionHealthCacheMu.Unlock() + }() + + extensionHealthCacheMu.Lock() + extensionHealthCache[deezer.ID] = cachedExtensionHealthResult{ + result: ExtensionHealthResult{ + ExtensionID: deezer.ID, + Status: "online", + CheckedAt: time.Now().UTC().Format(time.RFC3339), + }, + expiresAt: time.Now().Add(time.Minute), + } + extensionHealthCacheMu.Unlock() + + got := prioritizeFallbackProvidersByHealth( + []string{"amazon", "plain", "deezer"}, + manager, + "", + ) + want := []string{"deezer", "plain"} + if len(got) != len(want) { + t.Fatalf("unexpected provider order length: got %v want %v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("unexpected provider order at %d: got %v want %v", i, got, want) + } + } +} + func TestNormalizeDownloadDecryptionInfoPromotesLegacyKey(t *testing.T) { normalized := normalizeDownloadDecryptionInfo(nil, " 001122 ") if normalized == nil { diff --git a/lib/screens/album_screen.dart b/lib/screens/album_screen.dart index cfb85b1f..ab4c959d 100644 --- a/lib/screens/album_screen.dart +++ b/lib/screens/album_screen.dart @@ -567,20 +567,24 @@ class _AlbumScreenState extends ConsumerState { children: [ _buildLoveAllButton(), const SizedBox(width: 12), - _buildShareButton(context, tracks, artistName), - const SizedBox(width: 12), - FilledButton.icon( - onPressed: () => _downloadAll(context), - icon: Icon(Icons.download, size: 18), - label: Text( - context.l10n.downloadAllCount(tracks.length), - ), - style: FilledButton.styleFrom( - backgroundColor: Colors.white, - foregroundColor: Colors.black87, - minimumSize: const Size(0, 48), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(24), + Flexible( + child: FilledButton.icon( + onPressed: () => _downloadAll(context), + icon: Icon(Icons.download, size: 18), + label: Text( + context.l10n.downloadAllCount( + tracks.length, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + style: FilledButton.styleFrom( + backgroundColor: Colors.white, + foregroundColor: Colors.black87, + minimumSize: const Size(0, 48), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(24), + ), ), ), ), @@ -611,6 +615,23 @@ class _AlbumScreenState extends ConsumerState { ), onPressed: () => Navigator.pop(context), ), + actions: [ + Padding( + padding: const EdgeInsets.only(right: 8), + child: IconButton( + tooltip: context.l10n.openInOtherServices, + icon: Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.4), + shape: BoxShape.circle, + ), + child: const Icon(Icons.open_in_new_rounded, color: Colors.white), + ), + onPressed: () => _showShareSheet(context, tracks, artistName), + ), + ), + ], ); } @@ -849,7 +870,7 @@ class _AlbumScreenState extends ConsumerState { ); } - Widget _buildShareButton( + void _showShareSheet( BuildContext context, List tracks, String? artistName, @@ -861,30 +882,12 @@ class _AlbumScreenState extends ConsumerState { tracks.firstOrNull?.artistName ?? ''; - return Container( - width: 48, - height: 48, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: Colors.white.withValues(alpha: 0.15), - border: Border.all( - color: Colors.white.withValues(alpha: 0.3), - width: 1, - ), - ), - child: IconButton( - onPressed: () => CrossExtensionShareSheet.show( - context, - name: widget.albumName, - artists: resolvedArtists, - type: 'album', - sourceExtensionId: sourceExtensionId, - ), - icon: const Icon(Icons.open_in_new_rounded, size: 22), - color: Colors.white, - tooltip: context.l10n.openInOtherServices, - padding: EdgeInsets.zero, - ), + CrossExtensionShareSheet.show( + context, + name: widget.albumName, + artists: resolvedArtists, + type: 'album', + sourceExtensionId: sourceExtensionId, ); } diff --git a/lib/screens/artist_screen.dart b/lib/screens/artist_screen.dart index e8d18350..37fb1756 100644 --- a/lib/screens/artist_screen.dart +++ b/lib/screens/artist_screen.dart @@ -1334,6 +1334,33 @@ class _ArtistScreenState extends ConsumerState { ), onPressed: () => Navigator.pop(context), ), + actions: [ + Padding( + padding: const EdgeInsets.only(right: 8), + child: IconButton( + tooltip: context.l10n.openInOtherServices, + icon: Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.4), + shape: BoxShape.circle, + ), + child: const Icon(Icons.open_in_new_rounded, color: Colors.white), + ), + onPressed: () => _showShareSheet(context), + ), + ), + ], + ); + } + + void _showShareSheet(BuildContext context) { + CrossExtensionShareSheet.show( + context, + name: widget.artistName, + artists: '', + type: 'artist', + sourceExtensionId: _directMetadataProviderId() ?? '', ); } @@ -1422,31 +1449,10 @@ class _ArtistScreenState extends ConsumerState { ), ); }), - ), - ), - ), - const SizedBox(width: 12), - Container( - width: 52, - height: 52, - decoration: const BoxDecoration( - color: Colors.white, - shape: BoxShape.circle, - ), - child: IconButton( - onPressed: () => CrossExtensionShareSheet.show( - context, - name: widget.artistName, - artists: '', - type: 'artist', - sourceExtensionId: _directMetadataProviderId() ?? '', - ), - icon: const Icon(Icons.open_in_new_rounded, size: 24), - color: Colors.black87, - tooltip: context.l10n.openInOtherServices, - ), - ), - ], + ), + ), + ), + ], ); }