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; /// Per-user/device startup feedback; no tuner connections or stream URLs. 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 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 AwaitStream(Task 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 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 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(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)); } }