refactor(app): share client and presentation scaffolds
This commit is contained in:
+11
-836
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user