diff --git a/lib/media/media_server_client.dart b/lib/media/media_server_client.dart index 6a3d0d51..80c0c0b0 100644 --- a/lib/media/media_server_client.dart +++ b/lib/media/media_server_client.dart @@ -232,8 +232,9 @@ abstract class MediaServerClient { Future markWatched(MediaItem item); Future markUnwatched(MediaItem item); - /// Hide an item from Continue Watching without changing its watched - /// status. + /// Hide an item from Continue Watching without changing watched status or + /// progress. Only call when [capabilities.continueWatchingRemoval] is true; + /// unsupported backends throw [UnsupportedError]. Future removeFromContinueWatching(MediaItem item); /// Rate the item on a 0–10 scale. Backends without numeric ratings diff --git a/lib/media/server_capabilities.dart b/lib/media/server_capabilities.dart index 26cc86ae..5dfcbdf9 100644 --- a/lib/media/server_capabilities.dart +++ b/lib/media/server_capabilities.dart @@ -60,6 +60,10 @@ class ServerCapabilities { /// only a binary like/dislike, so star sliders should be hidden. final bool numericUserRating; + /// Hide an item from Continue Watching without changing watch state or + /// playback progress. Plex exposes this directly; Jellyfin does not. + final bool continueWatchingRemoval; + /// External subtitle search/marketplace (Plex `/library/metadata/{id}/subtitles`). /// Hides the "Search subtitles" affordance when false. final bool externalSubtitleSearch; @@ -113,6 +117,7 @@ class ServerCapabilities { this.serverSideSync = false, this.richHubs = false, this.numericUserRating = false, + this.continueWatchingRemoval = false, this.externalSubtitleSearch = false, this.trackPreferencePersistence = false, this.endpointFailover = false, @@ -135,6 +140,7 @@ class ServerCapabilities { serverSideSync: true, richHubs: true, numericUserRating: true, + continueWatchingRemoval: true, externalSubtitleSearch: true, trackPreferencePersistence: true, endpointFailover: true, @@ -185,6 +191,7 @@ class ServerCapabilities { bool? serverSideSync, bool? richHubs, bool? numericUserRating, + bool? continueWatchingRemoval, bool? externalSubtitleSearch, bool? trackPreferencePersistence, bool? endpointFailover, @@ -205,6 +212,7 @@ class ServerCapabilities { serverSideSync: serverSideSync ?? this.serverSideSync, richHubs: richHubs ?? this.richHubs, numericUserRating: numericUserRating ?? this.numericUserRating, + continueWatchingRemoval: continueWatchingRemoval ?? this.continueWatchingRemoval, externalSubtitleSearch: externalSubtitleSearch ?? this.externalSubtitleSearch, trackPreferencePersistence: trackPreferencePersistence ?? this.trackPreferencePersistence, endpointFailover: endpointFailover ?? this.endpointFailover, diff --git a/lib/services/jellyfin_client.dart b/lib/services/jellyfin_client.dart index 458421e1..88f988f6 100644 --- a/lib/services/jellyfin_client.dart +++ b/lib/services/jellyfin_client.dart @@ -1358,12 +1358,7 @@ class JellyfinClient with MediaServerCacheMixin implements MediaServerClient, Sc @override Future removeFromContinueWatching(MediaItem item) async { - // Jellyfin uses a `Hide` endpoint to remove items from Continue Watching. - final response = await _http.post( - '/UserItems/${_segment(item.id)}/HideFromResume', - queryParameters: {'userId': connection.userId, 'Hide': 'true'}, - ); - throwIfHttpError(response); + throw UnsupportedError('Jellyfin does not support removing items from Continue Watching.'); } @override diff --git a/lib/widgets/media_context_menu.dart b/lib/widgets/media_context_menu.dart index 5911a947..7e930c1b 100644 --- a/lib/widgets/media_context_menu.dart +++ b/lib/widgets/media_context_menu.dart @@ -211,11 +211,11 @@ class MediaContextMenuState extends State { activeProfile: activeProfile, ); - // Backend capabilities — used to gate the "Play Version" item below. - // Reads the same `capabilities.videoTranscoding` flag the in-player - // sheet uses so the two surfaces never disagree about what's offered. + // Backend capabilities gate menu items so we don't expose actions the + // active server cannot perform. final mediaClient = _itemServerId != null ? multiServerProvider.getClientForServer(_itemServerId!) : null; final canTranscode = mediaClient?.capabilities.videoTranscoding ?? false; + final canRemoveFromContinueWatching = mediaClient?.capabilities.continueWatchingRemoval ?? false; final menuActions = <_MenuAction>[]; @@ -271,7 +271,7 @@ class MediaContextMenuState extends State { ); } - if (widget.isInContinueWatching) { + if (widget.isInContinueWatching && canRemoveFromContinueWatching) { menuActions.add( _MenuAction( value: 'remove_from_continue_watching', diff --git a/test/services/jellyfin_client_urls_test.dart b/test/services/jellyfin_client_urls_test.dart index 8472c1c0..280fa760 100644 --- a/test/services/jellyfin_client_urls_test.dart +++ b/test/services/jellyfin_client_urls_test.dart @@ -399,7 +399,6 @@ void main() { await scoped.fetchClientSideEpisodeQueue('folder/show #1?x'); await scoped.markWatched(item); await scoped.markUnwatched(item); - await scoped.removeFromContinueWatching(item); await scoped.rate(item, 7); await scoped.rate(item, -1); @@ -408,10 +407,26 @@ void main() { expect(paths, contains('/Shows/folder%2Fshow%20%231%3Fx/Episodes')); expect(paths, contains('/UserPlayedItems/folder%2Fitem%20%231%3Fx')); expect(paths.where((p) => p == '/UserPlayedItems/folder%2Fitem%20%231%3Fx'), hasLength(2)); - expect(paths, contains('/UserItems/folder%2Fitem%20%231%3Fx/HideFromResume')); expect(paths.where((p) => p == '/UserItems/folder%2Fitem%20%231%3Fx/Rating'), hasLength(2)); }); + test('removeFromContinueWatching is unsupported for Jellyfin and does not call the server', () async { + var requested = false; + final scoped = JellyfinClient.forTesting( + connection: _conn(), + httpClient: MockClient((request) async { + requested = true; + return http.Response('', 500); + }), + ); + addTearDown(scoped.close); + + final item = MediaItem(id: 'item-1', backend: MediaBackend.jellyfin, kind: MediaKind.movie, serverId: 'srv-1'); + + await expectLater(scoped.removeFromContinueWatching(item), throwsA(isA())); + expect(requested, isFalse); + }); + test('getPlaybackInitialization URL-encodes appended api_key', () async { final scoped = JellyfinClient.forTesting( connection: _conn(accessToken: 'tok+with spaces/?&'),