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 /
|
/// Cache-aware fetch helpers shared by both backends so the offline-first /
|
||||||
/// network-then-cache pattern lives in one place.
|
/// network-then-cache pattern lives in one place.
|
||||||
///
|
///
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ import '../utils/app_logger.dart';
|
|||||||
import '../utils/device_identity.dart';
|
import '../utils/device_identity.dart';
|
||||||
import '../utils/failover_http_client.dart';
|
import '../utils/failover_http_client.dart';
|
||||||
import '../utils/media_server_retry.dart';
|
import '../utils/media_server_retry.dart';
|
||||||
|
import '../utils/future_extensions.dart';
|
||||||
import '../utils/media_server_timeouts.dart';
|
import '../utils/media_server_timeouts.dart';
|
||||||
import '../utils/log_redaction_manager.dart';
|
import '../utils/log_redaction_manager.dart';
|
||||||
import '../utils/external_ids.dart';
|
import '../utils/external_ids.dart';
|
||||||
@@ -111,7 +112,12 @@ class JellyfinClient
|
|||||||
_JellyfinLiveTvMethods,
|
_JellyfinLiveTvMethods,
|
||||||
_JellyfinImageDownloadMethods,
|
_JellyfinImageDownloadMethods,
|
||||||
_JellyfinMetadataEditMethods
|
_JellyfinMetadataEditMethods
|
||||||
implements MediaServerClient, SeasonEpisodePagingClient, ScopedMediaServerClient, GracefullyCloseable {
|
implements
|
||||||
|
MediaServerClient,
|
||||||
|
SeasonEpisodePagingClient,
|
||||||
|
MediaDeletionPermissionClient,
|
||||||
|
ScopedMediaServerClient,
|
||||||
|
GracefullyCloseable {
|
||||||
JellyfinClient._({required this._connection, required this._http, FavoriteChannelsRepository? favoritesRepository})
|
JellyfinClient._({required this._connection, required this._http, FavoriteChannelsRepository? favoritesRepository})
|
||||||
: _favoritesRepository = favoritesRepository ?? const SharedPreferencesFavoriteChannelsRepository();
|
: _favoritesRepository = favoritesRepository ?? const SharedPreferencesFavoriteChannelsRepository();
|
||||||
|
|
||||||
|
|||||||
@@ -144,4 +144,54 @@ mixin _JellyfinCollectionMethods on _JellyfinClientInternals {
|
|||||||
throwIfHttpError(response);
|
throwIfHttpError(response);
|
||||||
return true;
|
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`.
|
/// `/System/Info/Public` and `/Users/Me`.
|
||||||
static const jellyfinProbe = Duration(seconds: 8);
|
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
|
/// Best-effort `/Sessions/Logout` timeout — short because the call is
|
||||||
/// fire-and-forget; the token is removed locally regardless.
|
/// fire-and-forget; the token is removed locally regardless.
|
||||||
static const jellyfinSignOut = Duration(seconds: 5);
|
static const jellyfinSignOut = Duration(seconds: 5);
|
||||||
|
|||||||
@@ -82,6 +82,30 @@ bool isAdminActionAllowedForMediaItem({
|
|||||||
return isOwnerOrAdmin && !blockedByPlexHomeRole;
|
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)
|
/// 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.
|
/// to any media item with appropriate actions based on the item type.
|
||||||
/// Caller-supplied entry appended to a [MediaContextMenu] (e.g. the
|
/// 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).
|
/// that work for Jellyfin too (downloads, basic browse).
|
||||||
MediaServerClient _getMediaClientForItem() => context.getMediaClientWithFallback(serverIdOrNull(_itemServerId));
|
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 {
|
void _showContextMenu(BuildContext context) async {
|
||||||
if (_isContextMenuOpen) return;
|
if (_isContextMenuOpen) return;
|
||||||
_isContextMenuOpen = true;
|
_isContextMenuOpen = true;
|
||||||
@@ -260,6 +313,32 @@ class MediaContextMenuState extends State<MediaContextMenu> {
|
|||||||
final canRemoveFromContinueWatching = mediaClient?.capabilities.continueWatchingRemoval ?? false;
|
final canRemoveFromContinueWatching = mediaClient?.capabilities.continueWatchingRemoval ?? false;
|
||||||
final canEditMetadata = isAdmin && supportsMetadataEdit(mediaClient, mediaKind);
|
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>[];
|
final menuActions = <_MenuAction>[];
|
||||||
|
|
||||||
if (isCollection || isPlaylist) {
|
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));
|
menuActions.add(_MenuAction(value: 'add_to', icon: Symbols.add_rounded, label: t.common.addTo));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete media item (for episodes, movies, shows, and seasons) — admin
|
// Delete media item (for episodes, movies, shows, and seasons). Routed
|
||||||
// only. Backend-neutral: routed through `MediaServerClient.deleteMediaItem`,
|
// through `MediaServerClient.deleteMediaItem`, which both Plex and
|
||||||
// which both Plex and Jellyfin implement (DELETE /library/metadata/{id}
|
// Jellyfin implement (DELETE /library/metadata/{id} and
|
||||||
// and DELETE /Items/{id} respectively).
|
// DELETE /Items/{id} respectively); the kind and permission checks were
|
||||||
if (isAdmin &&
|
// resolved together above.
|
||||||
(mediaKind == MediaKind.episode ||
|
if (canDeleteFromServer) {
|
||||||
mediaKind == MediaKind.movie ||
|
|
||||||
mediaKind == MediaKind.show ||
|
|
||||||
mediaKind == MediaKind.season)) {
|
|
||||||
menuActions.add(
|
menuActions.add(
|
||||||
_MenuAction(
|
_MenuAction(
|
||||||
value: 'delete_media',
|
value: 'delete_media',
|
||||||
|
|||||||
@@ -0,0 +1,190 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:fake_async/fake_async.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
import 'package:http/testing.dart';
|
||||||
|
import 'package:plezy/exceptions/media_server_exceptions.dart';
|
||||||
|
import 'package:plezy/media/media_backend.dart';
|
||||||
|
import 'package:plezy/media/media_item.dart';
|
||||||
|
import 'package:plezy/media/media_kind.dart';
|
||||||
|
import 'package:plezy/media/media_server_client.dart';
|
||||||
|
import 'package:plezy/services/jellyfin_client.dart';
|
||||||
|
import 'package:plezy/utils/media_server_timeouts.dart';
|
||||||
|
|
||||||
|
import '../test_helpers/backend_client_fixtures.dart';
|
||||||
|
import '../test_helpers/http_fixtures.dart';
|
||||||
|
import '../test_helpers/media_items.dart';
|
||||||
|
|
||||||
|
/// `CanDelete` is what the server itself checks before honouring
|
||||||
|
/// `DELETE /Items/{id}` (`BaseItem.CanDelete(user)`), so it folds the global
|
||||||
|
/// `EnableContentDeletion` grant, the per-library grant, and item state. The
|
||||||
|
/// client must report it verbatim and never invent an answer.
|
||||||
|
void main() {
|
||||||
|
final item = testMediaItem(
|
||||||
|
id: 'movie-1',
|
||||||
|
backend: MediaBackend.jellyfin,
|
||||||
|
kind: MediaKind.movie,
|
||||||
|
title: 'Movie',
|
||||||
|
serverId: 'srv-1',
|
||||||
|
);
|
||||||
|
|
||||||
|
({JellyfinClient client, List<Uri> requests}) clientAnswering(
|
||||||
|
Future<http.Response> Function(http.Request request) handler,
|
||||||
|
) {
|
||||||
|
final requests = <Uri>[];
|
||||||
|
final client = JellyfinClient.forTesting(
|
||||||
|
connection: testJellyfinConnection(),
|
||||||
|
httpClient: MockClient((request) async {
|
||||||
|
requests.add(request.url);
|
||||||
|
return handler(request);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
addTearDown(client.close);
|
||||||
|
return (client: client, requests: requests);
|
||||||
|
}
|
||||||
|
|
||||||
|
group('JellyfinClient.fetchDeletePermission', () {
|
||||||
|
test('asks the list endpoint for CanDelete only, scoped to the item and user', () async {
|
||||||
|
final fake = clientAnswering(
|
||||||
|
(_) async => jsonResponse({
|
||||||
|
'Items': [
|
||||||
|
{'Id': 'movie-1', 'CanDelete': true},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await (fake.client as MediaDeletionPermissionClient).fetchDeletePermission(item);
|
||||||
|
|
||||||
|
final uri = fake.requests.single;
|
||||||
|
// `/Items?ids=` and not `/Users/{id}/Items/{id}`: the single-item route
|
||||||
|
// ignores `Fields` and ships the whole dto.
|
||||||
|
expect(uri.path, '/Items');
|
||||||
|
expect(uri.queryParameters['ids'], 'movie-1');
|
||||||
|
expect(uri.queryParameters['userId'], 'user-1');
|
||||||
|
expect(uri.queryParameters['Fields'], 'CanDelete');
|
||||||
|
expect(uri.queryParameters['EnableImages'], 'false');
|
||||||
|
expect(uri.queryParameters['EnableUserData'], 'false');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('reports the server answer for a permitted and a rejected item', () async {
|
||||||
|
final permitted = clientAnswering(
|
||||||
|
(_) async => jsonResponse({
|
||||||
|
'Items': [
|
||||||
|
{'Id': 'movie-1', 'CanDelete': true},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
final rejected = clientAnswering(
|
||||||
|
(_) async => jsonResponse({
|
||||||
|
'Items': [
|
||||||
|
{'Id': 'movie-1', 'CanDelete': false},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(await (permitted.client as MediaDeletionPermissionClient).fetchDeletePermission(item), isTrue);
|
||||||
|
expect(await (rejected.client as MediaDeletionPermissionClient).fetchDeletePermission(item), isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('treats an item the user cannot see as not deletable', () async {
|
||||||
|
final fake = clientAnswering((_) async => jsonResponse({'Items': <Object>[]}));
|
||||||
|
|
||||||
|
expect(await (fake.client as MediaDeletionPermissionClient).fetchDeletePermission(item), isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns null when the server omits CanDelete', () async {
|
||||||
|
// Old servers, or a future one that stops honouring the field: unknown is
|
||||||
|
// not "allowed", and the caller fails closed on null.
|
||||||
|
final fake = clientAnswering(
|
||||||
|
(_) async => jsonResponse({
|
||||||
|
'Items': [
|
||||||
|
{'Id': 'movie-1'},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(await (fake.client as MediaDeletionPermissionClient).fetchDeletePermission(item), isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('throws on an error response instead of reporting a permission', () async {
|
||||||
|
final fake = clientAnswering((_) async => http.Response('nope', 401));
|
||||||
|
|
||||||
|
await expectLater(
|
||||||
|
(fake.client as MediaDeletionPermissionClient).fetchDeletePermission(item),
|
||||||
|
throwsA(isA<MediaServerHttpException>().having((e) => e.statusCode, 'statusCode', 401)),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('gives up on a server that never answers', () {
|
||||||
|
fakeAsync((async) {
|
||||||
|
final client = JellyfinClient.forTesting(
|
||||||
|
connection: testJellyfinConnection(),
|
||||||
|
httpClient: MockClient((_) => Completer<http.Response>().future),
|
||||||
|
);
|
||||||
|
addTearDown(client.close);
|
||||||
|
|
||||||
|
final failure = _failureOf(client, item);
|
||||||
|
|
||||||
|
async.elapse(MediaServerTimeouts.jellyfinDeletePermission - const Duration(milliseconds: 1));
|
||||||
|
expect(failure(), isNull);
|
||||||
|
|
||||||
|
async.elapse(const Duration(milliseconds: 2));
|
||||||
|
expect(failure(), _isProbeTimeout);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('caps a slow connect followed by a stalled body at the whole-probe deadline', () {
|
||||||
|
// The regression this guards: the client applies its per-request budget
|
||||||
|
// to the connect and the receive phase separately, so a server that
|
||||||
|
// answers late and then stops sending would hold the context menu for
|
||||||
|
// roughly twice the budget without the caller-side deadline.
|
||||||
|
fakeAsync((async) {
|
||||||
|
final body = StreamController<List<int>>();
|
||||||
|
addTearDown(body.close);
|
||||||
|
final client = JellyfinClient.forTesting(
|
||||||
|
connection: testJellyfinConnection(),
|
||||||
|
httpClient: MockClient.streaming((_, _) async {
|
||||||
|
await Future<void>.delayed(MediaServerTimeouts.jellyfinDeletePermission - const Duration(seconds: 1));
|
||||||
|
return http.StreamedResponse(body.stream, 200, headers: const {'content-type': 'application/json'});
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
addTearDown(client.close);
|
||||||
|
|
||||||
|
final failure = _failureOf(client, item);
|
||||||
|
|
||||||
|
async.elapse(MediaServerTimeouts.jellyfinDeletePermission - const Duration(milliseconds: 1));
|
||||||
|
expect(failure(), isNull, reason: 'the response arrived in time; only the body is stalled');
|
||||||
|
|
||||||
|
async.elapse(const Duration(milliseconds: 2));
|
||||||
|
expect(failure(), _isProbeTimeout);
|
||||||
|
expect(async.elapsed, lessThan(MediaServerTimeouts.jellyfinDeletePermission * 2));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Starts the probe and hands back a getter for whatever it failed with, so a
|
||||||
|
/// [fakeAsync] body can advance the clock and inspect the outcome without
|
||||||
|
/// awaiting inside the zone.
|
||||||
|
Object? Function() _failureOf(JellyfinClient client, MediaItem item) {
|
||||||
|
Object? failure;
|
||||||
|
(client as MediaDeletionPermissionClient)
|
||||||
|
.fetchDeletePermission(item)
|
||||||
|
.then<void>(
|
||||||
|
(_) {},
|
||||||
|
onError: (Object e) {
|
||||||
|
failure = e;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return () => failure;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Both stalls must surface as the canonical media-server failure, the same
|
||||||
|
/// shape `MediaServerHttpClient` raises for its own per-phase expiries, so
|
||||||
|
/// callers need one catch and not two.
|
||||||
|
final _isProbeTimeout = isA<MediaServerHttpException>().having(
|
||||||
|
(e) => e.type,
|
||||||
|
'type',
|
||||||
|
MediaServerHttpErrorType.connectionTimeout,
|
||||||
|
);
|
||||||
@@ -27,6 +27,7 @@ import 'package:plezy/profiles/profile.dart';
|
|||||||
import 'package:plezy/profiles/active_profile_provider.dart';
|
import 'package:plezy/profiles/active_profile_provider.dart';
|
||||||
import 'package:plezy/providers/download_provider.dart';
|
import 'package:plezy/providers/download_provider.dart';
|
||||||
import 'package:plezy/providers/multi_server_provider.dart';
|
import 'package:plezy/providers/multi_server_provider.dart';
|
||||||
|
import 'package:plezy/providers/offline_mode_provider.dart';
|
||||||
import 'package:plezy/providers/playback_state_provider.dart';
|
import 'package:plezy/providers/playback_state_provider.dart';
|
||||||
import 'package:plezy/screens/music/album_detail_screen.dart';
|
import 'package:plezy/screens/music/album_detail_screen.dart';
|
||||||
import 'package:plezy/screens/music/artist_detail_screen.dart';
|
import 'package:plezy/screens/music/artist_detail_screen.dart';
|
||||||
@@ -40,12 +41,14 @@ import 'package:plezy/services/plex_api_cache.dart';
|
|||||||
import 'package:plezy/services/settings_service.dart';
|
import 'package:plezy/services/settings_service.dart';
|
||||||
import 'package:plezy/theme/mono_theme.dart';
|
import 'package:plezy/theme/mono_theme.dart';
|
||||||
import 'package:plezy/utils/media_server_http_client.dart';
|
import 'package:plezy/utils/media_server_http_client.dart';
|
||||||
|
import 'package:plezy/utils/media_server_timeouts.dart';
|
||||||
import 'package:plezy/utils/platform_detector.dart';
|
import 'package:plezy/utils/platform_detector.dart';
|
||||||
import 'package:plezy/widgets/media_context_menu.dart';
|
import 'package:plezy/widgets/media_context_menu.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import '../test_helpers/backend_client_fixtures.dart';
|
import '../test_helpers/backend_client_fixtures.dart';
|
||||||
import '../test_helpers/media_items.dart';
|
import '../test_helpers/media_items.dart';
|
||||||
import '../test_helpers/multi_server_fixtures.dart';
|
import '../test_helpers/multi_server_fixtures.dart';
|
||||||
|
import '../test_helpers/http_fixtures.dart';
|
||||||
import '../test_helpers/prefs.dart';
|
import '../test_helpers/prefs.dart';
|
||||||
import '../test_helpers/profile_stack.dart';
|
import '../test_helpers/profile_stack.dart';
|
||||||
import '../test_helpers/stub_music_playback_service.dart';
|
import '../test_helpers/stub_music_playback_service.dart';
|
||||||
@@ -86,6 +89,173 @@ void main() {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
group('isMediaDeletionAllowed', () {
|
||||||
|
test('Jellyfin follows the server answer, not the admin bit', () {
|
||||||
|
// Jellyfin's own check (`BaseItem.IsAuthorizedToDelete`) never consults
|
||||||
|
// `IsAdministrator` for library items, and only the first user created on
|
||||||
|
// a server gets `EnableContentDeletion` for free.
|
||||||
|
expect(
|
||||||
|
isMediaDeletionAllowed(
|
||||||
|
itemBackend: MediaBackend.jellyfin,
|
||||||
|
resolvedItemPermission: false,
|
||||||
|
isAdminActionAllowed: true,
|
||||||
|
),
|
||||||
|
isFalse,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
isMediaDeletionAllowed(
|
||||||
|
itemBackend: MediaBackend.jellyfin,
|
||||||
|
resolvedItemPermission: true,
|
||||||
|
isAdminActionAllowed: false,
|
||||||
|
),
|
||||||
|
isTrue,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Jellyfin fails closed when the permission is unknown', () {
|
||||||
|
expect(
|
||||||
|
isMediaDeletionAllowed(
|
||||||
|
itemBackend: MediaBackend.jellyfin,
|
||||||
|
resolvedItemPermission: null,
|
||||||
|
isAdminActionAllowed: true,
|
||||||
|
),
|
||||||
|
isFalse,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Plex keeps its account-level gate', () {
|
||||||
|
expect(
|
||||||
|
isMediaDeletionAllowed(
|
||||||
|
itemBackend: MediaBackend.plex,
|
||||||
|
resolvedItemPermission: null,
|
||||||
|
isAdminActionAllowed: true,
|
||||||
|
),
|
||||||
|
isTrue,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
isMediaDeletionAllowed(
|
||||||
|
itemBackend: MediaBackend.plex,
|
||||||
|
resolvedItemPermission: null,
|
||||||
|
isAdminActionAllowed: false,
|
||||||
|
),
|
||||||
|
isFalse,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an item with no backend marker is never deletable', () {
|
||||||
|
expect(
|
||||||
|
isMediaDeletionAllowed(itemBackend: null, resolvedItemPermission: true, isAdminActionAllowed: true),
|
||||||
|
isFalse,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('MediaContextMenu delete gate', () {
|
||||||
|
testWidgets('hides delete for an administrator the server refuses (issue #1749)', (tester) async {
|
||||||
|
final menuKey = await _pumpJellyfinMovieMenu(
|
||||||
|
tester,
|
||||||
|
isAdministrator: true,
|
||||||
|
handler: (_) async => _canDeleteResponse('movie-1', false),
|
||||||
|
);
|
||||||
|
|
||||||
|
await _openMenu(tester, menuKey);
|
||||||
|
|
||||||
|
expect(find.text(t.mediaMenu.deleteFromServer), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('shows delete for a non-admin the server permits', (tester) async {
|
||||||
|
final requests = <Uri>[];
|
||||||
|
final menuKey = await _pumpJellyfinMovieMenu(
|
||||||
|
tester,
|
||||||
|
isAdministrator: false,
|
||||||
|
requests: requests,
|
||||||
|
handler: (_) async => _canDeleteResponse('movie-1', true),
|
||||||
|
);
|
||||||
|
|
||||||
|
await _openMenu(tester, menuKey);
|
||||||
|
|
||||||
|
expect(find.text(t.mediaMenu.deleteFromServer), findsOneWidget);
|
||||||
|
final probes = requests.where((uri) => uri.queryParameters['Fields'] == 'CanDelete').toList();
|
||||||
|
expect(probes, hasLength(1));
|
||||||
|
expect(probes.single.queryParameters['ids'], 'movie-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('shows delete for a library-granted user inside the grant', (tester) async {
|
||||||
|
final menuKey = await _pumpJellyfinMovieMenu(
|
||||||
|
tester,
|
||||||
|
isAdministrator: false,
|
||||||
|
itemId: 'movie-in-grant',
|
||||||
|
handler: _libraryGrantedToOneMovie,
|
||||||
|
);
|
||||||
|
|
||||||
|
await _openMenu(tester, menuKey);
|
||||||
|
|
||||||
|
expect(find.text(t.mediaMenu.deleteFromServer), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('hides delete for a library-granted user outside the grant', (tester) async {
|
||||||
|
final menuKey = await _pumpJellyfinMovieMenu(
|
||||||
|
tester,
|
||||||
|
isAdministrator: false,
|
||||||
|
itemId: 'movie-outside-grant',
|
||||||
|
handler: _libraryGrantedToOneMovie,
|
||||||
|
);
|
||||||
|
|
||||||
|
await _openMenu(tester, menuKey);
|
||||||
|
|
||||||
|
expect(find.text(t.mediaMenu.deleteFromServer), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('hides delete when the permission probe fails', (tester) async {
|
||||||
|
final menuKey = await _pumpJellyfinMovieMenu(
|
||||||
|
tester,
|
||||||
|
isAdministrator: true,
|
||||||
|
handler: (_) async => http.Response('boom', 500),
|
||||||
|
);
|
||||||
|
|
||||||
|
await _openMenu(tester, menuKey);
|
||||||
|
|
||||||
|
expect(find.text(t.mediaMenu.deleteFromServer), findsNothing);
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
// The menu itself still opened; only the destructive entry is missing.
|
||||||
|
expect(find.text(t.mediaMenu.fileInfo), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('hides delete without probing while the item server is offline', (tester) async {
|
||||||
|
final requests = <Uri>[];
|
||||||
|
final menuKey = await _pumpJellyfinMovieMenu(
|
||||||
|
tester,
|
||||||
|
isAdministrator: true,
|
||||||
|
serverOnline: false,
|
||||||
|
requests: requests,
|
||||||
|
handler: (_) async => _canDeleteResponse('movie-1', true),
|
||||||
|
);
|
||||||
|
|
||||||
|
await _openMenu(tester, menuKey);
|
||||||
|
|
||||||
|
expect(find.text(t.mediaMenu.deleteFromServer), findsNothing);
|
||||||
|
expect(requests.where((uri) => uri.queryParameters['Fields'] == 'CanDelete'), isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('still opens the menu, without delete, when the probe hangs', (tester) async {
|
||||||
|
final menuKey = await _pumpJellyfinMovieMenu(
|
||||||
|
tester,
|
||||||
|
isAdministrator: true,
|
||||||
|
handler: (_) => Completer<http.Response>().future,
|
||||||
|
);
|
||||||
|
|
||||||
|
menuKey.currentState!.showContextMenu(tester.element(find.text('delete target')));
|
||||||
|
await tester.pump();
|
||||||
|
expect(find.text(t.mediaMenu.fileInfo), findsNothing, reason: 'the menu waits for the permission answer');
|
||||||
|
|
||||||
|
await tester.pump(MediaServerTimeouts.jellyfinDeletePermission + const Duration(milliseconds: 1));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text(t.mediaMenu.fileInfo), findsOneWidget);
|
||||||
|
expect(find.text(t.mediaMenu.deleteFromServer), findsNothing);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
group('supportsMetadataEdit', () {
|
group('supportsMetadataEdit', () {
|
||||||
test('allows Jellyfin video metadata edit through capability gate', () {
|
test('allows Jellyfin video metadata edit through capability gate', () {
|
||||||
final client = JellyfinClient.forTesting(
|
final client = JellyfinClient.forTesting(
|
||||||
@@ -612,6 +782,89 @@ Future<GlobalKey<MediaContextMenuState>> _pumpPlexMovieMenu(
|
|||||||
return menuKey;
|
return menuKey;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Jellyfin answer for the per-item delete-permission probe.
|
||||||
|
http.Response _canDeleteResponse(String id, bool canDelete) => jsonResponse({
|
||||||
|
'Items': [
|
||||||
|
{'Id': id, 'CanDelete': canDelete},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
/// A user holding `EnableContentDeletionFromFolders` for one library only: the
|
||||||
|
/// server answers per item, which is the only way that grant reaches a client.
|
||||||
|
Future<http.Response> _libraryGrantedToOneMovie(http.Request request) async {
|
||||||
|
final id = request.url.queryParameters['ids'] ?? '';
|
||||||
|
return _canDeleteResponse(id, id == 'movie-in-grant');
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<GlobalKey<MediaContextMenuState>> _pumpJellyfinMovieMenu(
|
||||||
|
WidgetTester tester, {
|
||||||
|
required bool isAdministrator,
|
||||||
|
required Future<http.Response> Function(http.Request request) handler,
|
||||||
|
String itemId = 'movie-1',
|
||||||
|
bool serverOnline = true,
|
||||||
|
List<Uri>? requests,
|
||||||
|
}) async {
|
||||||
|
LocaleSettings.setLocaleSync(AppLocale.en);
|
||||||
|
TvDetectionService.debugSetAppleTVOverride(true);
|
||||||
|
addTearDown(() => TvDetectionService.debugSetAppleTVOverride(null));
|
||||||
|
|
||||||
|
final client = JellyfinClient.forTesting(
|
||||||
|
connection: testJellyfinConnection(isAdministrator: isAdministrator),
|
||||||
|
httpClient: MockClient((request) async {
|
||||||
|
requests?.add(request.url);
|
||||||
|
return handler(request);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
final manager = MultiServerManager()..debugRegisterJellyfinClientForTesting(client, online: serverOnline);
|
||||||
|
final multiServerProvider = testMultiServerProvider(manager);
|
||||||
|
final offlineMode = OfflineModeProvider(manager);
|
||||||
|
final stack = await ProfileStack.create(withStorage: false);
|
||||||
|
addTearDown(() async {
|
||||||
|
await stack.dispose();
|
||||||
|
offlineMode.dispose();
|
||||||
|
multiServerProvider.dispose();
|
||||||
|
manager.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
final menuKey = GlobalKey<MediaContextMenuState>();
|
||||||
|
final item = testMediaItem(
|
||||||
|
id: itemId,
|
||||||
|
backend: MediaBackend.jellyfin,
|
||||||
|
kind: MediaKind.movie,
|
||||||
|
title: 'Movie',
|
||||||
|
serverId: 'srv-1',
|
||||||
|
);
|
||||||
|
await tester.pumpWidget(
|
||||||
|
TranslationProvider(
|
||||||
|
child: MultiProvider(
|
||||||
|
providers: [
|
||||||
|
ChangeNotifierProvider<MultiServerProvider>.value(value: multiServerProvider),
|
||||||
|
ChangeNotifierProvider<ActiveProfileProvider>.value(value: stack.active),
|
||||||
|
ChangeNotifierProvider<OfflineModeProvider>.value(value: offlineMode),
|
||||||
|
],
|
||||||
|
child: MaterialApp(
|
||||||
|
theme: monoTheme(dark: true),
|
||||||
|
home: Scaffold(
|
||||||
|
body: Center(
|
||||||
|
child: MediaContextMenu(
|
||||||
|
key: menuKey,
|
||||||
|
item: item,
|
||||||
|
child: const SizedBox(width: 120, height: 80, child: Text('delete target')),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return menuKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _openMenu(WidgetTester tester, GlobalKey<MediaContextMenuState> menuKey) async {
|
||||||
|
menuKey.currentState!.showContextMenu(tester.element(find.text('delete target')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _openPlaylistPicker(WidgetTester tester, GlobalKey<MediaContextMenuState> menuKey) async {
|
Future<void> _openPlaylistPicker(WidgetTester tester, GlobalKey<MediaContextMenuState> menuKey) async {
|
||||||
menuKey.currentState!.showContextMenu(tester.element(find.text('picker target')));
|
menuKey.currentState!.showContextMenu(tester.element(find.text('picker target')));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|||||||
Reference in New Issue
Block a user