Files
plezy/lib/services/jellyfin_client/parts/collections.dart
T
edde746 2a7e5f4f9c fix(jellyfin): ask the server who may delete before offering it
Jellyfin never consults IsAdministrator when authorizing a library
delete: BaseItem.IsAuthorizedToDelete looks at EnableContentDeletion and
the per-library grant, and only the first user a server creates gets the
former for free. Gating the "Delete from server" entry on the admin bit
therefore offered a destructive action that answers 401 to later
administrators, and hid it from plain users who do hold the grant.

Ask the server per item instead, through the new
MediaDeletionPermissionClient capability: BaseItemDto.CanDelete already
folds the global grant, the per-library grant, and item state such as
missing files or an in-progress recording. The probe runs when a menu
opens on a deletable kind, costs ~0.5 KB, carries a whole-request
deadline because the client's own budget covers connect and receive
separately, and fails closed on anything unknown. Plex keeps its
account-level owner/admin gate; it has no per-item permission on the
wire.

close #1749
2026-08-02 07:08:14 +02:00

198 lines
6.8 KiB
Dart

part of '../../jellyfin_client.dart';
mixin _JellyfinCollectionMethods on _JellyfinClientInternals {
static const int _collectionsPageSize = 36;
String? _boxSetsViewId;
@override
Future<List<MediaItem>> fetchCollections(String libraryId) => drainPages<MediaItem>(
(start, size) => fetchCollectionsPage(libraryId, start: start, size: size),
pageSize: _collectionsPageSize,
);
@override
Future<LibraryPage<MediaItem>> fetchCollectionsPage(
String libraryId, {
int? start,
int? size,
AbortController? abort,
}) async {
final s = start ?? 0;
final pageSize = size ?? _collectionsPageSize;
final boxSetsViewId = await _fetchBoxSetsViewId(abort: abort);
if (boxSetsViewId == null) {
return LibraryPage<MediaItem>(items: const [], totalCount: 0, offset: s);
}
final response = await _http.get(
'/Items',
queryParameters: {
'userId': connection.userId,
'ParentId': boxSetsViewId,
'IncludeItemTypes': 'BoxSet',
'Recursive': 'true',
'StartIndex': s.toString(),
'Limit': pageSize.toString(),
'SortBy': 'SortName',
'SortOrder': 'Ascending',
'Fields': _browseFields,
...jellyfinImageQueryParameters,
},
abort: abort,
);
throwIfHttpError(response);
return _pagedItems(response.data, offset: s, requestedSize: pageSize, map: _mapItems);
}
Future<String?> _fetchBoxSetsViewId({AbortController? abort}) async {
if (_boxSetsViewId != null) return _boxSetsViewId;
final response = await _http.get('/Users/${_segment(connection.userId)}/Views', abort: abort);
throwIfHttpError(response);
for (final view in _itemsArray(response.data)) {
final collectionType = (view['CollectionType'] as String?)?.toLowerCase();
final id = view['Id'] as String?;
if (collectionType == 'boxsets' && id != null && id.isNotEmpty) {
_boxSetsViewId = id;
return id;
}
}
return null;
}
@override
Future<LibraryPage<MediaItem>> fetchCollectionPage(
String collectionId, {
int? start,
int? size,
AbortController? abort,
String? libraryId,
String? libraryTitle,
}) async {
final s = start ?? 0;
final response = await _http.get(
'/Items',
queryParameters: {
'userId': connection.userId,
'ParentId': collectionId,
'StartIndex': s.toString(),
if (size != null) 'Limit': size.toString(),
'Fields': _browseFields,
...jellyfinImageQueryParameters,
},
abort: abort,
);
throwIfHttpError(response);
return _pagedItems(response.data, offset: s, requestedSize: size, map: _mapItems);
}
@override
Future<String?> createCollection({
required String libraryId,
required String title,
required List<MediaItem> items,
MediaKind? itemKind,
}) async {
// ParentId is optional on Jellyfin's `/Collections` endpoint — when
// omitted the server picks a default BoxSet root. We pass libraryId so
// the new collection lives in the same library as the seeded items.
final response = await _http.post(
'/Collections',
queryParameters: {
'Name': title,
if (items.isNotEmpty) 'Ids': items.map((i) => i.id).join(','),
if (libraryId.isNotEmpty) 'ParentId': libraryId,
},
);
throwIfHttpError(response);
final data = response.data;
return data is Map<String, dynamic> ? data['Id'] as String? : null;
}
@override
Future<bool> addToCollection({required String collectionId, required List<MediaItem> items}) async {
if (items.isEmpty) return true;
final response = await _http.post(
'/Collections/${_segment(collectionId)}/Items',
queryParameters: {'Ids': items.map((i) => i.id).join(',')},
);
throwIfHttpError(response);
return true;
}
@override
Future<bool> removeFromCollection({required String collectionId, required MediaItem item}) async {
final response = await _http.delete(
'/Collections/${_segment(collectionId)}/Items',
queryParameters: {'Ids': item.id},
);
throwIfHttpError(response);
return true;
}
@override
Future<bool> deleteCollection(MediaItem collection) async {
final response = await _http.delete('/Items/${_segment(collection.id)}');
throwIfHttpError(response);
return true;
}
@override
Future<bool> deleteMediaItem(MediaItem item) async {
final response = await _http.delete('/Items/${_segment(item.id)}');
throwIfHttpError(response);
return true;
}
/// `/Items?ids=` rather than `/Users/{id}/Items/{id}`: the single-item route
/// ignores `Fields` and returns the whole dto (measured 33 KB / ~150 ms on a
/// remote server), while the list route honours it and answers with a ~0.5 KB
/// body in ~40 ms. Images and user data are switched off for the same reason.
///
/// An id the user cannot see comes back as an empty `Items` array, which is
/// the same answer as "not allowed" for gating purposes.
///
/// A context menu waits on this, so the probe carries a real wall-clock
/// ceiling: [MediaServerHttpClient] applies its `timeout` to the connect and
/// receive phases separately (so it alone would allow roughly double), and
/// `allowEndpointFailover: false` keeps a dead endpoint from walking the
/// candidate list while the user holds a long-press. On expiry the request is
/// aborted rather than left running, and the timeout propagates so the caller
/// fails closed.
// No `@override`: like [fetchSeasonEpisodesPage], this satisfies an optional
// capability interface that the concrete client implements, not a member of
// the mixin's superclass constraint.
Future<bool?> fetchDeletePermission(MediaItem item) async {
final abort = AbortController();
try {
final response = await _http
.get(
'/Items',
queryParameters: {
'ids': item.id,
'userId': connection.userId,
'Fields': 'CanDelete',
'EnableImages': 'false',
'EnableUserData': 'false',
'EnableTotalRecordCount': 'false',
},
timeout: MediaServerTimeouts.jellyfinDeletePermission,
abort: abort,
allowEndpointFailover: false,
)
.namedTimeout(MediaServerTimeouts.jellyfinDeletePermission, operation: 'jellyfin delete permission');
throwIfHttpError(response);
final items = _itemsArray(response.data);
if (items.isEmpty) return false;
return items.first['CanDelete'] as bool?;
} on TimeoutException catch (e) {
// Stop the request rather than leave it running, and hand the caller the
// same exception shape `MediaServerHttpClient` raises for its own
// per-phase expiries.
abort.abort();
throw MediaServerHttpException.from(e);
}
}
}