fix(jellyfin): hide unsupported continue watching removal

close #995
This commit is contained in:
edde746
2026-05-09 04:41:13 +02:00
parent 5d2302b322
commit c38c0cf55d
5 changed files with 33 additions and 14 deletions
+3 -2
View File
@@ -232,8 +232,9 @@ abstract class MediaServerClient {
Future<void> markWatched(MediaItem item);
Future<void> 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<void> removeFromContinueWatching(MediaItem item);
/// Rate the item on a 010 scale. Backends without numeric ratings
+8
View File
@@ -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,
+1 -6
View File
@@ -1358,12 +1358,7 @@ class JellyfinClient with MediaServerCacheMixin implements MediaServerClient, Sc
@override
Future<void> 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
+4 -4
View File
@@ -211,11 +211,11 @@ class MediaContextMenuState extends State<MediaContextMenu> {
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<MediaContextMenu> {
);
}
if (widget.isInContinueWatching) {
if (widget.isInContinueWatching && canRemoveFromContinueWatching) {
menuActions.add(
_MenuAction(
value: 'remove_from_continue_watching',
+17 -2
View File
@@ -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<UnsupportedError>()));
expect(requested, isFalse);
});
test('getPlaybackInitialization URL-encodes appended api_key', () async {
final scoped = JellyfinClient.forTesting(
connection: _conn(accessToken: 'tok+with spaces/?&'),