fix(session): confirm routed delivery and keep playback state with the owner

This commit is contained in:
2026-09-26 14:45:40 +10:00
parent 086fdb8257
commit 611bcf2c4a
17 changed files with 1068 additions and 216 deletions
@@ -16,18 +16,20 @@ public interface IPodMessageBus
string PodId { get; }
/// <summary>
/// Sends a message to one instance and reports how many listeners took it, so that a message
/// addressed to an instance that is no longer there is not mistaken for a delivered one.
/// Sends a message to one instance and waits for that instance to report what it did with it. The
/// number of subscribers only proves the target's connection to the broker is up, so delivery is
/// taken from the acknowledgement of the instance that has to act on the message.
/// </summary>
/// <param name="targetPod">The instance to deliver to.</param>
/// <param name="message">The message.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>The number of instances the message reached.</returns>
Task<long> PublishAsync(string targetPod, PodMessage message, CancellationToken cancellationToken = default);
/// <returns><c>true</c> if the target acknowledged having carried the message out.</returns>
Task<bool> RequestAsync(string targetPod, PodMessage message, CancellationToken cancellationToken = default);
/// <summary>
/// Registers a handler for the messages addressed to this instance.
/// Registers a handler for the messages addressed to this instance. Whatever the handler returns is
/// sent back to the origin as the acknowledgement.
/// </summary>
/// <param name="handler">The handler.</param>
void Subscribe(Func<PodMessage, Task> handler);
void Subscribe(Func<PodMessage, Task<bool>> handler);
}
@@ -10,16 +10,25 @@ namespace MediaBrowser.Controller.Session;
/// </summary>
public interface ISessionDirectory
{
/// <summary>
/// Allocates the next connection epoch for a session. The counter lives in the shared store, so the
/// epochs of every instance are ordered by one clock instead of being compared across machines.
/// </summary>
/// <param name="sessionId">The session identifier.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>The allocated epoch, which is greater than every epoch allocated for the session before it.</returns>
Task<long> AllocateConnectionEpochAsync(string sessionId, CancellationToken cancellationToken = default);
/// <summary>
/// Claims a session for the publishing instance and restarts its expiry. The claim is refused when
/// another instance holds the connection, so an instance that merely served a request for the session
/// cannot take ownership of it.
/// </summary>
/// <param name="entry">The entry.</param>
/// <param name="connectedUtcTicks">When the publishing instance's connection to the session was established, or zero when it holds none.</param>
/// <param name="connectionEpoch">The epoch of the publishing instance's connection to the session, or zero when it holds none.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns><c>true</c> if the entry was written.</returns>
Task<bool> PublishAsync(SessionDirectoryEntry entry, long connectedUtcTicks, CancellationToken cancellationToken = default);
Task<bool> PublishAsync(SessionDirectoryEntry entry, long connectionEpoch, CancellationToken cancellationToken = default);
/// <summary>
/// Removes an entry, but only while the calling instance still owns it.
@@ -36,6 +45,7 @@ public interface ISessionDirectory
/// <param name="sessionId">The session identifier.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>The entry, or <c>null</c> when the session is in no instance's directory.</returns>
/// <exception cref="System.Exception">The store could not be read. An unreadable store is not an absent session.</exception>
Task<SessionDirectoryEntry?> GetAsync(string sessionId, CancellationToken cancellationToken = default);
/// <summary>
@@ -260,7 +260,8 @@ namespace MediaBrowser.Controller.Session
/// <param name="controllingSessionId">The controlling session identifier.</param>
/// <param name="sessionId">The session identifier.</param>
/// <param name="itemId">The item identifier.</param>
void ReportNowViewingItem(string controllingSessionId, string sessionId, string itemId);
/// <returns>A task representing the operation.</returns>
Task ReportNowViewingItem(string controllingSessionId, string sessionId, string itemId);
/// <summary>
/// Authenticates the new session.
@@ -18,11 +18,11 @@ public sealed class NullPodMessageBus : IPodMessageBus
public string PodId => PodIdentity.Current;
/// <inheritdoc />
public Task<long> PublishAsync(string targetPod, PodMessage message, CancellationToken cancellationToken = default)
=> Task.FromResult(0L);
public Task<bool> RequestAsync(string targetPod, PodMessage message, CancellationToken cancellationToken = default)
=> Task.FromResult(false);
/// <inheritdoc />
public void Subscribe(Func<PodMessage, Task> handler)
public void Subscribe(Func<PodMessage, Task<bool>> handler)
{
}
}
@@ -17,7 +17,11 @@ public sealed class NullSessionDirectory : ISessionDirectory
public static NullSessionDirectory Instance { get; } = new NullSessionDirectory();
/// <inheritdoc />
public Task<bool> PublishAsync(SessionDirectoryEntry entry, long connectedUtcTicks, CancellationToken cancellationToken = default)
public Task<long> AllocateConnectionEpochAsync(string sessionId, CancellationToken cancellationToken = default)
=> Task.FromResult(0L);
/// <inheritdoc />
public Task<bool> PublishAsync(SessionDirectoryEntry entry, long connectionEpoch, CancellationToken cancellationToken = default)
=> Task.FromResult(false);
/// <inheritdoc />
@@ -6,6 +6,11 @@ namespace MediaBrowser.Controller.Session;
/// </summary>
public sealed class PodMessage
{
/// <summary>
/// The <see cref="Kind"/> of the acknowledgement the receiving instance sends back.
/// </summary>
public const string AckKind = "Ack";
/// <summary>
/// Gets or sets the payload discriminator.
/// </summary>
@@ -16,6 +21,17 @@ public sealed class PodMessage
/// </summary>
public string OriginPod { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the identifier tying an acknowledgement to the message it answers.
/// </summary>
public string CorrelationId { get; set; } = string.Empty;
/// <summary>
/// Gets or sets a value indicating whether the receiving instance carried the message out. Only
/// meaningful on an <see cref="AckKind"/> message.
/// </summary>
public bool Handled { get; set; }
/// <summary>
/// Gets or sets the serialized payload.
/// </summary>
@@ -0,0 +1,23 @@
namespace MediaBrowser.Controller.Session;
/// <summary>
/// A now-viewing report for a session held by another instance, carried as a <see cref="PodMessage"/>.
/// The calling instance has already authorized it.
/// </summary>
public sealed class RoutedNowViewingItem
{
/// <summary>
/// The <see cref="PodMessage.Kind"/> this payload travels under.
/// </summary>
public const string Kind = "NowViewingItem";
/// <summary>
/// Gets or sets the session the report applies to.
/// </summary>
public string SessionId { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the item being viewed.
/// </summary>
public string ItemId { get; set; } = string.Empty;
}
@@ -0,0 +1,23 @@
namespace MediaBrowser.Controller.Session;
/// <summary>
/// The <see cref="PodMessage.Kind"/> values a playback report travels under when the session it is
/// addressed to is held by another instance. The payload is the report itself.
/// </summary>
public static class RoutedPlaybackReport
{
/// <summary>
/// A <c>PlaybackStartInfo</c>.
/// </summary>
public const string StartKind = "PlaybackStart";
/// <summary>
/// A <c>PlaybackProgressInfo</c>.
/// </summary>
public const string ProgressKind = "PlaybackProgress";
/// <summary>
/// A <c>PlaybackStopInfo</c>.
/// </summary>
public const string StoppedKind = "PlaybackStopped";
}