diff --git a/lib/media/media_server_client.dart b/lib/media/media_server_client.dart index 7c9e8a25..bfdeb5a9 100644 --- a/lib/media/media_server_client.dart +++ b/lib/media/media_server_client.dart @@ -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 fetchDeletePermission(MediaItem item); +} + /// Cache-aware fetch helpers shared by both backends so the offline-first / /// network-then-cache pattern lives in one place. /// diff --git a/lib/services/jellyfin_client.dart b/lib/services/jellyfin_client.dart index d1e8d078..a3bff02e 100644 --- a/lib/services/jellyfin_client.dart +++ b/lib/services/jellyfin_client.dart @@ -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(); diff --git a/lib/services/jellyfin_client/parts/collections.dart b/lib/services/jellyfin_client/parts/collections.dart index 8e3f8f29..90e5212f 100644 --- a/lib/services/jellyfin_client/parts/collections.dart +++ b/lib/services/jellyfin_client/parts/collections.dart @@ -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 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); + } + } } diff --git a/lib/utils/media_server_timeouts.dart b/lib/utils/media_server_timeouts.dart index a20cfebc..2b3ba8f0 100644 --- a/lib/utils/media_server_timeouts.dart +++ b/lib/utils/media_server_timeouts.dart @@ -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); diff --git a/lib/widgets/media_context_menu.dart b/lib/widgets/media_context_menu.dart index 038862c2..1251eda8 100644 --- a/lib/widgets/media_context_menu.dart +++ b/lib/widgets/media_context_menu.dart @@ -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 { /// 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 _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().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 { 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 { 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', diff --git a/test/services/jellyfin_delete_permission_test.dart b/test/services/jellyfin_delete_permission_test.dart new file mode 100644 index 00000000..9e5b8797 --- /dev/null +++ b/test/services/jellyfin_delete_permission_test.dart @@ -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 requests}) clientAnswering( + Future Function(http.Request request) handler, + ) { + final requests = []; + 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': []})); + + 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().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().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>(); + addTearDown(body.close); + final client = JellyfinClient.forTesting( + connection: testJellyfinConnection(), + httpClient: MockClient.streaming((_, _) async { + await Future.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( + (_) {}, + 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().having( + (e) => e.type, + 'type', + MediaServerHttpErrorType.connectionTimeout, +); diff --git a/test/widgets/media_context_menu_test.dart b/test/widgets/media_context_menu_test.dart index 74a36573..a670b882 100644 --- a/test/widgets/media_context_menu_test.dart +++ b/test/widgets/media_context_menu_test.dart @@ -27,6 +27,7 @@ import 'package:plezy/profiles/profile.dart'; import 'package:plezy/profiles/active_profile_provider.dart'; import 'package:plezy/providers/download_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/screens/music/album_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/theme/mono_theme.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/widgets/media_context_menu.dart'; import 'package:provider/provider.dart'; import '../test_helpers/backend_client_fixtures.dart'; import '../test_helpers/media_items.dart'; import '../test_helpers/multi_server_fixtures.dart'; +import '../test_helpers/http_fixtures.dart'; import '../test_helpers/prefs.dart'; import '../test_helpers/profile_stack.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 = []; + 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 = []; + 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().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', () { test('allows Jellyfin video metadata edit through capability gate', () { final client = JellyfinClient.forTesting( @@ -612,6 +782,89 @@ Future> _pumpPlexMovieMenu( 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 _libraryGrantedToOneMovie(http.Request request) async { + final id = request.url.queryParameters['ids'] ?? ''; + return _canDeleteResponse(id, id == 'movie-in-grant'); +} + +Future> _pumpJellyfinMovieMenu( + WidgetTester tester, { + required bool isAdministrator, + required Future Function(http.Request request) handler, + String itemId = 'movie-1', + bool serverOnline = true, + List? 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(); + final item = testMediaItem( + id: itemId, + backend: MediaBackend.jellyfin, + kind: MediaKind.movie, + title: 'Movie', + serverId: 'srv-1', + ); + await tester.pumpWidget( + TranslationProvider( + child: MultiProvider( + providers: [ + ChangeNotifierProvider.value(value: multiServerProvider), + ChangeNotifierProvider.value(value: stack.active), + ChangeNotifierProvider.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 _openMenu(WidgetTester tester, GlobalKey menuKey) async { + menuKey.currentState!.showContextMenu(tester.element(find.text('delete target'))); + await tester.pumpAndSettle(); +} + Future _openPlaylistPicker(WidgetTester tester, GlobalKey menuKey) async { menuKey.currentState!.showContextMenu(tester.element(find.text('picker target'))); await tester.pumpAndSettle();