Add opt-in FFmpeg continuity slates with per-viewer localization
This commit is contained in:
@@ -0,0 +1,170 @@
|
||||
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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user