aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--Directory.Packages.props8
-rw-r--r--Emby.Server.Implementations/HttpServer/WebSocketConnection.cs6
-rw-r--r--Emby.Server.Implementations/Localization/Core/si.json16
-rw-r--r--Emby.Server.Implementations/Localization/Ratings/se.json13
-rw-r--r--Emby.Server.Implementations/Session/SessionWebSocketListener.cs15
-rw-r--r--Jellyfin.Api/Helpers/StreamingHelpers.cs9
-rw-r--r--tests/Jellyfin.Server.Integration.Tests/SyncPlayLostWebSocketTests.cs140
7 files changed, 197 insertions, 10 deletions
diff --git a/Directory.Packages.props b/Directory.Packages.props
index 4dab79b6bf..f0a655d488 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -28,10 +28,10 @@
<PackageVersion Include="MetaBrainz.MusicBrainz" Version="8.0.1" />
<PackageVersion Include="Microsoft.AspNetCore.Authorization" Version="10.0.9" />
<PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.9" />
- <PackageVersion Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="4.14.0" />
- <PackageVersion Include="Microsoft.CodeAnalysis.Common" Version="5.3.0" />
- <PackageVersion Include="Microsoft.CodeAnalysis.CSharp" Version="5.3.0" />
- <PackageVersion Include="Microsoft.CodeAnalysis.Analyzers" Version="5.3.0" />
+ <PackageVersion Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="5.6.0" />
+ <PackageVersion Include="Microsoft.CodeAnalysis.Common" Version="5.6.0" />
+ <PackageVersion Include="Microsoft.CodeAnalysis.CSharp" Version="5.6.0" />
+ <PackageVersion Include="Microsoft.CodeAnalysis.Analyzers" Version="5.6.0" />
<PackageVersion Include="Microsoft.Data.Sqlite" Version="10.0.9" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.9" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.9" />
diff --git a/Emby.Server.Implementations/HttpServer/WebSocketConnection.cs b/Emby.Server.Implementations/HttpServer/WebSocketConnection.cs
index e9bf3b93a7..dc7f972c13 100644
--- a/Emby.Server.Implementations/HttpServer/WebSocketConnection.cs
+++ b/Emby.Server.Implementations/HttpServer/WebSocketConnection.cs
@@ -127,8 +127,12 @@ namespace Emby.Server.Implementations.HttpServer
{
receiveResult = await _socket.ReceiveAsync(memory, cancellationToken).ConfigureAwait(false);
}
- catch (WebSocketException ex)
+ catch (Exception ex) when (ex is WebSocketException or ObjectDisposedException or OperationCanceledException)
{
+ // ObjectDisposedException/OperationCanceledException: the socket was torn
+ // down underneath us (e.g. by the keep-alive watchdog after the connection
+ // was declared lost). Fall through so Closed is still raised and the
+ // session can release this connection.
_logger.LogWarning("WS {IP} error receiving data: {Message}", RemoteEndPoint, ex.Message);
break;
}
diff --git a/Emby.Server.Implementations/Localization/Core/si.json b/Emby.Server.Implementations/Localization/Core/si.json
index 0967ef424b..8efc0d1f2e 100644
--- a/Emby.Server.Implementations/Localization/Core/si.json
+++ b/Emby.Server.Implementations/Localization/Core/si.json
@@ -1 +1,15 @@
-{}
+{
+ "AppDeviceValues": "ඇප්: {0}, උපාංග: {1}",
+ "Artists": "කලාකරුවන්",
+ "AuthenticationSucceededWithUserName": "{0} සාර්ථකව තහවුරු කරන ලදී",
+ "Books": "පොත්",
+ "ChapterNameValue": "{0} වෙනි පරිච්ඡේදය",
+ "Collections": "සංහිතා",
+ "Default": "පෙරනිමි",
+ "External": "බාහිර",
+ "FailedLoginAttemptWithUserName": "{0} වෙතින් සිදුකළ පිවිසීමේ උත්සාහය අසාර්ථක විය",
+ "Favorites": "ප්‍රියතමයන්",
+ "Folders": "ෆෝල්ඩර",
+ "Forced": "නියමිත",
+ "Genres": "ප්‍රභේද"
+}
diff --git a/Emby.Server.Implementations/Localization/Ratings/se.json b/Emby.Server.Implementations/Localization/Ratings/se.json
index 70084995d1..818565e16b 100644
--- a/Emby.Server.Implementations/Localization/Ratings/se.json
+++ b/Emby.Server.Implementations/Localization/Ratings/se.json
@@ -10,7 +10,7 @@
}
},
{
- "ratingStrings": ["7"],
+ "ratingStrings": ["7", "7+", "7 År", "Från 7 år"],
"ratingScore": {
"score": 7,
"subScore": null
@@ -31,7 +31,7 @@
}
},
{
- "ratingStrings": ["11"],
+ "ratingStrings": ["11", "11+", "11 År", "Från 11 år"],
"ratingScore": {
"score": 11,
"subScore": null
@@ -45,11 +45,18 @@
}
},
{
- "ratingStrings": ["15"],
+ "ratingStrings": ["15", "15+", "15 År", "Från 15 år"],
"ratingScore": {
"score": 15,
"subScore": null
}
+ },
+ {
+ "ratingStrings": ["18", "18+", "Barnförbjuden", "Bfj"],
+ "ratingScore": {
+ "score": 18,
+ "subScore": null
+ }
}
]
}
diff --git a/Emby.Server.Implementations/Session/SessionWebSocketListener.cs b/Emby.Server.Implementations/Session/SessionWebSocketListener.cs
index 6a26e92e14..2582ed9df0 100644
--- a/Emby.Server.Implementations/Session/SessionWebSocketListener.cs
+++ b/Emby.Server.Implementations/Session/SessionWebSocketListener.cs
@@ -246,8 +246,21 @@ namespace Emby.Server.Implementations.Session
_logger.LogInformation("Lost {0} WebSockets.", lost.Count);
foreach (var webSocket in lost)
{
- // TODO: handle session relative to the lost webSocket
RemoveWebSocket(webSocket);
+
+ // The connection stopped answering keep-alives, so a close frame will
+ // never arrive and the pending receive loop would hang forever, keeping
+ // the session (and e.g. its SyncPlay group membership) alive. Disposing
+ // the connection aborts the receive loop, which raises Closed and lets
+ // the session end normally.
+ try
+ {
+ webSocket.Dispose();
+ }
+ catch (Exception exception)
+ {
+ _logger.LogWarning(exception, "Error disposing lost WebSocket from {RemoteEndPoint}.", webSocket.RemoteEndPoint);
+ }
}
}
}
diff --git a/Jellyfin.Api/Helpers/StreamingHelpers.cs b/Jellyfin.Api/Helpers/StreamingHelpers.cs
index bae2756303..6a6aac1327 100644
--- a/Jellyfin.Api/Helpers/StreamingHelpers.cs
+++ b/Jellyfin.Api/Helpers/StreamingHelpers.cs
@@ -144,6 +144,15 @@ public static class StreamingHelpers
mediaSource = liveStreamInfo.Item1;
state.DirectStreamProvider = liveStreamInfo.Item2;
+ // The requested live stream is no longer open. This commonly happens when a client keeps
+ // polling the HLS playlist (e.g. live.m3u8) after the stream was disposed because its
+ // consumer count dropped to zero. GetLiveStreamWithDirectStreamProvider returns a null
+ // MediaSource in that case, so return 404 instead of dereferencing it below.
+ if (mediaSource is null)
+ {
+ throw new ResourceNotFoundException($"The live stream with id {streamingRequest.LiveStreamId} could not be found or is no longer available.");
+ }
+
// Cap the max bitrate when it is too high. This is usually due to ffmpeg is unable to probe the source liveTV streams' bitrate.
if (mediaSource.FallbackMaxStreamingBitrate is not null && streamingRequest.VideoBitRate is not null)
{
diff --git a/tests/Jellyfin.Server.Integration.Tests/SyncPlayLostWebSocketTests.cs b/tests/Jellyfin.Server.Integration.Tests/SyncPlayLostWebSocketTests.cs
new file mode 100644
index 0000000000..fa15b33af6
--- /dev/null
+++ b/tests/Jellyfin.Server.Integration.Tests/SyncPlayLostWebSocketTests.cs
@@ -0,0 +1,140 @@
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.Linq;
+using System.Net;
+using System.Net.Http;
+using System.Net.Http.Json;
+using System.Net.WebSockets;
+using System.Reflection;
+using System.Text.Json;
+using System.Threading;
+using System.Threading.Tasks;
+using Emby.Server.Implementations.Session;
+using Jellyfin.Api.Models.SyncPlayDtos;
+using Jellyfin.Extensions.Json;
+using MediaBrowser.Controller.Net;
+using Microsoft.Extensions.DependencyInjection;
+using Xunit;
+
+namespace Jellyfin.Server.Integration.Tests;
+
+public sealed class SyncPlayLostWebSocketTests : IClassFixture<JellyfinApplicationFactory>
+{
+ private readonly JellyfinApplicationFactory _factory;
+
+ public SyncPlayLostWebSocketTests(JellyfinApplicationFactory factory)
+ {
+ _factory = factory;
+ }
+
+ [Fact]
+ public async Task LostWebSocket_EndsSession_And_RemovesEmptySyncPlayGroup()
+ {
+ var cancellationToken = TestContext.Current.CancellationToken;
+ var client = _factory.CreateClient();
+ var accessToken = await AuthHelper.CompleteStartupAsync(client);
+ client.DefaultRequestHeaders.AddAuthHeader(accessToken);
+
+ var wsClient = _factory.Server.CreateWebSocketClient();
+ wsClient.ConfigureRequest = request =>
+ request.Headers.Authorization = AuthHelper.DummyAuthHeader + $", Token={accessToken}";
+
+ var webSocket = await wsClient.ConnectAsync(
+ new UriBuilder(_factory.Server.BaseAddress)
+ {
+ Scheme = "ws",
+ Path = "websocket"
+ }.Uri,
+ cancellationToken);
+
+ _ = DrainAsync(webSocket, cancellationToken);
+
+ var watched = await WaitForWatchedWebSocketsAsync(TimeSpan.FromSeconds(10), cancellationToken);
+ var connection = Assert.Single(watched);
+
+ using var createResponse = await client.PostAsync(
+ "SyncPlay/New",
+ JsonContent.Create(new NewGroupRequestDto { GroupName = "ZombieGroupRepro" }, options: JsonDefaults.Options),
+ cancellationToken);
+ Assert.Equal(HttpStatusCode.OK, createResponse.StatusCode);
+ Assert.Equal(1, await WaitForGroupCountAsync(client, 1, TimeSpan.FromSeconds(10), cancellationToken));
+
+ connection.LastKeepAliveDate = DateTime.UtcNow - TimeSpan.FromSeconds(180);
+
+ var groupCount = await WaitForGroupCountAsync(client, 0, TimeSpan.FromSeconds(45), cancellationToken);
+ Assert.True(
+ groupCount == 0,
+ $"SyncPlay group still listed {groupCount} group(s) after the WebSocket was lost: "
+ + "the keep-alive watchdog removed the socket from its watchlist without closing "
+ + "the session, leaving a zombie participant in the group (SessionWebSocketListener).");
+ }
+
+ private static async Task DrainAsync(WebSocket webSocket, CancellationToken cancellationToken)
+ {
+ var buffer = new byte[4096];
+ try
+ {
+ while (webSocket.State == WebSocketState.Open)
+ {
+ await webSocket.ReceiveAsync(buffer, cancellationToken);
+ }
+ }
+ catch
+ {
+ // The server tears the connection down once the watchdog gives up on it.
+ }
+ }
+
+ private async Task<IReadOnlyList<IWebSocketConnection>> WaitForWatchedWebSocketsAsync(TimeSpan timeout, CancellationToken cancellationToken)
+ {
+ var listener = _factory.Services.GetRequiredService<IEnumerable<IWebSocketListener>>()
+ .OfType<SessionWebSocketListener>()
+ .Single();
+ var watchlistField = typeof(SessionWebSocketListener)
+ .GetField("_webSockets", BindingFlags.NonPublic | BindingFlags.Instance);
+ Assert.NotNull(watchlistField);
+ var watchlist = (IEnumerable<IWebSocketConnection>)watchlistField.GetValue(listener)!;
+
+ var stopwatch = Stopwatch.StartNew();
+ while (true)
+ {
+ try
+ {
+ var snapshot = watchlist.ToArray();
+ if (snapshot.Length > 0 || stopwatch.Elapsed >= timeout)
+ {
+ return snapshot;
+ }
+ }
+ catch (InvalidOperationException)
+ {
+ // The watchdog mutated the set during enumeration; retry.
+ }
+
+ await Task.Delay(100, cancellationToken);
+ }
+ }
+
+ private static async Task<int> WaitForGroupCountAsync(HttpClient client, int expected, TimeSpan timeout, CancellationToken cancellationToken)
+ {
+ var stopwatch = Stopwatch.StartNew();
+ var count = -1;
+ while (stopwatch.Elapsed < timeout)
+ {
+ using var response = await client.GetAsync("SyncPlay/List", cancellationToken);
+ response.EnsureSuccessStatusCode();
+ await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken);
+ using var document = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken);
+ count = document.RootElement.GetArrayLength();
+ if (count == expected)
+ {
+ return count;
+ }
+
+ await Task.Delay(500, cancellationToken);
+ }
+
+ return count;
+ }
+}