refactor(app): share client and presentation scaffolds

This commit is contained in:
edde746
2026-07-12 17:31:12 +02:00
parent 97f7508067
commit 6506f36843
24 changed files with 2142 additions and 1943 deletions
+11 -836
View File
@@ -76,6 +76,10 @@ import 'plex_playback_mapper.dart';
import 'playback_initialization_types.dart';
part 'plex_client/parts/live_tv.dart';
part 'plex_client/parts/playlists.dart';
part 'plex_client/parts/collections.dart';
part 'plex_client/parts/play_queues.dart';
part 'plex_client/parts/metadata_edit.dart';
/// Result of a paginated library content fetch
class _LibraryContentResult {
@@ -208,7 +212,13 @@ bool? _parsePlexTranscoderVideoCapability(Object? value) {
}
class PlexClient
with MediaServerCacheMixin, _PlexLiveTvClientMethods
with
MediaServerCacheMixin,
_PlexLiveTvClientMethods,
_PlexPlaylistMethods,
_PlexCollectionMethods,
_PlexPlayQueueMethods,
_PlexMetadataEditMethods
implements MediaServerClient, SeasonEpisodePagingClient, GracefullyCloseable {
@override
PlexConfig config;
@@ -2083,717 +2093,6 @@ class PlexClient
return _LibraryContentResult(items: pageItems, totalSize: totalSize);
}
/// Get playlist content by playlist ID, paginated.
Future<_LibraryContentResult> _getPlaylist(String playlistId, {int? start, int? size, AbortController? abort}) =>
_fetchPaginatedList('/playlists/$playlistId/items', start: start, size: size, abort: abort);
/// Get all playlists.
/// Filters by playlistType=video by default.
/// Set smart to true/false to filter smart playlists, or null for all.
Future<List<PlexPlaylistDto>> _getPlaylists({String playlistType = 'video', bool? smart}) async {
try {
final all = <PlexPlaylistDto>[];
var start = 0;
while (true) {
final page = await _getPlaylistsPage(
playlistType: playlistType,
smart: smart,
start: start,
size: _fetchAllPageSize,
);
if (page.items.isEmpty) break;
all.addAll(page.items);
start += page.items.length;
if (start >= page.totalSize) break;
}
return all;
} catch (e, st) {
appLogger.e('Failed to get playlists', error: e, stackTrace: st);
return [];
}
}
Future<({List<PlexPlaylistDto> items, int totalSize})> _getPlaylistsPage({
String playlistType = 'video',
bool? smart,
int? start,
int? size,
AbortController? abort,
}) async {
final pageSize = size ?? _defaultListContainerSize;
final queryParams = <String, dynamic>{
if (playlistType.isNotEmpty) 'playlistType': playlistType,
..._buildPaginationParams(start, pageSize),
};
if (smart != null) {
queryParams['smart'] = smart ? '1' : '0';
}
final response = await _getWithFailover('/playlists', queryParameters: queryParams, abort: abort);
return _extractPlaylistListResult(response, start: start, size: pageSize);
}
/// Get playlist metadata by playlist ID
/// Returns the playlist details (not the items)
Future<PlexPlaylistDto?> _getPlaylistMetadata(String playlistId) async {
try {
final response = await _getWithFailover('/playlists/$playlistId');
final container = _getMediaContainer(response);
if (container == null || container['Metadata'] == null) {
return null;
}
final List<dynamic> metadata = container['Metadata'] as List;
if (metadata.isEmpty) {
return null;
}
return PlexPlaylistDto.fromJson(metadata.first as Map<String, dynamic>);
} catch (e) {
appLogger.e('Failed to get playlist metadata: $e');
return null;
}
}
/// Neutral [MediaServerClient.createPlaylist] override — wraps
/// [createPlaylistFromUri] after building a Plex metadata URI from
/// the supplied items.
@override
Future<MediaPlaylist?> createPlaylist({required String title, required List<MediaItem> items}) async {
if (items.isEmpty) {
return createPlaylistFromUri(title: title);
}
final uri = await buildMetadataUri(items.map((i) => i.id).join(','));
return createPlaylistFromUri(title: title, uri: uri, type: items.first.kind.isMusic ? 'audio' : 'video');
}
/// Create a new playlist
/// [title] - Name of the playlist
/// [uri] - Optional comma-separated list of item URIs to add (e.g., "server://uuid/com.plexapp.plugins.library/library/metadata/1234")
/// [playQueueId] - Optional play queue ID to create playlist from
/// [type] - Plex playlist type ('video' or 'audio' for music items)
///
/// Errors propagate to the caller (matches the [MediaServerClient]
/// contract — throw on HTTP/transport failures, return `null` only when
/// the server replied 2xx but with no usable playlist payload).
Future<MediaPlaylist?> createPlaylistFromUri({
required String title,
String? uri,
int? playQueueId,
String type = 'video',
}) async {
final queryParams = <String, dynamic>{'type': type, 'title': title, 'smart': '0'};
if (uri != null) {
queryParams['uri'] = uri;
}
if (playQueueId != null) {
queryParams['playQueueID'] = playQueueId.toString();
}
final response = await _http.post('/playlists', queryParameters: queryParams);
throwIfHttpError(response);
final container = _getMediaContainer(response);
if (container == null || container['Metadata'] == null) {
return null;
}
final List<dynamic> metadata = container['Metadata'] as List;
if (metadata.isEmpty) {
return null;
}
final dto = PlexPlaylistDto.fromJson(
metadata.first as Map<String, dynamic>,
).copyWith(serverId: serverId, serverName: serverName);
return PlexMappers.mediaPlaylist(dto);
}
/// Delete a playlist
@override
Future<bool> deletePlaylist(MediaPlaylist playlist) {
return _wrapBoolApiCall(() => _http.delete('/playlists/${playlist.id}'), 'Failed to delete playlist');
}
/// Neutral [MediaServerClient.addToPlaylist] override — builds a Plex
/// metadata URI from [items] and delegates to [addItemsToPlaylistByUri].
@override
Future<bool> addToPlaylist({required String playlistId, required List<MediaItem> items}) async {
if (items.isEmpty) return true;
final uri = await buildMetadataUri(items.map((i) => i.id).join(','));
return addItemsToPlaylistByUri(playlistId: playlistId, uri: uri);
}
/// Add items to a playlist
/// [playlistId] - The playlist to add items to
/// [uri] - Comma-separated list of item URIs to add
Future<bool> addItemsToPlaylistByUri({required String playlistId, required String uri}) async {
appLogger.d(
'Adding to playlist $playlistId with URI: ${uri.substring(0, uri.length > 100 ? 100 : uri.length)}${uri.length > 100 ? "..." : ""}',
);
final result = await _wrapBoolApiCall(
() => _http.put('/playlists/$playlistId/items', queryParameters: {'uri': uri}),
'Failed to add to playlist',
);
if (result) {
appLogger.d('Add to playlist response status: 200');
}
return result;
}
@override
Future<bool> removeFromPlaylist({required String playlistId, required MediaItem item}) {
if (item is! PlexMediaItem || item.playlistItemId == null) return Future.value(false);
return _wrapBoolApiCall(
() => _http.delete('/playlists/$playlistId/items/${item.playlistItemId}'),
'Failed to remove from playlist',
);
}
/// Plex's `?after=0` sentinel means "move to the top". For any other index
/// the API needs the playlist-item id of the row that should sit immediately
/// before [item] after the move — that's what [afterItem] provides.
@override
Future<bool> movePlaylistItem({
required String playlistId,
required MediaItem item,
required int newIndex,
required MediaItem? afterItem,
}) async {
if (item is! PlexMediaItem || item.playlistItemId == null) return false;
final int after;
if (newIndex == 0) {
after = 0;
} else if (afterItem is PlexMediaItem && afterItem.playlistItemId != null) {
after = afterItem.playlistItemId!;
} else {
return false;
}
appLogger.d('Moving playlist item ${item.playlistItemId} after $after in playlist $playlistId');
return _wrapBoolApiCall(
() => _http.put('/playlists/$playlistId/items/${item.playlistItemId}/move', queryParameters: {'after': after}),
'Failed to move playlist item',
);
}
/// Update metadata fields for a media item
Future<bool> updateMetadata({
required int sectionId,
required String ratingKey,
required int typeNumber,
String? title,
String? titleSort,
String? originalTitle,
String? originallyAvailableAt,
String? contentRating,
String? studio,
String? tagline,
String? summary,
Map<String, ({List<String> current, List<String> original})>? tagChanges,
}) async {
final queryParams = <String, dynamic>{'type': typeNumber, 'id': ratingKey};
void addField(String name, String? value) {
if (value != null) {
queryParams['$name.value'] = value;
queryParams['$name.locked'] = '1';
}
}
addField('title', title);
addField('titleSort', titleSort);
addField('originalTitle', originalTitle);
addField('originallyAvailableAt', originallyAvailableAt);
addField('contentRating', contentRating);
addField('studio', studio);
addField('tagline', tagline);
addField('summary', summary);
if (tagChanges != null) {
for (final entry in tagChanges.entries) {
final field = entry.key;
final current = entry.value.current;
final original = entry.value.original;
for (var i = 0; i < current.length; i++) {
queryParams['$field[$i].tag.tag'] = current[i];
}
final removed = original.where((t) => !current.contains(t)).toList();
if (removed.isNotEmpty) {
queryParams['$field[].tag.tag-'] = removed.map(Uri.encodeComponent).join(',');
}
queryParams['$field.locked'] = '1';
}
}
final result = await _wrapBoolApiCall(
() => _http.put('/library/sections/$sectionId/all', queryParameters: queryParams),
'Failed to update metadata',
);
if (result) {
await _deleteMetadataEditCache(ratingKey);
}
return result;
}
/// Search for match candidates for a media item.
Future<List<PlexMatchResult>> findMatches(
String ratingKey, {
String? title,
String? year,
String? agent,
String? language,
}) async {
final queryParams = <String, dynamic>{'manual': 1};
if (title != null && title.isNotEmpty) queryParams['title'] = title;
if (year != null && year.isNotEmpty) queryParams['year'] = year;
if (agent != null && agent.isNotEmpty) queryParams['agent'] = agent;
if (language != null && language.isNotEmpty) queryParams['language'] = language;
return _wrapListApiCall<PlexMatchResult>(
() => _getWithFailover('/library/metadata/$ratingKey/matches', queryParameters: queryParams),
(response) {
final container = _getMediaContainer(response);
if (container == null || container['SearchResult'] == null) return [];
return (container['SearchResult'] as List)
.map((json) => PlexMatchResult.fromJson(json as Map<String, dynamic>))
.toList();
},
'Failed to search for matches',
);
}
/// Apply a chosen match to a media item.
Future<bool> applyMatch(String ratingKey, {required String guid, String? name, String? year}) async {
final queryParams = <String, dynamic>{'guid': guid};
if (name != null && name.isNotEmpty) queryParams['name'] = name;
if (year != null && year.isNotEmpty) queryParams['year'] = year;
final result = await _wrapBoolApiCall(
() => _http.put('/library/metadata/$ratingKey/match', queryParameters: queryParams),
'Failed to apply match',
);
if (result) {
await _deleteMetadataEditCache(ratingKey);
}
return result;
}
Future<bool> unmatchItem(String ratingKey) async {
final result = await _wrapBoolApiCall(
() => _http.put('/library/metadata/$ratingKey/unmatch'),
'Failed to unmatch item',
);
if (result) {
await _deleteMetadataEditCache(ratingKey);
}
return result;
}
/// Get available artwork (posters or backgrounds) for a media item
Future<List<Map<String, dynamic>>> getAvailableArtwork(String ratingKey, String element) async {
try {
final response = await _getWithFailover('/library/metadata/$ratingKey/$element');
final container = _getMediaContainer(response);
if (container != null && container['Metadata'] != null) {
return (container['Metadata'] as List).cast<Map<String, dynamic>>();
}
return [];
} catch (e) {
appLogger.e('Failed to get available artwork', error: e);
return [];
}
}
/// Set artwork from a URL (can be a Plex internal path or external URL)
Future<bool> setArtworkFromUrl(String ratingKey, String element, String url) async {
final setElement = element.endsWith('s') ? element.substring(0, element.length - 1) : element;
final result = await _wrapBoolApiCall(
() => _http.put('/library/metadata/$ratingKey/$setElement', queryParameters: {'url': url}),
'Failed to set artwork from URL',
);
if (result) {
await _deleteMetadataEditCache(ratingKey);
}
return result;
}
/// Upload artwork from binary data
Future<bool> uploadArtwork(String ratingKey, String element, List<int> bytes) async {
final setElement = element.endsWith('s') ? element.substring(0, element.length - 1) : element;
final result = await _wrapBoolApiCall(
() => _http.put(
'/library/metadata/$ratingKey/$setElement',
body: bytes,
headers: {'Content-Type': 'application/octet-stream', 'Content-Length': '${bytes.length}'},
),
'Failed to upload artwork',
);
if (result) {
await _deleteMetadataEditCache(ratingKey);
}
return result;
}
/// Update per-media advanced preferences
Future<bool> updateMetadataPrefs(String ratingKey, Map<String, String> prefs) async {
final result = await _wrapBoolApiCall(
() => _http.put('/library/metadata/$ratingKey/prefs', queryParameters: prefs),
'Failed to update metadata preferences',
);
if (result) {
await _deleteMetadataEditCache(ratingKey);
}
return result;
}
Future<void> _deleteMetadataEditCache(String ratingKey) async {
try {
await _cache.deleteForItem(serverId, ratingKey);
} catch (e, st) {
appLogger.w('Plex metadata edit cache invalidation failed', error: e, stackTrace: st);
}
}
/// Get one page of collections for a library section.
Future<_LibraryContentResult> _getLibraryCollectionsPage(
String sectionId, {
int? start,
int? size,
AbortController? abort,
}) async {
final queryParameters = _buildPaginationParams(start, size)..['includeGuids'] = 1;
final response = await _getWithFailover(
'/library/sections/$sectionId/collections',
queryParameters: queryParameters,
abort: abort,
);
return _extractLibraryContentResult(
response,
librarySectionID: _librarySectionIdFromString(sectionId),
start: start,
requestedSize: size,
);
}
/// Get all collections for a library section.
Future<List<PlexMetadataDto>> _getLibraryCollections(String sectionId) async {
try {
return _fetchAllPages((start, size, abort) {
return _getLibraryCollectionsPage(sectionId, start: start, size: size, abort: abort);
});
} catch (e, st) {
appLogger.e('Failed to get library collections', error: e, stackTrace: st);
return [];
}
}
/// Get items in a collection, paginated.
Future<_LibraryContentResult> _getCollectionItems(
String collectionId, {
int? start,
int? size,
AbortController? abort,
String? librarySectionID,
String? librarySectionTitle,
}) => _fetchPaginatedList(
'/library/collections/$collectionId/children',
start: start,
size: size,
abort: abort,
librarySectionID: _librarySectionIdFromString(librarySectionID),
librarySectionTitle: librarySectionTitle,
);
/// Get media featuring a specific person (actor/director), paginated.
Future<_LibraryContentResult> _getPersonMedia(String personId, {int? start, int? size, AbortController? abort}) =>
_fetchPaginatedList('/library/people/$personId/media', start: start, size: size, abort: abort);
/// Fetch every media item featuring a given person.
Future<List<PlexMetadataDto>> _fetchAllPersonMediaDto(String personId) =>
_fetchAllPages((start, size, abort) => _getPersonMedia(personId, start: start, size: size, abort: abort));
/// Delete a collection. Reads the section id from [collection.libraryId].
@override
Future<bool> deleteCollection(MediaItem collection) async {
final sectionId = collection.libraryId ?? '';
return deleteCollectionById(sectionId, collection.id);
}
Future<bool> deleteCollectionById(String sectionId, String collectionId) async {
appLogger.d('Deleting collection: sectionId=$sectionId, collectionId=$collectionId');
final result = await _wrapBoolApiCall(
() => _http.delete('/library/collections/$collectionId'),
'Failed to delete collection',
);
if (result) {
appLogger.d('Delete collection response: 200');
}
return result;
}
/// Neutral [MediaServerClient.createCollection] — builds a Plex metadata
/// URI for [items] and maps [itemKind] to Plex's section type id.
@override
Future<String?> createCollection({
required String libraryId,
required String title,
required List<MediaItem> items,
MediaKind? itemKind,
}) async {
final uri = items.isEmpty ? '' : await buildMetadataUri(items.map((i) => i.id).join(','));
final type = switch (itemKind) {
MediaKind.movie => 1,
MediaKind.show => 2,
MediaKind.season => 3,
MediaKind.episode => 4,
_ => null,
};
return createCollectionFromUri(sectionId: libraryId, title: title, uri: uri, type: type);
}
/// Create a new collection
/// Creates a new collection and optionally adds items to it
/// Returns the created collection ID or null if failed
Future<String?> createCollectionFromUri({
required String sectionId,
required String title,
required String uri,
int? type,
}) async {
try {
appLogger.d('Creating collection: sectionId=$sectionId, title=$title, type=$type');
final response = await _http.post(
'/library/collections',
queryParameters: {'type': ?type, 'title': title, 'smart': 0, 'sectionId': sectionId, 'uri': uri},
);
throwIfHttpError(response);
appLogger.d('Create collection response: ${response.statusCode}');
// Extract the collection ID from the response
// The response should contain the created collection metadata
final container = _getMediaContainer(response);
if (container != null) {
final metadata = container['Metadata'];
if (metadata != null && (metadata as List).isNotEmpty) {
final collectionId = metadata.first['ratingKey']?.toString();
appLogger.d('Created collection with ID: $collectionId');
return collectionId;
}
}
return null;
} catch (e) {
appLogger.e('Failed to create collection', error: e);
return null;
}
}
/// Neutral [MediaServerClient.addToCollection] — builds a Plex metadata URI
/// from [items] and delegates to [addItemsToCollectionByUri].
@override
Future<bool> addToCollection({required String collectionId, required List<MediaItem> items}) async {
if (items.isEmpty) return true;
final uri = await buildMetadataUri(items.map((i) => i.id).join(','));
return addItemsToCollectionByUri(collectionId: collectionId, uri: uri);
}
/// Add items to an existing collection
/// Adds one or more items (specified by URI) to an existing collection
Future<bool> addItemsToCollectionByUri({required String collectionId, required String uri}) async {
appLogger.d('Adding items to collection: collectionId=$collectionId');
final result = await _wrapBoolApiCall(
() => _http.put('/library/collections/$collectionId/items', queryParameters: {'uri': uri}),
'Failed to add items to collection',
);
if (result) {
appLogger.d('Add to collection response: 200');
}
return result;
}
/// Remove an item from a collection
/// Removes a single item from an existing collection
@override
Future<bool> removeFromCollection({required String collectionId, required MediaItem item}) async {
appLogger.d('Removing item from collection: collectionId=$collectionId, itemId=${item.id}');
final result = await _wrapBoolApiCall(
() => _http.delete('/library/collections/$collectionId/items/${item.id}'),
'Failed to remove item from collection',
);
if (result) {
appLogger.d('Remove from collection response: 200');
}
return result;
}
/// Parse a `/playQueues/{id}` response into a [PlayQueueResponse] with
/// MediaItem-typed entries.
PlayQueueResponse _parsePlayQueueResponse(dynamic data, {int? librarySectionID, String? librarySectionTitle}) {
final container = data is Map && data['MediaContainer'] is Map
? data['MediaContainer'] as Map<String, dynamic>
: data as Map<String, dynamic>;
final containerSectionID = _librarySectionIdFromJson(container) ?? librarySectionID;
final containerSectionTitle = _librarySectionTitleFromJson(container) ?? librarySectionTitle;
final metadata = container['Metadata'];
List<MediaItem>? items;
if (metadata is List) {
items = [
for (final e in metadata)
if (e is Map<String, dynamic>)
PlexMappers.mediaItem(
_createTaggedMetadataWithLibrary(
e,
librarySectionID: containerSectionID,
librarySectionTitle: containerSectionTitle,
),
),
];
}
final playQueueID = flexibleInt(container['playQueueID']);
final playQueueVersion = flexibleInt(container['playQueueVersion']);
if (playQueueID == null || playQueueVersion == null) {
throw const FormatException('Plex play queue response is missing its numeric id or version');
}
return PlayQueueResponse(
playQueueID: playQueueID,
playQueueSelectedItemID: flexibleInt(container['playQueueSelectedItemID']),
playQueueSelectedItemOffset: flexibleInt(container['playQueueSelectedItemOffset']),
playQueueSelectedMetadataItemID: container['playQueueSelectedMetadataItemID'] as String?,
playQueueShuffled: flexibleBool(container['playQueueShuffled']),
playQueueSourceURI: container['playQueueSourceURI'] as String?,
playQueueTotalCount: flexibleInt(container['playQueueTotalCount']),
playQueueVersion: playQueueVersion,
size: flexibleInt(container['size']),
items: items,
);
}
/// Create a new play queue
/// Either uri or playlistID must be specified
Future<PlayQueueResponse?> createPlayQueue({
String? uri,
int? playlistID,
required String type,
String? key,
int shuffle = 0,
int repeat = 0,
int continuous = 0,
String? librarySectionID,
String? librarySectionTitle,
}) async {
try {
final queryParams = <String, dynamic>{
'type': type,
'shuffle': shuffle,
'repeat': repeat,
'continuous': continuous,
};
if (uri != null) {
queryParams['uri'] = uri;
}
if (playlistID != null) {
queryParams['playlistID'] = playlistID;
}
if (key != null) {
queryParams['key'] = key;
}
final response = await _http.post('/playQueues', queryParameters: queryParams);
throwIfHttpError(response);
return _parsePlayQueueResponse(
response.data,
librarySectionID: _librarySectionIdFromString(librarySectionID),
librarySectionTitle: librarySectionTitle,
);
} catch (e) {
appLogger.e('Failed to create play queue', error: e);
return null;
}
}
/// Get a play queue with optional windowing
/// Can request a window of items around a specific item
Future<PlayQueueResponse?> getPlayQueue(
int playQueueId, {
String? center,
int window = 50,
int includeBefore = 1,
int includeAfter = 1,
String? librarySectionID,
String? librarySectionTitle,
}) async {
try {
final queryParams = <String, dynamic>{
'window': window,
'includeBefore': includeBefore,
'includeAfter': includeAfter,
};
if (center != null) {
queryParams['center'] = center;
}
final response = await _getWithFailover('/playQueues/$playQueueId', queryParameters: queryParams);
return _parsePlayQueueResponse(
response.data,
librarySectionID: _librarySectionIdFromString(librarySectionID),
librarySectionTitle: librarySectionTitle,
);
} catch (e) {
appLogger.e('Failed to get play queue: $e');
return null;
}
}
/// Create a play queue for a TV show (all episodes)
///
/// This is a convenience method that creates a play queue from a show's URI.
/// Perfect for sequential or shuffle playback of an entire series.
///
/// Parameters:
/// - [showRatingKey]: The rating key of the show
/// - [shuffle]: Whether to shuffle the episodes (0 = off, 1 = on)
/// - [startingEpisodeKey]: Optional rating key of episode to start from
///
/// Returns a PlayQueueResponse with all episodes from the show
Future<PlayQueueResponse?> createShowPlayQueue({
required String showRatingKey,
int shuffle = 0,
String? startingEpisodeKey,
String? librarySectionID,
String? librarySectionTitle,
}) async {
try {
// Build the queue from the show's `/allLeaves` (every episode) rather than
// `/children` (its seasons). Plex flattens `/children` season-by-season,
// which clumps the whole Specials folder together; `/allLeaves` makes Plex
// order the queue by the show's aired episode order, so Specials interleave
// between regular episodes the way Plex's own client plays them. `/children`
// otherwise strands interleaved Specials ahead of S01, so sequential
// auto-play walks the season and never reaches them (#1416).
final uri = '${await buildMetadataUri(showRatingKey)}/allLeaves';
return await createPlayQueue(
uri: uri,
type: 'video',
shuffle: shuffle,
key: startingEpisodeKey != null ? '/library/metadata/$startingEpisodeKey' : null,
continuous: startingEpisodeKey != null && shuffle == 0 ? 1 : 0,
librarySectionID: librarySectionID,
librarySectionTitle: librarySectionTitle,
);
} catch (e) {
appLogger.e('Failed to create show play queue', error: e);
return null;
}
}
/// Extract both Metadata and Directory entries from response
/// Folders can come back as either type
/// Automatically tags all items with this client's serverId and serverName
@@ -4090,130 +3389,6 @@ class PlexClient
throw UnsupportedError('Plex does not support user favorites.');
}
@override
Future<List<MediaPlaylist>> fetchPlaylists({String playlistType = 'video', bool? smart}) async {
final playlists = await _getPlaylists(playlistType: playlistType, smart: smart);
return playlists.map((p) => PlexMappers.mediaPlaylist(p)).toList();
}
@override
Future<LibraryPage<MediaPlaylist>> fetchPlaylistsPage({
String playlistType = 'video',
bool? smart,
int? start,
int? size,
AbortController? abort,
}) async {
final result = await _getPlaylistsPage(
playlistType: playlistType,
smart: smart,
start: start,
size: size,
abort: abort,
);
return LibraryPage<MediaPlaylist>(
items: result.items.map((p) => PlexMappers.mediaPlaylist(p)).toList(),
totalCount: result.totalSize,
offset: start ?? 0,
);
}
@override
Future<MediaPlaylist?> fetchPlaylistMetadata(String id) async {
final p = await _getPlaylistMetadata(id);
return p == null ? null : PlexMappers.mediaPlaylist(p);
}
@override
Future<List<MediaItem>> fetchPlaylistItems(String id, {int offset = 0, int limit = 100}) async {
final page = await fetchPlaylistPage(id, start: offset, size: limit);
return page.items;
}
@override
Future<LibraryPage<MediaItem>> fetchPlaylistPage(
String playlistId, {
int? start,
int? size,
AbortController? abort,
}) async {
final result = await _getPlaylist(playlistId, start: start, size: size, abort: abort);
return LibraryPage<MediaItem>(
items: result.items.map((m) => PlexMappers.mediaItem(m)).toList(),
totalCount: result.totalSize,
offset: start ?? 0,
);
}
@override
Future<List<MediaItem>> fetchCollections(String libraryId) async {
final raw = await _getLibraryCollections(libraryId);
return raw.map((m) => PlexMappers.mediaItem(m)).toList();
}
@override
Future<LibraryPage<MediaItem>> fetchCollectionsPage(
String libraryId, {
int? start,
int? size,
AbortController? abort,
}) async {
final result = await _getLibraryCollectionsPage(libraryId, start: start, size: size, abort: abort);
return LibraryPage<MediaItem>(
items: result.items.map((m) => PlexMappers.mediaItem(m)).toList(),
totalCount: result.totalSize,
offset: start ?? 0,
);
}
@override
Future<LibraryPage<MediaItem>> fetchCollectionPage(
String collectionId, {
int? start,
int? size,
AbortController? abort,
String? libraryId,
String? libraryTitle,
}) async {
final result = await _getCollectionItems(
collectionId,
start: start,
size: size,
abort: abort,
librarySectionID: libraryId,
librarySectionTitle: libraryTitle,
);
return LibraryPage<MediaItem>(
items: result.items.map((m) => PlexMappers.mediaItem(m)).toList(),
totalCount: result.totalSize,
offset: start ?? 0,
);
}
@override
Future<LibraryPage<MediaItem>> fetchPersonMediaPage(
String personId, {
int? start,
int? size,
AbortController? abort,
}) async {
final result = await _getPersonMedia(personId, start: start, size: size, abort: abort);
return LibraryPage<MediaItem>(
items: result.items.map((m) => PlexMappers.mediaItem(m)).toList(),
totalCount: result.totalSize,
offset: start ?? 0,
);
}
@override
Future<List<MediaItem>> fetchPersonMedia(String personId) => fetchAllPersonMediaAsMediaItems(personId);
/// Plex-specific: full person-media listing across pages.
Future<List<MediaItem>> fetchAllPersonMediaAsMediaItems(String personId) async {
final raw = await _fetchAllPersonMediaDto(personId);
return raw.map((m) => PlexMappers.mediaItem(m)).toList();
}
/// Plex-specific: hub content as neutral [MediaItem]s.
Future<List<MediaItem>> fetchHubContent(String hubKey) async {
final raw = await _getHubContent(hubKey);