Files
Ensemble/docs/SESSION_2026_01_17_OPTIMISTIC_UPDATES.md

7.9 KiB

Session 2026-01-17: Optimistic Updates & Podcast Provider Filtering

Overview

This session focused on fixing provider filtering for podcasts, resolving iTunes podcast provider issues, and implementing optimistic updates for library buttons across detail screens.

Issues Addressed

1. Podcast Provider Counts Showing Incorrectly

Problem: All Spotify accounts showed the same podcast count instead of only counting podcasts owned by each account.

Root Cause: The podcast counting logic wasn't checking the inLibrary field on provider mappings.

Solution:

  • Added inLibrary field to ProviderMapping model
  • Updated getProvidersWithPodcasts() to only count mappings where inLibrary == true
  • Updated podcast filtering in library screen to check inLibrary field

Files Modified:

  • lib/models/media_item.dart - Added inLibrary field
  • lib/providers/music_assistant_provider.dart - Updated counting logic
  • lib/screens/new_library_screen.dart - Updated filtering logic

2. iTunes Podcasts Not Appearing in Library

Problem: Podcasts added from iTunes search weren't showing in library and iTunes provider filter showed 0 count.

Investigation:

  • Added itunes_podcasts to provider capabilities and music provider domains
  • Added synthetic provider instance creation for search-only providers
  • Added debug logging to understand podcast loading

Resolution: The issue was with Music Assistant's iTunes podcast provider itself - podcasts added via iTunes weren't being properly stored in the MA library. The user removed the iTunes provider from MA and re-added podcasts via Spotify search as a workaround.

Files Modified:

  • lib/models/provider_instance.dart - Added itunes_podcasts to capabilities

3. Episode Loading Failing for Non-Library Podcasts

Problem: When a podcast was removed from library, the old library ID became invalid and episodes wouldn't load.

Solution: Added fallback logic to try loading episodes via provider mappings when the primary ID fails.

Files Modified:

  • lib/screens/podcast_detail_screen.dart - Added fallback episode loading

4. Search Filtering Rework

Problem: Server-side provider filtering was being applied to search results when it should only apply when the "in library" toggle is enabled.

Solution:

  • Removed filterSearchResults() calls from search methods
  • Added client-side _filterByEnabledProviders() helper
  • Applied filtering only when _libraryOnly toggle is ON

Files Modified:

  • lib/providers/music_assistant_provider.dart - Removed filterSearchResults calls
  • lib/screens/search_screen.dart - Added client-side filtering

5. Library Buttons Not Updating Immediately

Problem: Add/remove library buttons on detail screens waited for API response before updating, causing perceived lag.

Solution: Implemented optimistic updates - update UI immediately, revert on failure.

Files Modified:

  • lib/screens/podcast_detail_screen.dart
  • lib/screens/album_details_screen.dart
  • lib/screens/artist_details_screen.dart

Code Changes

ProviderMapping.inLibrary Field

// lib/models/media_item.dart
class ProviderMapping {
  final String itemId;
  final String providerDomain;
  final String providerInstance;
  final bool available;
  final Map<String, dynamic>? audioFormat;
  final bool inLibrary;  // NEW: indicates ownership

  factory ProviderMapping.fromJson(Map<String, dynamic> json) {
    return ProviderMapping(
      // ... other fields ...
      inLibrary: json['in_library'] == true || json['in_library'] == 1,
    );
  }
}

Podcast Provider Counting

// lib/providers/music_assistant_provider.dart
Map<String, int> getProvidersWithPodcasts() {
  final counts = <String, int>{};
  for (final item in _podcasts) {
    final mappings = item.providerMappings;
    if (mappings != null) {
      for (final mapping in mappings) {
        // Only count if this provider "owns" the item
        if (mapping.inLibrary) {
          final instanceId = mapping.providerInstance;
          if (instanceId.isNotEmpty) {
            counts[instanceId] = (counts[instanceId] ?? 0) + 1;
          }
        }
      }
    }
  }
  return counts;
}

Optimistic Update Pattern

// Example from podcast_detail_screen.dart
Future<void> _toggleLibrary() async {
  final newState = !_isInLibrary;

  // OPTIMISTIC UPDATE: Update UI immediately
  setState(() {
    _isInLibrary = newState;
  });

  if (mounted) {
    ScaffoldMessenger.of(context).showSnackBar(
      SnackBar(content: Text(S.of(context)!.addedToLibrary)),
    );
  }

  // Fire and forget - API call happens in background
  maProvider.addToLibrary(...).catchError((e) {
    // Revert on failure
    if (mounted) {
      setState(() {
        _isInLibrary = !newState;
      });
    }
  });
}

Client-Side Search Filtering

// lib/screens/search_screen.dart
Map<String, List<MediaItem>> _filterByEnabledProviders(
  Map<String, List<MediaItem>> results,
  Set<String> enabledProviders,
) {
  if (enabledProviders.isEmpty) return results;

  bool isItemAllowed(MediaItem item) {
    final mappings = item.providerMappings;
    if (mappings == null || mappings.isEmpty) return true;
    return mappings.any((m) => m.inLibrary && enabledProviders.contains(m.providerInstance));
  }

  return {
    for (final entry in results.entries)
      entry.key: entry.value.where(isItemAllowed).toList(),
  };
}

// Applied when setting results:
if (_libraryOnly) {
  final enabledProviders = provider.enabledProviderIds.toSet();
  combinedResults = _filterByEnabledProviders(combinedResults, enabledProviders);
}

Provider Capabilities Update

// lib/models/provider_instance.dart
static const Map<String, Set<String>> providerCapabilities = {
  'spotify': {'artists', 'albums', 'tracks', 'playlists', 'audiobooks', 'podcasts'},
  'ytmusic': {'artists', 'albums', 'tracks', 'playlists', 'podcasts'},
  'apple_music': {'artists', 'albums', 'tracks', 'playlists', 'podcasts'},
  'plex': {'artists', 'albums', 'tracks', 'playlists', 'audiobooks', 'podcasts'},
  'jellyfin': {'artists', 'albums', 'tracks', 'playlists', 'audiobooks', 'podcasts'},
  'emby': {'artists', 'albums', 'tracks', 'playlists', 'audiobooks', 'podcasts'},
  'audiobookshelf': {'audiobooks', 'podcasts'},
  'itunes_podcasts': {'podcasts'},  // NEW
  'tunein': {'radio', 'podcasts'},
  // ... other providers
};

Summary of All Modified Files

File Changes
lib/models/media_item.dart Added inLibrary field to ProviderMapping
lib/models/provider_instance.dart Added itunes_podcasts, podcasts to capabilities
lib/providers/music_assistant_provider.dart Updated podcast counting, removed filterSearchResults
lib/screens/new_library_screen.dart Updated podcast filtering with inLibrary check
lib/screens/search_screen.dart Added client-side filtering for in-library mode
lib/screens/podcast_detail_screen.dart Optimistic updates, fallback episode loading
lib/screens/album_details_screen.dart Optimistic updates for library button
lib/screens/artist_details_screen.dart Optimistic updates for library button

Key Learnings

  1. inLibrary field: Music Assistant's provider mappings include an in_library field that indicates whether the user added the item from that specific provider account. This is essential for accurate per-provider filtering.

  2. iTunes podcast provider: The MA iTunes podcast provider is for search only - it doesn't properly store items in the library. Users should add podcasts from streaming providers like Spotify instead.

  3. Optimistic updates: For better UX, library toggle buttons should update immediately and only revert if the API call fails, rather than waiting for API response.

  4. Search vs Library filtering: Server-side provider filtering should only apply to library views, not search results (unless the "in library" toggle is enabled).