Compare commits
2
Commits
v2.9.0-beta
...
stash-6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8f7da70352 | ||
|
|
526727955f |
@@ -58,6 +58,10 @@ class MediaItem {
|
||||
final bool? favorite;
|
||||
final int? position;
|
||||
final Duration? duration;
|
||||
final DateTime? timestampAdded;
|
||||
final DateTime? timestampModified;
|
||||
final DateTime? lastPlayed;
|
||||
final int? playCount;
|
||||
|
||||
MediaItem({
|
||||
required this.itemId,
|
||||
@@ -71,6 +75,10 @@ class MediaItem {
|
||||
this.favorite,
|
||||
this.position,
|
||||
this.duration,
|
||||
this.timestampAdded,
|
||||
this.timestampModified,
|
||||
this.lastPlayed,
|
||||
this.playCount,
|
||||
});
|
||||
|
||||
factory MediaItem.fromJson(Map<String, dynamic> json) {
|
||||
@@ -83,6 +91,21 @@ class MediaItem {
|
||||
);
|
||||
}
|
||||
|
||||
// Parse timestamp fields (can be int unix timestamp or ISO string)
|
||||
DateTime? parseTimestamp(dynamic value) {
|
||||
if (value == null) return null;
|
||||
if (value is int) {
|
||||
return DateTime.fromMillisecondsSinceEpoch(value * 1000);
|
||||
}
|
||||
if (value is double) {
|
||||
return DateTime.fromMillisecondsSinceEpoch((value * 1000).toInt());
|
||||
}
|
||||
if (value is String) {
|
||||
return DateTime.tryParse(value);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
return MediaItem(
|
||||
itemId: json['item_id']?.toString() ?? json['id']?.toString() ?? '',
|
||||
provider: json['provider'] as String? ?? 'unknown',
|
||||
@@ -99,6 +122,10 @@ class MediaItem {
|
||||
duration: json['duration'] != null
|
||||
? Duration(seconds: (json['duration'] as num).toInt())
|
||||
: null,
|
||||
timestampAdded: parseTimestamp(json['timestamp_added']),
|
||||
timestampModified: parseTimestamp(json['timestamp_modified']),
|
||||
lastPlayed: parseTimestamp(json['last_played']),
|
||||
playCount: json['play_count'] as int?,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -116,6 +143,10 @@ class MediaItem {
|
||||
if (favorite != null) 'favorite': favorite,
|
||||
if (position != null) 'position': position,
|
||||
if (duration != null) 'duration': duration!.inSeconds,
|
||||
if (timestampAdded != null) 'timestamp_added': timestampAdded!.millisecondsSinceEpoch ~/ 1000,
|
||||
if (timestampModified != null) 'timestamp_modified': timestampModified!.millisecondsSinceEpoch ~/ 1000,
|
||||
if (lastPlayed != null) 'last_played': lastPlayed!.millisecondsSinceEpoch ~/ 1000,
|
||||
if (playCount != null) 'play_count': playCount,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -130,6 +161,10 @@ class Artist extends MediaItem {
|
||||
super.providerMappings,
|
||||
super.metadata,
|
||||
super.favorite,
|
||||
super.timestampAdded,
|
||||
super.timestampModified,
|
||||
super.lastPlayed,
|
||||
super.playCount,
|
||||
}) : super(mediaType: MediaType.artist);
|
||||
|
||||
factory Artist.fromJson(Map<String, dynamic> json) {
|
||||
@@ -143,6 +178,10 @@ class Artist extends MediaItem {
|
||||
providerMappings: item.providerMappings,
|
||||
metadata: item.metadata,
|
||||
favorite: item.favorite,
|
||||
timestampAdded: item.timestampAdded,
|
||||
timestampModified: item.timestampModified,
|
||||
lastPlayed: item.lastPlayed,
|
||||
playCount: item.playCount,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -167,6 +206,10 @@ class Album extends MediaItem {
|
||||
super.providerMappings,
|
||||
super.metadata,
|
||||
super.favorite,
|
||||
super.timestampAdded,
|
||||
super.timestampModified,
|
||||
super.lastPlayed,
|
||||
super.playCount,
|
||||
}) : super(mediaType: MediaType.album);
|
||||
|
||||
factory Album.fromJson(Map<String, dynamic> json) {
|
||||
@@ -194,6 +237,10 @@ class Album extends MediaItem {
|
||||
providerMappings: item.providerMappings,
|
||||
metadata: item.metadata,
|
||||
favorite: item.favorite,
|
||||
timestampAdded: item.timestampAdded,
|
||||
timestampModified: item.timestampModified,
|
||||
lastPlayed: item.lastPlayed,
|
||||
playCount: item.playCount,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -240,6 +287,10 @@ class Track extends MediaItem {
|
||||
super.favorite,
|
||||
super.position,
|
||||
super.duration,
|
||||
super.timestampAdded,
|
||||
super.timestampModified,
|
||||
super.lastPlayed,
|
||||
super.playCount,
|
||||
}) : super(mediaType: MediaType.track);
|
||||
|
||||
factory Track.fromJson(Map<String, dynamic> json) {
|
||||
@@ -261,6 +312,10 @@ class Track extends MediaItem {
|
||||
favorite: item.favorite,
|
||||
position: item.position,
|
||||
duration: item.duration,
|
||||
timestampAdded: item.timestampAdded,
|
||||
timestampModified: item.timestampModified,
|
||||
lastPlayed: item.lastPlayed,
|
||||
playCount: item.playCount,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -295,6 +350,10 @@ class Playlist extends MediaItem {
|
||||
super.providerMappings,
|
||||
super.metadata,
|
||||
super.favorite,
|
||||
super.timestampAdded,
|
||||
super.timestampModified,
|
||||
super.lastPlayed,
|
||||
super.playCount,
|
||||
}) : super(mediaType: MediaType.playlist);
|
||||
|
||||
factory Playlist.fromJson(Map<String, dynamic> json) {
|
||||
@@ -311,6 +370,10 @@ class Playlist extends MediaItem {
|
||||
providerMappings: item.providerMappings,
|
||||
metadata: item.metadata,
|
||||
favorite: item.favorite,
|
||||
timestampAdded: item.timestampAdded,
|
||||
timestampModified: item.timestampModified,
|
||||
lastPlayed: item.lastPlayed,
|
||||
playCount: item.playCount,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import 'package:audio_service/audio_service.dart' as audio_service;
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter_volume_controller/flutter_volume_controller.dart';
|
||||
import '../models/media_item.dart';
|
||||
import '../models/music_provider.dart';
|
||||
import '../models/player.dart';
|
||||
import '../services/music_assistant_api.dart';
|
||||
import '../services/settings_service.dart';
|
||||
@@ -106,9 +107,9 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
// This is populated with episode covers when podcast covers are low-res
|
||||
final Map<String, String> _podcastCoverCache = {};
|
||||
|
||||
// Provider filter: list of allowed provider instance IDs from MA user settings
|
||||
// Empty list means all providers are allowed
|
||||
List<String> _providerFilter = [];
|
||||
// Provider filter: list of DISABLED provider instance IDs from local app settings
|
||||
// Empty list means all providers are enabled (shown)
|
||||
List<String> _disabledProviders = [];
|
||||
|
||||
// Player filter: list of allowed player IDs from MA user settings
|
||||
// Empty list means all players are allowed
|
||||
@@ -125,8 +126,14 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
_connectionState == MAConnectionState.authenticated;
|
||||
|
||||
// Library getters with provider filtering applied
|
||||
List<Artist> get artists => filterByProvider(_artists);
|
||||
List<Album> get albums => filterByProvider(_albums);
|
||||
List<Artist> get artists {
|
||||
_logger.log('🔍 artists getter: ${_artists.length} items, disabled=$_disabledProviders');
|
||||
return filterByProvider(_artists);
|
||||
}
|
||||
List<Album> get albums {
|
||||
_logger.log('🔍 albums getter: ${_albums.length} items, disabled=$_disabledProviders');
|
||||
return filterByProvider(_albums);
|
||||
}
|
||||
List<Track> get tracks => filterByProvider(_tracks);
|
||||
List<MediaItem> get radioStations => filterByProvider(_radioStations);
|
||||
List<MediaItem> get podcasts => filterByProvider(_podcasts);
|
||||
@@ -138,54 +145,145 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
bool get isLoadingRadio => _isLoadingRadio;
|
||||
bool get isLoadingPodcasts => _isLoadingPodcasts;
|
||||
|
||||
/// Provider filter from MA user settings (empty = all providers allowed)
|
||||
List<String> get providerFilter => _providerFilter;
|
||||
/// Available music providers from MA server
|
||||
List<MusicProvider> get musicProviders => _api?.musicProviders ?? [];
|
||||
|
||||
/// Whether provider filtering is active
|
||||
bool get hasProviderFilter => _providerFilter.isNotEmpty;
|
||||
/// List of disabled provider instance IDs (from local app settings)
|
||||
List<String> get disabledProviders => _disabledProviders;
|
||||
|
||||
/// Whether provider filtering is active (any provider disabled)
|
||||
bool get hasProviderFilter => _disabledProviders.isNotEmpty;
|
||||
|
||||
/// Infrastructure providers that should be ignored for library filtering
|
||||
static const _infrastructureProviders = {'builtin', 'radiobrowser', 'itunes_podcasts', 'audiobookshelf'};
|
||||
|
||||
/// Get the single selected provider for server-side filtering
|
||||
/// Returns the provider instance ID if exactly one "real" music provider is enabled
|
||||
/// Returns null if no filtering needed or multiple providers enabled
|
||||
String? get selectedProviderForFilter {
|
||||
if (_disabledProviders.isEmpty) return null;
|
||||
|
||||
// Get all available music providers from the server
|
||||
final allProviders = musicProviders;
|
||||
if (allProviders.isEmpty) return null;
|
||||
|
||||
// Find enabled "real" music providers (not infrastructure)
|
||||
final enabledRealProviders = allProviders.where((p) {
|
||||
// Skip infrastructure providers
|
||||
if (_infrastructureProviders.contains(p.instanceId) ||
|
||||
_infrastructureProviders.any((infra) => p.instanceId.startsWith(infra))) {
|
||||
return false;
|
||||
}
|
||||
// Check if this provider is enabled (not in disabled list)
|
||||
return !_disabledProviders.contains(p.instanceId);
|
||||
}).toList();
|
||||
|
||||
// If exactly one real provider is enabled, use it for server-side filtering
|
||||
if (enabledRealProviders.length == 1) {
|
||||
final provider = enabledRealProviders.first.instanceId;
|
||||
_logger.log('🔌 Selected provider for filter: $provider');
|
||||
return provider;
|
||||
}
|
||||
|
||||
_logger.log('🔌 No single provider selected: ${enabledRealProviders.length} enabled');
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Check if a media item should be visible based on provider filter
|
||||
/// Returns true if:
|
||||
/// - No filter is active (empty list = all providers allowed)
|
||||
/// - The item has at least one provider mapping in the allowed list
|
||||
/// - The item's primary provider is in the allowed list
|
||||
/// Returns true if item has at least one ENABLED provider
|
||||
/// Hide only if ALL of the item's providers are disabled
|
||||
int _filterDebugCount = 0;
|
||||
bool isItemAllowedByProviderFilter(MediaItem item) {
|
||||
// No filter = show everything
|
||||
if (_providerFilter.isEmpty) return true;
|
||||
// No disabled providers = show everything
|
||||
if (_disabledProviders.isEmpty) return true;
|
||||
|
||||
// Check if item's provider mappings include any allowed provider
|
||||
final mappings = item.providerMappings;
|
||||
if (mappings != null && mappings.isNotEmpty) {
|
||||
for (final mapping in mappings) {
|
||||
if (_providerFilter.contains(mapping.providerInstance)) {
|
||||
return true;
|
||||
// Get real provider mappings (exclude "None" which is MA's internal library)
|
||||
final realMappings = mappings
|
||||
.where((m) => m.providerInstance != 'None' && m.providerInstance.isNotEmpty)
|
||||
.toList();
|
||||
|
||||
// If no real mappings, show the item (only has internal library reference)
|
||||
if (realMappings.isEmpty) {
|
||||
if (_filterDebugCount < 10) {
|
||||
_logger.log('🔍 ALLOWED (no real mappings): "${item.name}"');
|
||||
_filterDebugCount++;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Show if ANY provider is enabled (not in disabled list)
|
||||
final enabledMappings = realMappings.where((m) => !_disabledProviders.contains(m.providerInstance)).toList();
|
||||
final allowed = enabledMappings.isNotEmpty;
|
||||
|
||||
if (_filterDebugCount < 10) {
|
||||
final allProviders = realMappings.map((m) => m.providerInstance).toList();
|
||||
final enabledProviders = enabledMappings.map((m) => m.providerInstance).toList();
|
||||
_logger.log('🔍 ${allowed ? "ALLOWED" : "FILTERED"}: "${item.name}" providers=$allProviders enabled=$enabledProviders');
|
||||
_filterDebugCount++;
|
||||
}
|
||||
return allowed;
|
||||
}
|
||||
|
||||
// Also check primary provider field (for items without full mappings)
|
||||
if (_providerFilter.contains(item.provider)) {
|
||||
return true;
|
||||
// Check primary provider field (for items without full mappings)
|
||||
final allowed = !_disabledProviders.contains(item.provider);
|
||||
if (_filterDebugCount < 10) {
|
||||
_logger.log('🔍 ${allowed ? "ALLOWED" : "FILTERED"} (no mappings): "${item.name}" provider=${item.provider}');
|
||||
_filterDebugCount++;
|
||||
}
|
||||
|
||||
return false;
|
||||
return allowed;
|
||||
}
|
||||
|
||||
/// Filter a list of media items based on provider filter
|
||||
List<T> filterByProvider<T extends MediaItem>(List<T> items) {
|
||||
if (_providerFilter.isEmpty) return items;
|
||||
return items.where(isItemAllowedByProviderFilter).toList();
|
||||
if (_disabledProviders.isEmpty) return items;
|
||||
_filterDebugCount = 0; // Reset for fresh logging
|
||||
final filtered = items.where(isItemAllowedByProviderFilter).toList();
|
||||
_logger.log('🔌 filterByProvider: ${items.length} → ${filtered.length} (disabled=$_disabledProviders)');
|
||||
return filtered;
|
||||
}
|
||||
|
||||
/// Filter search results map based on provider filter
|
||||
Map<String, List<MediaItem>> filterSearchResults(Map<String, List<MediaItem>> results) {
|
||||
if (_providerFilter.isEmpty) return results;
|
||||
if (_disabledProviders.isEmpty) return results;
|
||||
return {
|
||||
for (final entry in results.entries)
|
||||
entry.key: entry.value.where(isItemAllowedByProviderFilter).toList(),
|
||||
};
|
||||
}
|
||||
|
||||
/// Load disabled providers from local settings
|
||||
Future<void> loadDisabledProviders() async {
|
||||
_disabledProviders = await SettingsService.getDisabledMusicProviders();
|
||||
_logger.log('🔌 Loaded disabled providers: $_disabledProviders');
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Toggle a provider's enabled state and persist
|
||||
/// Clears caches and forces a fresh sync with the new filter
|
||||
Future<void> toggleProviderEnabled(String instanceId, bool enabled) async {
|
||||
_logger.log('🔌 Toggle provider $instanceId enabled=$enabled');
|
||||
await SettingsService.toggleProviderEnabled(instanceId, enabled);
|
||||
await loadDisabledProviders();
|
||||
|
||||
// Clear all caches so fresh filtered data is fetched
|
||||
_cacheService.invalidateHomeCache();
|
||||
_cacheService.invalidateSearchCache();
|
||||
await SyncService.instance.clearCache();
|
||||
|
||||
_logger.log('🔌 After toggle, disabled=$_disabledProviders, selectedFilter=${selectedProviderForFilter}');
|
||||
|
||||
// Force a fresh sync with the new provider filter
|
||||
if (_api != null) {
|
||||
_logger.log('🔌 Forcing library re-sync with provider filter...');
|
||||
await SyncService.instance.forceSync(_api!, provider: selectedProviderForFilter);
|
||||
_albums = SyncService.instance.cachedAlbums;
|
||||
_artists = SyncService.instance.cachedArtists;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
/// Player filter from MA user settings (empty = all players allowed)
|
||||
List<String> get playerFilter => _playerFilter;
|
||||
|
||||
@@ -916,22 +1014,11 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
_logger.log('✅ Set owner name from MA profile: $profileName');
|
||||
}
|
||||
|
||||
// Capture provider filter (empty list = all providers allowed)
|
||||
final providerFilterRaw = userInfo['provider_filter'];
|
||||
if (providerFilterRaw is List) {
|
||||
_providerFilter = providerFilterRaw.cast<String>().toList();
|
||||
if (_providerFilter.isNotEmpty) {
|
||||
_logger.log('🔒 Provider filter active: ${_providerFilter.length} providers allowed');
|
||||
_logger.log(' Allowed: ${_providerFilter.join(", ")}');
|
||||
} else {
|
||||
_logger.log('🔓 No provider filter - all providers visible');
|
||||
}
|
||||
} else {
|
||||
_providerFilter = [];
|
||||
_logger.log('🔓 No provider filter in user settings');
|
||||
}
|
||||
// Load provider filter from LOCAL app settings (not MA user profile)
|
||||
// MA user profile provider_filter is ignored as it causes issues with recents
|
||||
await loadDisabledProviders();
|
||||
|
||||
// Capture player filter (empty list = all players allowed)
|
||||
// Capture player filter from MA user settings (empty list = all players allowed)
|
||||
final playerFilterRaw = userInfo['player_filter'];
|
||||
if (playerFilterRaw is List) {
|
||||
_playerFilter = playerFilterRaw.cast<String>().toList();
|
||||
@@ -2720,38 +2807,46 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
Future<List<Artist>> getDiscoverArtistsWithCache({bool forceRefresh = false}) async {
|
||||
if (_cacheService.isDiscoverArtistsCacheValid(forceRefresh: forceRefresh)) {
|
||||
_logger.log('📦 Using cached discover artists');
|
||||
return _cacheService.getCachedDiscoverArtists()!;
|
||||
return filterByProvider(_cacheService.getCachedDiscoverArtists()!);
|
||||
}
|
||||
|
||||
if (_api == null) return _cacheService.getCachedDiscoverArtists() ?? [];
|
||||
if (_api == null) {
|
||||
final cached = _cacheService.getCachedDiscoverArtists();
|
||||
return cached != null ? filterByProvider(cached) : [];
|
||||
}
|
||||
|
||||
try {
|
||||
_logger.log('🔄 Fetching fresh discover artists...');
|
||||
final artists = await _api!.getRandomArtists(limit: LibraryConstants.defaultRecentLimit);
|
||||
_cacheService.setCachedDiscoverArtists(artists);
|
||||
return artists;
|
||||
return filterByProvider(artists);
|
||||
} catch (e) {
|
||||
_logger.log('❌ Failed to fetch discover artists: $e');
|
||||
return _cacheService.getCachedDiscoverArtists() ?? [];
|
||||
final cached = _cacheService.getCachedDiscoverArtists();
|
||||
return cached != null ? filterByProvider(cached) : [];
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<Album>> getDiscoverAlbumsWithCache({bool forceRefresh = false}) async {
|
||||
if (_cacheService.isDiscoverAlbumsCacheValid(forceRefresh: forceRefresh)) {
|
||||
_logger.log('📦 Using cached discover albums');
|
||||
return _cacheService.getCachedDiscoverAlbums()!;
|
||||
return filterByProvider(_cacheService.getCachedDiscoverAlbums()!);
|
||||
}
|
||||
|
||||
if (_api == null) return _cacheService.getCachedDiscoverAlbums() ?? [];
|
||||
if (_api == null) {
|
||||
final cached = _cacheService.getCachedDiscoverAlbums();
|
||||
return cached != null ? filterByProvider(cached) : [];
|
||||
}
|
||||
|
||||
try {
|
||||
_logger.log('🔄 Fetching fresh discover albums...');
|
||||
final albums = await _api!.getRandomAlbums(limit: LibraryConstants.defaultRecentLimit);
|
||||
_cacheService.setCachedDiscoverAlbums(albums);
|
||||
return albums;
|
||||
return filterByProvider(albums);
|
||||
} catch (e) {
|
||||
_logger.log('❌ Failed to fetch discover albums: $e');
|
||||
return _cacheService.getCachedDiscoverAlbums() ?? [];
|
||||
final cached = _cacheService.getCachedDiscoverAlbums();
|
||||
return cached != null ? filterByProvider(cached) : [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2759,21 +2854,31 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
_cacheService.invalidateHomeCache();
|
||||
}
|
||||
|
||||
/// Get cached recent albums synchronously (for instant display)
|
||||
List<Album>? getCachedRecentAlbums() => _cacheService.getCachedRecentAlbums();
|
||||
/// Get cached recent albums synchronously (for instant display, with filtering)
|
||||
List<Album>? getCachedRecentAlbums() {
|
||||
final cached = _cacheService.getCachedRecentAlbums();
|
||||
return cached != null ? filterByProvider(cached) : null;
|
||||
}
|
||||
|
||||
/// Get cached discover artists synchronously (for instant display)
|
||||
List<Artist>? getCachedDiscoverArtists() => _cacheService.getCachedDiscoverArtists();
|
||||
/// Get cached discover artists synchronously (for instant display, with filtering)
|
||||
List<Artist>? getCachedDiscoverArtists() {
|
||||
final cached = _cacheService.getCachedDiscoverArtists();
|
||||
return cached != null ? filterByProvider(cached) : null;
|
||||
}
|
||||
|
||||
/// Get cached discover albums synchronously (for instant display)
|
||||
List<Album>? getCachedDiscoverAlbums() => _cacheService.getCachedDiscoverAlbums();
|
||||
/// Get cached discover albums synchronously (for instant display, with filtering)
|
||||
List<Album>? getCachedDiscoverAlbums() {
|
||||
final cached = _cacheService.getCachedDiscoverAlbums();
|
||||
return cached != null ? filterByProvider(cached) : null;
|
||||
}
|
||||
|
||||
/// Force a full library sync (for pull-to-refresh)
|
||||
Future<void> forceLibrarySync() async {
|
||||
if (_api == null) return;
|
||||
|
||||
_logger.log('🔄 Forcing full library sync...');
|
||||
await SyncService.instance.forceSync(_api!);
|
||||
final providerFilter = selectedProviderForFilter;
|
||||
_logger.log('🔄 Forcing full library sync (provider=$providerFilter)...');
|
||||
await SyncService.instance.forceSync(_api!, provider: providerFilter);
|
||||
|
||||
// Update local lists from sync result
|
||||
_albums = SyncService.instance.cachedAlbums;
|
||||
@@ -2794,32 +2899,36 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
// FAVORITES FOR HOME SCREEN
|
||||
// ============================================================================
|
||||
|
||||
/// Get favorite albums from the library
|
||||
/// Get favorite albums from the library (with provider filtering)
|
||||
Future<List<Album>> getFavoriteAlbums() async {
|
||||
// Filter from loaded library - favorites are already loaded
|
||||
return _albums.where((a) => a.favorite == true).toList();
|
||||
// Use filtered albums to respect provider settings
|
||||
return albums.where((a) => a.favorite == true).toList();
|
||||
}
|
||||
|
||||
/// Get favorite artists from the library
|
||||
/// Get favorite artists from the library (with provider filtering)
|
||||
Future<List<Artist>> getFavoriteArtists() async {
|
||||
// Filter from loaded library - favorites are already loaded
|
||||
return _artists.where((a) => a.favorite == true).toList();
|
||||
// Use filtered artists to respect provider settings
|
||||
return artists.where((a) => a.favorite == true).toList();
|
||||
}
|
||||
|
||||
/// Get favorite tracks from the library
|
||||
/// Get favorite tracks from the library (with provider filtering)
|
||||
Future<List<Track>> getFavoriteTracks() async {
|
||||
// If full tracks list is loaded, filter from it
|
||||
// Use filtered tracks to respect provider settings
|
||||
if (_tracks.isNotEmpty) {
|
||||
final favTracks = _tracks.where((t) => t.favorite == true).toList();
|
||||
// Update cache if we have new data
|
||||
if (favTracks.isNotEmpty && favTracks.length != _cachedFavoriteTracks.length) {
|
||||
_cachedFavoriteTracks = favTracks;
|
||||
_persistFavoriteTracks(favTracks);
|
||||
final favTracks = tracks.where((t) => t.favorite == true).toList();
|
||||
// Update cache if we have new data (using unfiltered for persistence)
|
||||
final unfilteredFavTracks = _tracks.where((t) => t.favorite == true).toList();
|
||||
if (unfilteredFavTracks.isNotEmpty && unfilteredFavTracks.length != _cachedFavoriteTracks.length) {
|
||||
_cachedFavoriteTracks = unfilteredFavTracks;
|
||||
_persistFavoriteTracks(unfilteredFavTracks);
|
||||
}
|
||||
return favTracks;
|
||||
}
|
||||
// Otherwise return cached favorites (for instant display before full library loads)
|
||||
return _cachedFavoriteTracks;
|
||||
return filterByProvider(_cachedFavoriteTracks);
|
||||
}
|
||||
|
||||
/// Get favorite playlists from the library
|
||||
@@ -2866,13 +2975,13 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
// Check cache first
|
||||
if (!forceRefresh && _cacheService.isInProgressAudiobooksCacheValid()) {
|
||||
_logger.log('📦 Using cached in-progress audiobooks');
|
||||
return _cacheService.getCachedInProgressAudiobooks()!;
|
||||
return filterByProvider(_cacheService.getCachedInProgressAudiobooks()!);
|
||||
}
|
||||
|
||||
if (_api == null) {
|
||||
// Fallback to cache when offline
|
||||
final cached = _cacheService.getCachedInProgressAudiobooks();
|
||||
if (cached != null && cached.isNotEmpty) return cached;
|
||||
if (cached != null && cached.isNotEmpty) return filterByProvider(cached);
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -2892,26 +3001,29 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
_logger.log('❌ Failed to fetch in-progress audiobooks: $e');
|
||||
// Fallback to cache on error
|
||||
final cached = _cacheService.getCachedInProgressAudiobooks();
|
||||
if (cached != null && cached.isNotEmpty) return cached;
|
||||
if (cached != null && cached.isNotEmpty) return filterByProvider(cached);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/// Get cached in-progress audiobooks synchronously (for instant display)
|
||||
List<Audiobook>? getCachedInProgressAudiobooks() => _cacheService.getCachedInProgressAudiobooks();
|
||||
/// Get cached in-progress audiobooks synchronously (for instant display, with filtering)
|
||||
List<Audiobook>? getCachedInProgressAudiobooks() {
|
||||
final cached = _cacheService.getCachedInProgressAudiobooks();
|
||||
return cached != null ? filterByProvider(cached) : null;
|
||||
}
|
||||
|
||||
/// Get random audiobooks for discovery with caching
|
||||
Future<List<Audiobook>> getDiscoverAudiobooksWithCache({bool forceRefresh = false}) async {
|
||||
// Check cache first
|
||||
if (!forceRefresh && _cacheService.isDiscoverAudiobooksCacheValid()) {
|
||||
_logger.log('📦 Using cached discover audiobooks');
|
||||
return _cacheService.getCachedDiscoverAudiobooks()!;
|
||||
return filterByProvider(_cacheService.getCachedDiscoverAudiobooks()!);
|
||||
}
|
||||
|
||||
if (_api == null) {
|
||||
// Fallback to cache when offline
|
||||
final cached = _cacheService.getCachedDiscoverAudiobooks();
|
||||
if (cached != null && cached.isNotEmpty) return cached;
|
||||
if (cached != null && cached.isNotEmpty) return filterByProvider(cached);
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -2928,13 +3040,16 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
_logger.log('❌ Failed to fetch discover audiobooks: $e');
|
||||
// Fallback to cache on error
|
||||
final cached = _cacheService.getCachedDiscoverAudiobooks();
|
||||
if (cached != null && cached.isNotEmpty) return cached;
|
||||
if (cached != null && cached.isNotEmpty) return filterByProvider(cached);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/// Get cached discover audiobooks synchronously (for instant display)
|
||||
List<Audiobook>? getCachedDiscoverAudiobooks() => _cacheService.getCachedDiscoverAudiobooks();
|
||||
/// Get cached discover audiobooks synchronously (for instant display, with filtering)
|
||||
List<Audiobook>? getCachedDiscoverAudiobooks() {
|
||||
final cached = _cacheService.getCachedDiscoverAudiobooks();
|
||||
return cached != null ? filterByProvider(cached) : null;
|
||||
}
|
||||
|
||||
/// Get random series for discovery with caching
|
||||
Future<List<AudiobookSeries>> getDiscoverSeriesWithCache({bool forceRefresh = false}) async {
|
||||
@@ -4402,10 +4517,15 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
}
|
||||
|
||||
// Fetch tracks from API (not cached - too many items)
|
||||
// Apply provider filter if a single provider is selected
|
||||
if (_api != null) {
|
||||
try {
|
||||
_tracks = await _api!.getTracks(limit: LibraryConstants.maxLibraryItems);
|
||||
_logger.log('📥 Fetched ${_tracks.length} tracks from MA');
|
||||
final providerFilter = selectedProviderForFilter;
|
||||
_tracks = await _api!.getTracks(
|
||||
limit: LibraryConstants.maxLibraryItems,
|
||||
provider: providerFilter,
|
||||
);
|
||||
_logger.log('📥 Fetched ${_tracks.length} tracks from MA (provider=$providerFilter)');
|
||||
} catch (e) {
|
||||
_logger.log('⚠️ Failed to fetch tracks: $e');
|
||||
}
|
||||
@@ -4431,6 +4551,8 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
if (_api == null) return;
|
||||
|
||||
final syncService = SyncService.instance;
|
||||
final providerFilter = selectedProviderForFilter;
|
||||
_logger.log('🔄 _syncLibraryInBackground starting with providerFilter=$providerFilter');
|
||||
|
||||
// Listen for sync completion to update our lists
|
||||
void onSyncComplete() {
|
||||
@@ -4444,18 +4566,18 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
}
|
||||
|
||||
syncService.addListener(onSyncComplete);
|
||||
await syncService.syncFromApi(_api!);
|
||||
await syncService.syncFromApi(_api!, provider: providerFilter);
|
||||
}
|
||||
|
||||
/// Load radio stations from the library
|
||||
Future<void> loadRadioStations() async {
|
||||
Future<void> loadRadioStations({String? orderBy}) async {
|
||||
if (!isConnected || _api == null) return;
|
||||
|
||||
try {
|
||||
_isLoadingRadio = true;
|
||||
notifyListeners();
|
||||
|
||||
_radioStations = await _api!.getRadioStations(limit: 100);
|
||||
_radioStations = await _api!.getRadioStations(limit: 100, orderBy: orderBy);
|
||||
_isLoadingRadio = false;
|
||||
notifyListeners();
|
||||
} catch (e) {
|
||||
@@ -4465,14 +4587,14 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> loadPodcasts() async {
|
||||
Future<void> loadPodcasts({String? orderBy}) async {
|
||||
if (!isConnected || _api == null) return;
|
||||
|
||||
try {
|
||||
_isLoadingPodcasts = true;
|
||||
notifyListeners();
|
||||
|
||||
_podcasts = await _api!.getPodcasts(limit: 100);
|
||||
_podcasts = await _api!.getPodcasts(limit: 100, orderBy: orderBy);
|
||||
|
||||
_logger.log('🎙️ Loaded ${_podcasts.length} podcasts');
|
||||
_isLoadingPodcasts = false;
|
||||
@@ -4515,7 +4637,7 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> loadArtists({int? limit, int? offset, String? search}) async {
|
||||
Future<void> loadArtists({int? limit, int? offset, String? search, String? orderBy}) async {
|
||||
if (!isConnected) return;
|
||||
|
||||
try {
|
||||
@@ -4528,6 +4650,7 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
offset: offset,
|
||||
search: search,
|
||||
albumArtistsOnly: false, // Show ALL library artists, not just those with albums
|
||||
orderBy: orderBy,
|
||||
);
|
||||
|
||||
_isLoading = false;
|
||||
@@ -4545,6 +4668,7 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
int? offset,
|
||||
String? search,
|
||||
String? artistId,
|
||||
String? orderBy,
|
||||
}) async {
|
||||
if (!isConnected) return;
|
||||
|
||||
@@ -4558,6 +4682,7 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
offset: offset,
|
||||
search: search,
|
||||
artistId: artistId,
|
||||
orderBy: orderBy,
|
||||
);
|
||||
|
||||
_isLoading = false;
|
||||
@@ -4596,10 +4721,10 @@ class MusicAssistantProvider with ChangeNotifier {
|
||||
}
|
||||
|
||||
/// Get playlists with provider filtering applied
|
||||
Future<List<Playlist>> getPlaylists({int? limit, bool? favoriteOnly}) async {
|
||||
Future<List<Playlist>> getPlaylists({int? limit, bool? favoriteOnly, String? orderBy}) async {
|
||||
if (_api == null) return [];
|
||||
try {
|
||||
final playlists = await _api!.getPlaylists(limit: limit, favoriteOnly: favoriteOnly);
|
||||
final playlists = await _api!.getPlaylists(limit: limit, favoriteOnly: favoriteOnly, orderBy: orderBy);
|
||||
return filterByProvider(playlists);
|
||||
} catch (e) {
|
||||
_logger.log('❌ Failed to fetch playlists: $e');
|
||||
|
||||
+688
-357
File diff suppressed because it is too large
Load Diff
@@ -145,6 +145,9 @@ class SearchScreenState extends State<SearchScreen> {
|
||||
double _highlightLeft = 0;
|
||||
double _highlightWidth = 80; // Default width until measured
|
||||
|
||||
// Track provider filter to detect changes
|
||||
List<String>? _lastDisabledProviders;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
@@ -153,18 +156,48 @@ class SearchScreenState extends State<SearchScreen> {
|
||||
_loadRecentSearches();
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
// Check if provider filter changed and clear caches if so
|
||||
final provider = context.read<MusicAssistantProvider>();
|
||||
final currentDisabled = provider.disabledProviders;
|
||||
if (_lastDisabledProviders != null &&
|
||||
!_listEquals(_lastDisabledProviders!, currentDisabled)) {
|
||||
_cachedAvailableFilters = null;
|
||||
_cachedListItems.clear();
|
||||
}
|
||||
_lastDisabledProviders = List.from(currentDisabled);
|
||||
}
|
||||
|
||||
bool _listEquals(List<String> a, List<String> b) {
|
||||
if (a.length != b.length) return false;
|
||||
for (int i = 0; i < a.length; i++) {
|
||||
if (a[i] != b[i]) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Get filtered search results based on provider filter settings
|
||||
Map<String, List<MediaItem>> _getFilteredResults(MusicAssistantProvider provider) {
|
||||
return provider.filterSearchResults(_searchResults);
|
||||
}
|
||||
|
||||
/// Get list of available filters based on current results (cached)
|
||||
/// Uses provider filter to exclude disabled providers from results
|
||||
List<String> _getAvailableFilters() {
|
||||
if (_cachedAvailableFilters != null) return _cachedAvailableFilters!;
|
||||
|
||||
final provider = context.read<MusicAssistantProvider>();
|
||||
final filtered = _getFilteredResults(provider);
|
||||
final filters = <String>['all'];
|
||||
if (_searchResults['artists']?.isNotEmpty == true) filters.add('artists');
|
||||
if (_searchResults['albums']?.isNotEmpty == true) filters.add('albums');
|
||||
if (_searchResults['tracks']?.isNotEmpty == true) filters.add('tracks');
|
||||
if (_searchResults['playlists']?.isNotEmpty == true) filters.add('playlists');
|
||||
if (_searchResults['audiobooks']?.isNotEmpty == true) filters.add('audiobooks');
|
||||
if (_searchResults['radios']?.isNotEmpty == true) filters.add('radios');
|
||||
if (_searchResults['podcasts']?.isNotEmpty == true) filters.add('podcasts');
|
||||
if (filtered['artists']?.isNotEmpty == true) filters.add('artists');
|
||||
if (filtered['albums']?.isNotEmpty == true) filters.add('albums');
|
||||
if (filtered['tracks']?.isNotEmpty == true) filters.add('tracks');
|
||||
if (filtered['playlists']?.isNotEmpty == true) filters.add('playlists');
|
||||
if (filtered['audiobooks']?.isNotEmpty == true) filters.add('audiobooks');
|
||||
if (filtered['radios']?.isNotEmpty == true) filters.add('radios');
|
||||
if (filtered['podcasts']?.isNotEmpty == true) filters.add('podcasts');
|
||||
_cachedAvailableFilters = filters;
|
||||
return filters;
|
||||
}
|
||||
@@ -691,13 +724,16 @@ class SearchScreenState extends State<SearchScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
final artists = _searchResults['artists'] as List<MediaItem>? ?? [];
|
||||
final albums = _searchResults['albums'] as List<MediaItem>? ?? [];
|
||||
final tracks = _searchResults['tracks'] as List<MediaItem>? ?? [];
|
||||
final playlists = _searchResults['playlists'] as List<MediaItem>? ?? [];
|
||||
final audiobooks = _searchResults['audiobooks'] as List<MediaItem>? ?? [];
|
||||
final radios = _searchResults['radios'] as List<MediaItem>? ?? [];
|
||||
final podcasts = _searchResults['podcasts'] as List<MediaItem>? ?? [];
|
||||
// Apply provider filter to search results
|
||||
final provider = context.read<MusicAssistantProvider>();
|
||||
final filtered = _getFilteredResults(provider);
|
||||
final artists = filtered['artists'] as List<MediaItem>? ?? [];
|
||||
final albums = filtered['albums'] as List<MediaItem>? ?? [];
|
||||
final tracks = filtered['tracks'] as List<MediaItem>? ?? [];
|
||||
final playlists = filtered['playlists'] as List<MediaItem>? ?? [];
|
||||
final audiobooks = filtered['audiobooks'] as List<MediaItem>? ?? [];
|
||||
final radios = filtered['radios'] as List<MediaItem>? ?? [];
|
||||
final podcasts = filtered['podcasts'] as List<MediaItem>? ?? [];
|
||||
|
||||
final hasResults = artists.isNotEmpty || albums.isNotEmpty || tracks.isNotEmpty ||
|
||||
playlists.isNotEmpty || audiobooks.isNotEmpty || radios.isNotEmpty ||
|
||||
|
||||
@@ -49,6 +49,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
bool _showHints = true;
|
||||
// Library settings
|
||||
bool _showOnlyArtistsWithAlbums = false;
|
||||
// Music provider filter settings
|
||||
List<String> _disabledProviders = [];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -107,6 +109,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
// Load library settings
|
||||
final showOnlyArtistsWithAlbums = await SettingsService.getShowOnlyArtistsWithAlbums();
|
||||
|
||||
// Load music provider filter settings
|
||||
final disabledProviders = await SettingsService.getDisabledMusicProviders();
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_showRecentAlbums = showRecent;
|
||||
@@ -129,6 +134,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
_volumePrecisionMode = volumePrecision;
|
||||
_showHints = showHints;
|
||||
_showOnlyArtistsWithAlbums = showOnlyArtistsWithAlbums;
|
||||
_disabledProviders = disabledProviders;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -705,6 +711,66 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
|
||||
const SizedBox(height: 32),
|
||||
|
||||
// Music Providers section
|
||||
if (provider.musicProviders.isNotEmpty) ...[
|
||||
Text(
|
||||
'Music Providers',
|
||||
style: textTheme.titleMedium?.copyWith(
|
||||
color: colorScheme.onBackground,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Filter content shown in Home, Library, and Search',
|
||||
style: textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onBackground.withOpacity(0.6),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceVariant.withOpacity(0.3),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Column(
|
||||
children: provider.musicProviders.map((musicProvider) {
|
||||
final isEnabled = !_disabledProviders.contains(musicProvider.instanceId);
|
||||
return SwitchListTile(
|
||||
title: Text(
|
||||
musicProvider.name,
|
||||
style: TextStyle(color: colorScheme.onSurface),
|
||||
),
|
||||
subtitle: musicProvider.isStreamingProvider
|
||||
? Text(
|
||||
'Streaming',
|
||||
style: TextStyle(
|
||||
color: colorScheme.onSurface.withOpacity(0.6),
|
||||
fontSize: 12,
|
||||
),
|
||||
)
|
||||
: null,
|
||||
value: isEnabled,
|
||||
onChanged: (value) async {
|
||||
await provider.toggleProviderEnabled(musicProvider.instanceId, value);
|
||||
setState(() {
|
||||
if (value) {
|
||||
_disabledProviders.remove(musicProvider.instanceId);
|
||||
} else {
|
||||
_disabledProviders.add(musicProvider.instanceId);
|
||||
}
|
||||
});
|
||||
},
|
||||
activeColor: colorScheme.primary,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
],
|
||||
|
||||
// Player section
|
||||
Text(
|
||||
S.of(context)!.players,
|
||||
|
||||
@@ -7,6 +7,7 @@ import 'package:uuid/uuid.dart';
|
||||
import '../constants/network.dart';
|
||||
import '../constants/timings.dart' show Timings, LibraryConstants;
|
||||
import '../models/media_item.dart';
|
||||
import '../models/music_provider.dart';
|
||||
import '../models/player.dart';
|
||||
import 'debug_logger.dart';
|
||||
import 'settings_service.dart';
|
||||
@@ -52,6 +53,10 @@ class MusicAssistantAPI {
|
||||
bool _authRequired = false;
|
||||
int? _schemaVersion;
|
||||
|
||||
// Music providers (type == "music")
|
||||
List<MusicProvider> _musicProviders = [];
|
||||
List<MusicProvider> get musicProviders => _musicProviders;
|
||||
|
||||
// Auth state
|
||||
bool _isAuthenticated = false;
|
||||
bool get isAuthenticated => _isAuthenticated;
|
||||
@@ -376,23 +381,30 @@ class MusicAssistantAPI {
|
||||
String? search,
|
||||
bool? favoriteOnly,
|
||||
bool albumArtistsOnly = true,
|
||||
String? orderBy,
|
||||
String? provider, // Filter to specific provider instance
|
||||
}) async {
|
||||
try {
|
||||
final args = {
|
||||
if (limit != null) 'limit': limit,
|
||||
if (offset != null) 'offset': offset,
|
||||
if (search != null) 'search': search,
|
||||
if (favoriteOnly != null) 'favorite': favoriteOnly,
|
||||
'album_artists_only': albumArtistsOnly,
|
||||
if (orderBy != null) 'order_by': orderBy,
|
||||
if (provider != null) 'provider': provider,
|
||||
};
|
||||
_logger.log('🔍 API getArtists args: $args');
|
||||
final response = await _sendCommand(
|
||||
'music/artists/library_items',
|
||||
args: {
|
||||
if (limit != null) 'limit': limit,
|
||||
if (offset != null) 'offset': offset,
|
||||
if (search != null) 'search': search,
|
||||
if (favoriteOnly != null) 'favorite': favoriteOnly,
|
||||
'album_artists_only': albumArtistsOnly,
|
||||
},
|
||||
args: args,
|
||||
);
|
||||
|
||||
final items = response['result'] as List<dynamic>?;
|
||||
if (items == null) {
|
||||
return [];
|
||||
}
|
||||
_logger.log('🔍 API getArtists returned ${items.length} items');
|
||||
return items
|
||||
.map((item) => Artist.fromJson(item as Map<String, dynamic>))
|
||||
.toList();
|
||||
@@ -402,7 +414,7 @@ class MusicAssistantAPI {
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<Artist>> getRandomArtists({int limit = 10, bool albumArtistsOnly = true}) async {
|
||||
Future<List<Artist>> getRandomArtists({int limit = 10, bool albumArtistsOnly = true, String? provider}) async {
|
||||
try {
|
||||
final response = await _sendCommand(
|
||||
'music/artists/library_items',
|
||||
@@ -410,6 +422,7 @@ class MusicAssistantAPI {
|
||||
'limit': limit,
|
||||
'order_by': 'random',
|
||||
'album_artists_only': albumArtistsOnly,
|
||||
if (provider != null) 'provider': provider,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -431,6 +444,8 @@ class MusicAssistantAPI {
|
||||
String? search,
|
||||
bool? favoriteOnly,
|
||||
String? artistId,
|
||||
String? orderBy,
|
||||
String? provider, // Filter to specific provider instance
|
||||
}) async {
|
||||
try {
|
||||
final args = <String, dynamic>{
|
||||
@@ -439,7 +454,10 @@ class MusicAssistantAPI {
|
||||
if (search != null) 'search': search,
|
||||
if (favoriteOnly != null) 'favorite': favoriteOnly,
|
||||
if (artistId != null) 'artist_id': artistId,
|
||||
if (orderBy != null) 'order_by': orderBy,
|
||||
if (provider != null) 'provider': provider,
|
||||
};
|
||||
_logger.log('🔍 API getAlbums args: $args');
|
||||
|
||||
final response = await _sendCommand(
|
||||
'music/albums/library_items',
|
||||
@@ -448,6 +466,7 @@ class MusicAssistantAPI {
|
||||
|
||||
final items = response['result'] as List<dynamic>?;
|
||||
if (items == null) return [];
|
||||
_logger.log('🔍 API getAlbums returned ${items.length} items');
|
||||
|
||||
return items
|
||||
.map((item) => Album.fromJson(item as Map<String, dynamic>))
|
||||
@@ -465,6 +484,8 @@ class MusicAssistantAPI {
|
||||
bool? favoriteOnly,
|
||||
String? artistId,
|
||||
String? albumId,
|
||||
String? orderBy,
|
||||
String? provider, // Filter to specific provider instance
|
||||
}) async {
|
||||
try {
|
||||
final response = await _sendCommand(
|
||||
@@ -473,9 +494,11 @@ class MusicAssistantAPI {
|
||||
if (limit != null) 'limit': limit,
|
||||
if (offset != null) 'offset': offset,
|
||||
if (search != null) 'search': search,
|
||||
if (provider != null) 'provider': provider,
|
||||
if (favoriteOnly != null) 'favorite': favoriteOnly,
|
||||
if (artistId != null) 'artist': artistId,
|
||||
if (albumId != null) 'album': albumId,
|
||||
if (orderBy != null) 'order_by': orderBy,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -496,6 +519,7 @@ class MusicAssistantAPI {
|
||||
int? limit,
|
||||
int? offset,
|
||||
bool? favoriteOnly,
|
||||
String? orderBy,
|
||||
}) async {
|
||||
try {
|
||||
final response = await _sendCommand(
|
||||
@@ -504,6 +528,7 @@ class MusicAssistantAPI {
|
||||
if (limit != null) 'limit': limit,
|
||||
if (offset != null) 'offset': offset,
|
||||
if (favoriteOnly != null) 'favorite': favoriteOnly,
|
||||
if (orderBy != null) 'order_by': orderBy,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -555,6 +580,7 @@ class MusicAssistantAPI {
|
||||
int? limit,
|
||||
int? offset,
|
||||
bool? favoriteOnly,
|
||||
String? orderBy,
|
||||
}) async {
|
||||
try {
|
||||
final response = await _sendCommand(
|
||||
@@ -563,6 +589,7 @@ class MusicAssistantAPI {
|
||||
if (limit != null) 'limit': limit,
|
||||
if (offset != null) 'offset': offset,
|
||||
if (favoriteOnly != null) 'favorite': favoriteOnly,
|
||||
if (orderBy != null) 'order_by': orderBy,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1338,13 +1365,14 @@ class MusicAssistantAPI {
|
||||
}
|
||||
|
||||
/// Get random albums
|
||||
Future<List<Album>> getRandomAlbums({int limit = 10}) async {
|
||||
Future<List<Album>> getRandomAlbums({int limit = 10, String? provider}) async {
|
||||
try {
|
||||
final response = await _sendCommand(
|
||||
'music/albums/library_items',
|
||||
args: {
|
||||
'limit': limit,
|
||||
'order_by': 'random',
|
||||
if (provider != null) 'provider': provider,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1460,6 +1488,7 @@ class MusicAssistantAPI {
|
||||
int? offset,
|
||||
String? search,
|
||||
bool? favoriteOnly,
|
||||
String? orderBy,
|
||||
}) async {
|
||||
try {
|
||||
final response = await _sendCommand(
|
||||
@@ -1469,6 +1498,7 @@ class MusicAssistantAPI {
|
||||
if (offset != null) 'offset': offset,
|
||||
if (search != null) 'search': search,
|
||||
if (favoriteOnly != null) 'favorite': favoriteOnly,
|
||||
if (orderBy != null) 'order_by': orderBy,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1490,7 +1520,7 @@ class MusicAssistantAPI {
|
||||
final response = await _sendCommand(
|
||||
'music/playlist',
|
||||
args: {
|
||||
'provider': provider,
|
||||
'provider_instance_id_or_domain': provider,
|
||||
'item_id': itemId,
|
||||
},
|
||||
);
|
||||
@@ -3269,10 +3299,22 @@ class MusicAssistantAPI {
|
||||
_logger.log('⚠️ Could not load provider manifests: $e');
|
||||
}
|
||||
|
||||
// Fetch provider instances (active providers)
|
||||
// Fetch provider instances (active providers) and extract music providers
|
||||
try {
|
||||
await _sendCommand('providers');
|
||||
_logger.log('✓ Provider instances loaded');
|
||||
final response = await _sendCommand('providers');
|
||||
final providers = response['result'] as List<dynamic>?;
|
||||
if (providers != null) {
|
||||
_musicProviders = providers
|
||||
.where((p) => (p as Map<String, dynamic>)['type'] == 'music')
|
||||
.map((p) => MusicProvider.fromJson(p as Map<String, dynamic>))
|
||||
.where((p) => p.available) // Only include available providers
|
||||
.where((p) => p.domain != 'music_assistant') // Exclude MA itself
|
||||
.toList();
|
||||
_logger.log('✓ Found ${_musicProviders.length} music providers:');
|
||||
for (final p in _musicProviders) {
|
||||
_logger.log(' - ${p.name}: instanceId="${p.instanceId}", domain="${p.domain}"');
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.log('⚠️ Could not load provider instances: $e');
|
||||
}
|
||||
|
||||
@@ -172,6 +172,9 @@ class SettingsService {
|
||||
// Podcast Cover Cache (iTunes URLs for high-res artwork)
|
||||
static const String _keyPodcastCoverCache = 'podcast_cover_cache';
|
||||
|
||||
// Music provider filter
|
||||
static const String _keyDisabledMusicProviders = 'disabled_music_providers'; // JSON list of disabled provider instance IDs
|
||||
|
||||
static Future<String?> getServerUrl() async {
|
||||
final prefs = await _getPrefs();
|
||||
return prefs.getString(_keyServerUrl);
|
||||
@@ -871,4 +874,48 @@ class SettingsService {
|
||||
cache[podcastId] = url;
|
||||
await setPodcastCoverCache(cache);
|
||||
}
|
||||
|
||||
// ============ MUSIC PROVIDER FILTER ============
|
||||
|
||||
/// Get list of disabled music provider instance IDs
|
||||
/// Returns empty list if none disabled (all providers enabled)
|
||||
static Future<List<String>> getDisabledMusicProviders() async {
|
||||
final prefs = await _getPrefs();
|
||||
final json = prefs.getString(_keyDisabledMusicProviders);
|
||||
if (json == null || json.isEmpty) return [];
|
||||
try {
|
||||
return (jsonDecode(json) as List<dynamic>).cast<String>();
|
||||
} catch (_) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/// Set list of disabled music provider instance IDs
|
||||
static Future<void> setDisabledMusicProviders(List<String> disabledIds) async {
|
||||
final prefs = await _getPrefs();
|
||||
if (disabledIds.isEmpty) {
|
||||
await prefs.remove(_keyDisabledMusicProviders);
|
||||
} else {
|
||||
await prefs.setString(_keyDisabledMusicProviders, jsonEncode(disabledIds));
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if a specific provider is disabled
|
||||
static Future<bool> isProviderDisabled(String instanceId) async {
|
||||
final disabled = await getDisabledMusicProviders();
|
||||
return disabled.contains(instanceId);
|
||||
}
|
||||
|
||||
/// Toggle a provider's enabled/disabled state
|
||||
static Future<void> toggleProviderEnabled(String instanceId, bool enabled) async {
|
||||
final disabled = await getDisabledMusicProviders();
|
||||
if (enabled) {
|
||||
disabled.remove(instanceId);
|
||||
} else {
|
||||
if (!disabled.contains(instanceId)) {
|
||||
disabled.add(instanceId);
|
||||
}
|
||||
}
|
||||
await setDisabledMusicProviders(disabled);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,7 +119,8 @@ class SyncService with ChangeNotifier {
|
||||
|
||||
/// Sync library data from MA API in background
|
||||
/// Updates database cache and notifies listeners when complete
|
||||
Future<void> syncFromApi(MusicAssistantAPI api, {bool force = false}) async {
|
||||
/// If provider is specified, only fetch items from that provider (server-side filtering)
|
||||
Future<void> syncFromApi(MusicAssistantAPI api, {bool force = false, String? provider}) async {
|
||||
if (_isSyncing) {
|
||||
_logger.log('🔄 Sync already in progress, skipping');
|
||||
return;
|
||||
@@ -149,16 +150,17 @@ class SyncService with ChangeNotifier {
|
||||
notifyListeners();
|
||||
|
||||
try {
|
||||
_logger.log('🔄 Starting background library sync...');
|
||||
_logger.log('🔄 Starting background library sync (provider=$provider)...');
|
||||
|
||||
// Read artist filter setting - when ON, only fetch artists that have albums
|
||||
final showOnlyArtistsWithAlbums = await SettingsService.getShowOnlyArtistsWithAlbums();
|
||||
_logger.log('🎨 Sync using albumArtistsOnly: $showOnlyArtistsWithAlbums');
|
||||
_logger.log('🎨 Sync using albumArtistsOnly: $showOnlyArtistsWithAlbums, provider: $provider');
|
||||
|
||||
// Fetch fresh data from MA API (in parallel for speed)
|
||||
// If provider is specified, filter to only that provider's library items
|
||||
final results = await Future.wait([
|
||||
api.getAlbums(limit: 1000),
|
||||
api.getArtists(limit: 1000, albumArtistsOnly: showOnlyArtistsWithAlbums),
|
||||
api.getAlbums(limit: 1000, provider: provider),
|
||||
api.getArtists(limit: 1000, albumArtistsOnly: showOnlyArtistsWithAlbums, provider: provider),
|
||||
api.getAudiobooks(limit: 1000),
|
||||
api.getPlaylists(limit: 1000),
|
||||
]);
|
||||
@@ -273,11 +275,12 @@ class SyncService with ChangeNotifier {
|
||||
}
|
||||
|
||||
/// Force a fresh sync (for pull-to-refresh)
|
||||
Future<void> forceSync(MusicAssistantAPI api) async {
|
||||
await syncFromApi(api, force: true);
|
||||
Future<void> forceSync(MusicAssistantAPI api, {String? provider}) async {
|
||||
await syncFromApi(api, force: true, provider: provider);
|
||||
}
|
||||
|
||||
/// Clear all cached data
|
||||
/// Clear all cached data and reset sync state
|
||||
/// This allows a fresh sync to start even if one was in progress
|
||||
Future<void> clearCache() async {
|
||||
if (!_db.isInitialized) return;
|
||||
|
||||
@@ -287,9 +290,10 @@ class SyncService with ChangeNotifier {
|
||||
_cachedAudiobooks = [];
|
||||
_cachedPlaylists = [];
|
||||
_lastSyncTime = null;
|
||||
_isSyncing = false; // Reset so a new sync can start
|
||||
_status = SyncStatus.idle;
|
||||
notifyListeners();
|
||||
_logger.log('🗑️ Library cache cleared');
|
||||
_logger.log('🗑️ Library cache cleared (sync state reset)');
|
||||
}
|
||||
|
||||
/// Get albums (from cache or empty if not loaded)
|
||||
|
||||
Reference in New Issue
Block a user