171 lines
9.7 KiB
C#
171 lines
9.7 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.FromMinutes(2)).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.FromMinutes(2))
|
|
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) });
|
|
}
|
|
[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));
|
|
}
|
|
}
|