Files
jellyfin-plugin-enigma2/Jellyfin.Plugin.Enigma2/PlaybackFeedback.cs

171 lines
9.8 KiB
C#

using Jellyfin.Data;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Database.Implementations.Enums;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.LiveTv;
using MediaBrowser.Controller.Net;
using MediaBrowser.Controller.Session;
using MediaBrowser.Model.Session;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
namespace Jellyfin.Plugin.Enigma2;
/// <summary>Per-user/device startup feedback; no tuner connections or stream URLs.</summary>
public sealed class PlaybackFeedback
{
private readonly IHttpContextAccessor contexts;
private readonly IAuthorizationContext authorization;
private readonly ISessionManager sessions;
private readonly StartupMessages messages;
private readonly object gate = new();
private readonly Dictionary<(Guid User, string Device, string Reference), Attempt> attempts = new();
public PlaybackFeedback(IHttpContextAccessor contexts, IAuthorizationContext authorization, ISessionManager sessions, StartupMessages messages = null)
{ this.contexts = contexts; this.authorization = authorization; this.sessions = sessions; this.messages = messages ?? new StartupMessages(); }
public async Task<Attempt> Begin(string reference, CancellationToken token)
{
// Scheduled recordings/background probes have no requesting viewer.
// Never guess a destination or broadcast to all sessions of a user.
var context = contexts.HttpContext;
if (context == null) return null;
AuthorizationInfo auth;
try { auth = await authorization.GetAuthorizationInfo(context).WaitAsync(TimeSpan.FromSeconds(1), token); }
catch (Exception error) when (error is not OperationCanceledException || !token.IsCancellationRequested) { return null; }
if (auth.UserId == Guid.Empty || string.IsNullOrEmpty(auth.DeviceId) || auth.IsApiKey
|| auth.User == null || !auth.User.HasPermission(PermissionKind.EnableLiveTvAccess)) return null;
string destination = null;
var matches = sessions.Sessions.Where(s => s.UserId == auth.UserId && s.DeviceId == auth.DeviceId && s.IsActive).Take(2).ToArray();
if ((Plugin.Instance?.Configuration?.ShowNativeStartupMessages ?? true) && matches.Length == 1 && IsAndroidTv(matches[0].Client)
&& matches[0].SupportedCommands?.Any(c => c == GeneralCommandType.DisplayMessage) == true) destination = matches[0].Id;
var key = (auth.UserId, auth.DeviceId, reference);
var language = messages.Resolve(auth.UserId, messages.ClientLanguage(auth.UserId, auth.DeviceId), context.Request.Headers.AcceptLanguage.ToString());
var attempt = new Attempt(this, key, destination, language, token);
lock (gate)
{
foreach (var expired in attempts.Where(x => DateTime.UtcNow - x.Value.UpdatedUtc > TimeSpan.FromSeconds(StartupBudget.WebSeconds + 15)).Select(x => x.Key).ToArray()) attempts.Remove(expired);
if (attempts.Count >= 512 && !attempts.ContainsKey(key)) attempts.Remove(attempts.OrderBy(x => x.Value.UpdatedUtc).First().Key);
attempts[key] = attempt;
}
attempt.Report("preparing");
return attempt;
}
public static bool IsAndroidTv(string client) => (client ?? "").Replace(" ", "").Contains("AndroidTV", StringComparison.OrdinalIgnoreCase);
public object Snapshot(Guid user, string device, string reference)
{
lock (gate)
{
if (!attempts.TryGetValue((user, device ?? "", reference), out var attempt) || DateTime.UtcNow - attempt.UpdatedUtc > TimeSpan.FromSeconds(StartupBudget.WebSeconds + 15))
return new { Available = false, Phase = "preparing" };
return new { Available = true, attempt.Phase, attempt.StartedUtc, attempt.UpdatedUtc };
}
}
public async Task<T> AwaitStream<T>(Task<T> stream, Attempt attempt, CancellationToken token)
{
if (attempt == null) return await stream;
attempt.Report("checking_stream");
if (await Task.WhenAny(stream, Task.Delay(TimeSpan.FromSeconds(6), token)) != stream)
{
token.ThrowIfCancellationRequested();
attempt.Report("waiting_for_stream");
}
return await stream;
}
private bool Current(Attempt attempt)
{ lock (gate) return attempts.TryGetValue(attempt.Key, out var current) && ReferenceEquals(current, attempt); }
public sealed class Attempt
{
private readonly PlaybackFeedback owner;
internal readonly (Guid User, string Device, string Reference) Key;
private readonly string destination;
private readonly string language;
public string Language => language;
private readonly CancellationToken token;
private Task notifications = Task.CompletedTask;
private int notificationCount;
public DateTime StartedUtc { get; } = DateTime.UtcNow;
public DateTime UpdatedUtc { get; private set; } = DateTime.UtcNow;
public string Phase { get; private set; } = "preparing";
internal Attempt(PlaybackFeedback owner, (Guid, string, string) key, string destination, string language, CancellationToken token)
{ this.owner = owner; Key = key; this.destination = destination; this.language = language; this.token = token; }
public void Report(string phase)
{
lock (owner.gate)
{
if (!owner.Current(this)) return;
Phase = phase; UpdatedUtc = DateTime.UtcNow;
var message = phase switch
{
"preparing" => owner.messages.Text(language, "native_preparing"),
"waiting_for_stream" => owner.messages.Text(language, "native_waiting"),
"ready" => owner.messages.Text(language, "native_ready"),
"failed" => owner.messages.Text(language, "native_failed"),
_ => null
};
if (destination == null || message == null || token.IsCancellationRequested || notificationCount >= 3) return;
notificationCount++;
notifications = notifications.ContinueWith(async _ =>
{
if (token.IsCancellationRequested || !owner.Current(this)) return;
// Sending status must never hold up or fail actual playback.
using var deadline = CancellationTokenSource.CreateLinkedTokenSource(token);
deadline.CancelAfter(TimeSpan.FromMilliseconds(500));
try
{
var target = owner.sessions.Sessions.FirstOrDefault(s => s.Id == destination && s.UserId == Key.User && s.DeviceId == Key.Device && s.IsActive);
if (target != null) await owner.sessions.SendMessageCommand(destination, destination,
new MessageCommand { Header = owner.messages.Text(language, "header"), Text = message, TimeoutMs = 3000 }, deadline.Token).WaitAsync(deadline.Token);
}
catch { /* Optional feedback cannot break the channel. */ }
}, CancellationToken.None, TaskContinuationOptions.None, TaskScheduler.Default).Unwrap();
}
}
}
}
[ApiController]
[Route("DecoderIntegration/PlaybackProgress")]
[Authorize(Policy = "LiveTvAccess")]
public sealed class PlaybackFeedbackController : ControllerBase
{
private readonly PlaybackFeedback feedback;
private readonly ILibraryManager library;
private readonly IUserManager users;
private readonly IAuthorizationContext authorization;
private readonly StartupMessages messages;
public PlaybackFeedbackController(PlaybackFeedback feedback, ILibraryManager library, IUserManager users, IAuthorizationContext authorization, StartupMessages messages = null)
{ this.feedback = feedback; this.library = library; this.users = users; this.authorization = authorization; this.messages = messages ?? new StartupMessages(); }
[HttpGet("/DecoderIntegration/StartupMessages")]
[ResponseCache(NoStore = true, Location = ResponseCacheLocation.None)]
public async Task<IActionResult> Messages([FromQuery] string language = null)
{
if (!Guid.TryParse(User.FindFirst("Jellyfin-UserId")?.Value, out var id) || users.GetUserById(id) is not { } user) return Unauthorized();
if (!user.HasPermission(PermissionKind.EnableLiveTvAccess)) return Forbid();
var selected = messages.Resolve(id, language, Request.Headers.AcceptLanguage.ToString());
var auth = await authorization.GetAuthorizationInfo(HttpContext);
if (auth.UserId == id && !auth.IsApiKey) messages.RememberClientLanguage(id, auth.DeviceId, language);
return Ok(new { Language = selected, Texts = messages.Texts(selected), StartupTimeoutSeconds = StartupBudget.WebSeconds });
}
[HttpGet]
[ResponseCache(NoStore = true, Location = ResponseCacheLocation.None)]
public async Task<IActionResult> Get([FromQuery] Guid channelId)
{
if (!Guid.TryParse(User.FindFirst("Jellyfin-UserId")?.Value, out var id)) return Unauthorized();
var user = users.GetUserById(id);
if (user == null) return Unauthorized();
if (!user.HasPermission(PermissionKind.EnableLiveTvAccess)) return Forbid();
var item = channelId == Guid.Empty ? null : library.GetItemById<BaseItem>(channelId, user);
if (item is not LiveTvChannel channel || !string.Equals(channel.ServiceName, "Enigma2", StringComparison.OrdinalIgnoreCase)) return NotFound();
var auth = await authorization.GetAuthorizationInfo(HttpContext);
if (auth.UserId != id || string.IsNullOrEmpty(auth.DeviceId)) return Unauthorized();
return Ok(feedback.Snapshot(id, auth.DeviceId, channel.ExternalId));
}
}