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
This commit is contained in:
@@ -760,6 +760,27 @@ abstract interface class SeasonEpisodePagingClient {
|
||||
});
|
||||
}
|
||||
|
||||
/// Optional capability for clients whose server can answer "may the signed-in
|
||||
/// user delete *this* item?" per item.
|
||||
///
|
||||
/// Jellyfin-only by nature: `BaseItemDto.CanDelete` folds the global
|
||||
/// `EnableContentDeletion` grant, the per-library
|
||||
/// `EnableContentDeletionFromFolders` grant, and item state (virtual/missing
|
||||
/// files, in-progress recordings) into one server-computed boolean — none of
|
||||
/// which a client can reproduce. Plex exposes no per-item delete permission,
|
||||
/// so it deliberately does not implement this and callers keep using their
|
||||
/// account-level owner/admin gate for it.
|
||||
abstract interface class MediaDeletionPermissionClient {
|
||||
/// `true`/`false` as reported by the server for [item], or `null` when the
|
||||
/// server did not answer (item not visible to this user, unexpected shape).
|
||||
///
|
||||
/// Never served from cache: the answer changes server-side with no
|
||||
/// client-visible event, and a stale `true` puts a destructive action back in
|
||||
/// front of a user who lost the grant. Callers must fail closed on `null`
|
||||
/// and on throw.
|
||||
Future<bool?> fetchDeletePermission(MediaItem item);
|
||||
}
|
||||
|
||||
/// Cache-aware fetch helpers shared by both backends so the offline-first /
|
||||
/// network-then-cache pattern lives in one place.
|
||||
///
|
||||
|
||||
@@ -39,6 +39,7 @@ import '../utils/app_logger.dart';
|
||||
import '../utils/device_identity.dart';
|
||||
import '../utils/failover_http_client.dart';
|
||||
import '../utils/media_server_retry.dart';
|
||||
import '../utils/future_extensions.dart';
|
||||
import '../utils/media_server_timeouts.dart';
|
||||
import '../utils/log_redaction_manager.dart';
|
||||
import '../utils/external_ids.dart';
|
||||
@@ -111,7 +112,12 @@ class JellyfinClient
|
||||
_JellyfinLiveTvMethods,
|
||||
_JellyfinImageDownloadMethods,
|
||||
_JellyfinMetadataEditMethods
|
||||
implements MediaServerClient, SeasonEpisodePagingClient, ScopedMediaServerClient, GracefullyCloseable {
|
||||
implements
|
||||
MediaServerClient,
|
||||
SeasonEpisodePagingClient,
|
||||
MediaDeletionPermissionClient,
|
||||
ScopedMediaServerClient,
|
||||
GracefullyCloseable {
|
||||
JellyfinClient._({required this._connection, required this._http, FavoriteChannelsRepository? favoritesRepository})
|
||||
: _favoritesRepository = favoritesRepository ?? const SharedPreferencesFavoriteChannelsRepository();
|
||||
|
||||
|
||||
@@ -144,4 +144,54 @@ mixin _JellyfinCollectionMethods on _JellyfinClientInternals {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,6 +52,15 @@ class MediaServerTimeouts {
|
||||
/// `/System/Info/Public` and `/Users/Me`.
|
||||
static const jellyfinProbe = Duration(seconds: 8);
|
||||
|
||||
/// Per-item delete-permission probe. Shorter than [jellyfinProbe] because it
|
||||
/// blocks a context menu from opening: a server that is nominally online but
|
||||
/// hung must not hold the menu for a health-sweep budget. Unlike the other
|
||||
/// values here it is also applied as a whole-request deadline by the caller
|
||||
/// (the per-request budget covers the connect and receive phases
|
||||
/// individually), and expiry fails closed — no delete entry — so the ceiling
|
||||
/// only ever costs an entry, never safety.
|
||||
static const jellyfinDeletePermission = Duration(seconds: 3);
|
||||
|
||||
/// Best-effort `/Sessions/Logout` timeout — short because the call is
|
||||
/// fire-and-forget; the token is removed locally regardless.
|
||||
static const jellyfinSignOut = Duration(seconds: 5);
|
||||
|
||||
@@ -82,6 +82,30 @@ bool isAdminActionAllowedForMediaItem({
|
||||
return isOwnerOrAdmin && !blockedByPlexHomeRole;
|
||||
}
|
||||
|
||||
/// Whether "Delete from server" may be offered for an item.
|
||||
///
|
||||
/// Deliberately not folded into [isAdminActionAllowedForMediaItem]: on Jellyfin
|
||||
/// the admin bit says nothing about deletion. `BaseItem.IsAuthorizedToDelete`
|
||||
/// consults `EnableContentDeletion` and the per-library grant only, and only
|
||||
/// the auto-created first user gets the former for free — so an administrator
|
||||
/// can lack the right (issue #1749) and a plain user can hold it. The server's
|
||||
/// per-item answer ([resolvedItemPermission], from
|
||||
/// [MediaDeletionPermissionClient]) is therefore the sole Jellyfin condition,
|
||||
/// and anything unknown — offline, request failed, timed out, item invisible —
|
||||
/// stays hidden rather than offering a button that 401s.
|
||||
///
|
||||
/// Plex has no per-item permission on the wire, so it keeps the account-level
|
||||
/// owner/admin gate.
|
||||
bool isMediaDeletionAllowed({
|
||||
required MediaBackend? itemBackend,
|
||||
required bool? resolvedItemPermission,
|
||||
required bool isAdminActionAllowed,
|
||||
}) => switch (itemBackend) {
|
||||
null => false,
|
||||
MediaBackend.jellyfin => resolvedItemPermission == true,
|
||||
MediaBackend.plex => isAdminActionAllowed,
|
||||
};
|
||||
|
||||
/// A reusable wrapper widget that adds a context menu (long press / right click)
|
||||
/// to any media item with appropriate actions based on the item type.
|
||||
/// Caller-supplied entry appended to a [MediaContextMenu] (e.g. the
|
||||
@@ -207,6 +231,35 @@ class MediaContextMenuState extends State<MediaContextMenu> {
|
||||
/// that work for Jellyfin too (downloads, basic browse).
|
||||
MediaServerClient _getMediaClientForItem() => context.getMediaClientWithFallback(serverIdOrNull(_itemServerId));
|
||||
|
||||
/// Ask the server whether the signed-in user may delete [item] right now.
|
||||
///
|
||||
/// Returns `null` when the backend exposes no per-item permission (Plex),
|
||||
/// which leaves the account-level gate in charge, and `false` for every
|
||||
/// unknown on a backend that does expose one — offline, server down, request
|
||||
/// failed or timed out. The probe blocks the menu opening, so it is bounded
|
||||
/// by `MediaServerTimeouts.jellyfinDeletePermission` and does not chase
|
||||
/// failover endpoints: a stalled endpoint hunt would be felt as a frozen
|
||||
/// long-press, and hiding one entry is the cheaper failure.
|
||||
///
|
||||
/// Backend detection comes first so a menu on a backend without the
|
||||
/// capability neither probes nor touches offline state — the read would
|
||||
/// otherwise be a new dependency for every screen that shows a movie row.
|
||||
Future<bool?> _resolveDeletePermission({
|
||||
required MediaServerClient? client,
|
||||
required MediaItem? item,
|
||||
required bool serverOnline,
|
||||
}) async {
|
||||
final permissionClient = client is MediaDeletionPermissionClient ? client as MediaDeletionPermissionClient : null;
|
||||
if (item == null || permissionClient == null) return null;
|
||||
if (!serverOnline || context.read<OfflineModeProvider>().isOffline) return false;
|
||||
try {
|
||||
return await permissionClient.fetchDeletePermission(item);
|
||||
} catch (e, st) {
|
||||
appLogger.w('Delete permission probe failed', error: e, stackTrace: st);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
void _showContextMenu(BuildContext context) async {
|
||||
if (_isContextMenuOpen) return;
|
||||
_isContextMenuOpen = true;
|
||||
@@ -260,6 +313,32 @@ class MediaContextMenuState extends State<MediaContextMenu> {
|
||||
final canRemoveFromContinueWatching = mediaClient?.capabilities.continueWatchingRemoval ?? false;
|
||||
final canEditMetadata = isAdmin && supportsMetadataEdit(mediaClient, mediaKind);
|
||||
|
||||
// Deletion is the one gate that asks the server per item; see
|
||||
// [isMediaDeletionAllowed]. Only kinds that can actually be deleted pay
|
||||
// for the round trip, and only on a backend that answers it.
|
||||
final isDeletableKind =
|
||||
mediaKind == MediaKind.episode ||
|
||||
mediaKind == MediaKind.movie ||
|
||||
mediaKind == MediaKind.show ||
|
||||
mediaKind == MediaKind.season;
|
||||
final canDeleteFromServer =
|
||||
isDeletableKind &&
|
||||
isMediaDeletionAllowed(
|
||||
itemBackend: itemBackend,
|
||||
resolvedItemPermission: await _resolveDeletePermission(
|
||||
client: mediaClient,
|
||||
item: mediaItem,
|
||||
serverOnline: itemServerOnline,
|
||||
),
|
||||
isAdminActionAllowed: isAdmin,
|
||||
);
|
||||
if (!mounted || !context.mounted) {
|
||||
// The awaited probe outlived the widget; the try/finally that normally
|
||||
// clears this flag only starts once the menu is on screen.
|
||||
_isContextMenuOpen = false;
|
||||
return;
|
||||
}
|
||||
|
||||
final menuActions = <_MenuAction>[];
|
||||
|
||||
if (isCollection || isPlaylist) {
|
||||
@@ -547,15 +626,12 @@ class MediaContextMenuState extends State<MediaContextMenu> {
|
||||
menuActions.add(_MenuAction(value: 'add_to', icon: Symbols.add_rounded, label: t.common.addTo));
|
||||
}
|
||||
|
||||
// Delete media item (for episodes, movies, shows, and seasons) — admin
|
||||
// only. Backend-neutral: routed through `MediaServerClient.deleteMediaItem`,
|
||||
// which both Plex and Jellyfin implement (DELETE /library/metadata/{id}
|
||||
// and DELETE /Items/{id} respectively).
|
||||
if (isAdmin &&
|
||||
(mediaKind == MediaKind.episode ||
|
||||
mediaKind == MediaKind.movie ||
|
||||
mediaKind == MediaKind.show ||
|
||||
mediaKind == MediaKind.season)) {
|
||||
// Delete media item (for episodes, movies, shows, and seasons). Routed
|
||||
// through `MediaServerClient.deleteMediaItem`, which both Plex and
|
||||
// Jellyfin implement (DELETE /library/metadata/{id} and
|
||||
// DELETE /Items/{id} respectively); the kind and permission checks were
|
||||
// resolved together above.
|
||||
if (canDeleteFromServer) {
|
||||
menuActions.add(
|
||||
_MenuAction(
|
||||
value: 'delete_media',
|
||||
|
||||
Reference in New Issue
Block a user