From d7628028f6ebeea8e07c191fdbf7d7335de76917 Mon Sep 17 00:00:00 2001 From: Wasabules <39313803+Wasabules@users.noreply.github.com> Date: Sun, 6 Sep 2026 05:01:02 +0200 Subject: [PATCH] fix: Resolve playlist membership without flooding the connection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PlaylistNotifier._init asked every playlist whether it already holds the item being added, and it asked them all at once: forEach with an async callback awaits nothing, so one request per playlist leaves at the same moment. On a library with a few hundred playlists that burst is enough to make a reverse proxy start refusing requests, and everything else queues behind it — unrelated calls measured at 10s instead of 300ms, with the app reporting itself offline because the reachability probe times out. Most of that work was never needed. The provider is built with an empty item list, so the lookups ran on startup with nothing to look for and every answer was discarded. They are now skipped entirely when there is no item, which is the common case. When there is one, the lookups run through a small pool instead of all at once, and a playlist that cannot be read is recorded as unknown rather than throwing out of the async callback as an unhandled exception. --- lib/providers/playlist_provider.dart | 77 ++++++++++++++++++++++------ 1 file changed, 60 insertions(+), 17 deletions(-) diff --git a/lib/providers/playlist_provider.dart b/lib/providers/playlist_provider.dart index df919a3de..6a3dda0ec 100644 --- a/lib/providers/playlist_provider.dart +++ b/lib/providers/playlist_provider.dart @@ -1,3 +1,5 @@ +import 'dart:developer'; + import 'package:chopper/chopper.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -8,6 +10,11 @@ import 'package:fladder/providers/api_provider.dart'; import 'package:fladder/providers/service_provider.dart'; import 'package:fladder/util/map_bool_helper.dart'; +/// Playlist membership lookups kept in flight at once. Sending them all at +/// once floods the connection on libraries holding hundreds of playlists, and +/// every other request queues up behind them. +const _membershipLookupConcurrency = 5; + final playlistStateProvider = StateProvider>((ref) => []); class _PlaylistProviderModel { @@ -62,32 +69,68 @@ class PlaylistNotifier extends StateNotifier<_PlaylistProviderModel> { ], ); - final playlists = serverPlaylists.body?.items?.map((e) => PlaylistModel.fromBaseDto(e, ref)).toList(); + final playlists = serverPlaylists.body?.items?.map((e) => PlaylistModel.fromBaseDto(e, ref)).toList() ?? []; - ref.read(playlistStateProvider.notifier).state = playlists ?? []; + ref.read(playlistStateProvider.notifier).state = playlists; state = state.copyWith( - collections: Map.fromIterables(playlists ?? [], List.generate(playlists?.length ?? 0, (index) => null)), + collections: Map.fromIterables(playlists, List.generate(playlists.length, (index) => null)), ); - playlists?.forEach( - (playlist) async { - final itemList = await api.playlistsPlaylistIdItemsGet( - playlistId: playlist.id, - enableImages: false, - enableUserData: false, - fields: [], - ); - final List items = (itemList.body?.items ?? []).map((e) => e.id).toList(); - state = state.copyWith( - collections: state.collections.setKey(playlist, items.contains(state.items.firstOrNull?.id)), - ); - }, - ); + await _markPlaylistsHoldingItem(playlists); state = state.copyWith(isLoading: false); } + /// Marks the playlists that already hold the item being added. + /// + /// Jellyfin has no bulk endpoint for this, so it costs one request per + /// playlist and they are spread over [_membershipLookupConcurrency] workers. + Future _markPlaylistsHoldingItem(List playlists) async { + final itemId = state.items.firstOrNull?.id; + + // The provider is built with an empty item list, so without this the + // lookups would run on startup with nothing to look for. + if (itemId == null || playlists.isEmpty) { + state = state.copyWith(collections: {for (final playlist in playlists) playlist: false}); + return; + } + + var next = 0; + + Future resolveNext() async { + while (mounted) { + final index = next++; + if (index >= playlists.length) return; + + final playlist = playlists[index]; + final holdsItem = await _playlistHoldsItem(playlist, itemId); + if (!mounted) return; + + state = state.copyWith(collections: state.collections.setKey(playlist, holdsItem)); + } + } + + await Future.wait(List.generate(_membershipLookupConcurrency, (_) => resolveNext())); + } + + /// Whether [playlist] contains [itemId], or null when it could not be read. + Future _playlistHoldsItem(PlaylistModel playlist, String itemId) async { + try { + final itemList = await api.playlistsPlaylistIdItemsGet( + playlistId: playlist.id, + enableImages: false, + enableUserData: false, + fields: [], + ); + return itemList.body?.items.any((item) => item.id == itemId) ?? false; + } catch (e) { + // One unreadable playlist should not take the whole sheet down with it. + log('Could not read the items of playlist ${playlist.id}: $e'); + return null; + } + } + Future addToPlaylist({required PlaylistModel playlist}) async { final response = await api.playlistsPlaylistIdItemsPost(playlistId: playlist.id, ids: state.items.map((e) => e.id).toList());