using System; using System.Collections.Concurrent; using System.IO; using System.Linq; using System.Text.Json; using System.Threading; using System.Threading.Tasks; using Jellyfin.Data; using Jellyfin.Database.Implementations.Enums; using MediaBrowser.Common.Configuration; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.LiveTv; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; namespace Jellyfin.Plugin.Enigma2; public sealed class WebPlaybackPolicy { public bool IsEnigmaChannel { get; set; } public bool PreferH264 { get; set; } public static WebPlaybackPolicy For(BaseItem item, bool enabled) { var owned = item is LiveTvChannel && string.Equals(item.ServiceName, "Enigma2", StringComparison.OrdinalIgnoreCase); return new() { IsEnigmaChannel = owned, PreferH264 = owned && enabled }; } } public sealed class ChannelPreferences { public string[] FavoriteIds { get; set; } = Array.Empty(); public string[] HiddenIds { get; set; } = Array.Empty(); public string[] OrderedIds { get; set; } = Array.Empty(); public string DefaultView { get; set; } = "all"; public void Validate() { string[] Clean(string[] values) => (values ?? Array.Empty()).Take(2000).Where(v=>Guid.TryParse(v,out _)).Select(v=>Guid.Parse(v).ToString("N")).Distinct().ToArray(); FavoriteIds=Clean(FavoriteIds); HiddenIds=Clean(HiddenIds); OrderedIds=Clean(OrderedIds); if (DefaultView != "all" && DefaultView != "favorites" && DefaultView != "guide") DefaultView="all"; } } public sealed class PreferencesStore { private readonly string folder; private readonly ConcurrentDictionary locks = new(); public PreferencesStore(IApplicationPaths paths) { folder=Path.Combine(paths.DataPath,"decoder-integration","users"); } public async Task Load(Guid user, CancellationToken token) { var gate=locks.GetOrAdd(user,_=>new(1)); await gate.WaitAsync(token); try { var file=Path.Combine(folder,user.ToString("N")+".json"); if (!File.Exists(file)) return new(); var result=JsonSerializer.Deserialize(await File.ReadAllTextAsync(file,token)) ?? new(); result.Validate(); return result; } finally { gate.Release(); } } public async Task Save(Guid user, ChannelPreferences value, CancellationToken token) { value.Validate(); var gate=locks.GetOrAdd(user,_=>new(1)); await gate.WaitAsync(token); try { Directory.CreateDirectory(folder); var file=Path.Combine(folder,user.ToString("N")+".json"); var temp=file+".tmp"; await File.WriteAllTextAsync(temp,JsonSerializer.Serialize(value),token); File.Move(temp,file,true); } finally { gate.Release(); } } public async Task Remap(Guid user, System.Collections.Generic.IReadOnlyDictionary mapping, CancellationToken token) { var gate=locks.GetOrAdd(user,_=>new(1)); await gate.WaitAsync(token); try { var file=Path.Combine(folder,user.ToString("N")+".json"); if(!File.Exists(file))return; var value=JsonSerializer.Deserialize(await File.ReadAllTextAsync(file,token))??new();value.Validate(); var before=JsonSerializer.Serialize(value); string[] Map(string[] ids)=>ids.Select(id=>mapping.TryGetValue(id,out var target)?target:id).Distinct().ToArray(); value.FavoriteIds=Map(value.FavoriteIds);value.HiddenIds=Map(value.HiddenIds);value.OrderedIds=Map(value.OrderedIds); var after=JsonSerializer.Serialize(value); if(before==after)return; await File.WriteAllTextAsync(file+".tmp",after,token);File.Move(file+".tmp",file,true); } finally { gate.Release(); } } } [ApiController] [Route("DecoderIntegration")] [Authorize(Policy="LiveTvAccess")] public sealed class PreferencesController : ControllerBase { private readonly PreferencesStore store; private readonly InternetEpg epg; private readonly LiveStreamRelay relay; private readonly LiveStreamProbe probe; private readonly ILibraryManager library; private readonly IUserManager users; private readonly ChannelSyncTask channelSync; public PreferencesController(PreferencesStore store,InternetEpg epg,LiveStreamRelay relay,LiveStreamProbe probe,ILibraryManager library,IUserManager users,ChannelSyncTask channelSync=null) { this.store=store; this.epg=epg; this.relay=relay; this.probe=probe; this.library=library; this.users=users; this.channelSync=channelSync; } private Guid UserId => Guid.TryParse(User.FindFirst("Jellyfin-UserId")?.Value,out var id) ? id : Guid.Empty; [HttpGet("Channels")] [AllowAnonymous] public IActionResult Page() { var stream=typeof(Plugin).Assembly.GetManifestResourceStream("Jellyfin.Plugin.Enigma2.Configuration.channels.html"); return File(stream,"text/html; charset=utf-8"); } [HttpGet("Preferences")] public async Task> Get(CancellationToken token) => UserId == Guid.Empty ? Unauthorized() : Ok(await store.Load(UserId,token)); [HttpPut("Preferences")] public async Task Put(ChannelPreferences value,CancellationToken token) { if (UserId == Guid.Empty) return Unauthorized(); await store.Save(UserId,value,token); return NoContent(); } [HttpGet("WebPlaybackPolicy")] [ResponseCache(NoStore = true, Location = ResponseCacheLocation.None)] public ActionResult GetWebPlaybackPolicy([FromQuery] Guid channelId) { // Resolve the authenticated user explicitly: the native library lookup with a // null user would skip visibility checks. Never accept a userId from the query. if (UserId == Guid.Empty) return Unauthorized(); var user = users.GetUserById(UserId); if (user == null) return Unauthorized(); if (!user.HasPermission(PermissionKind.EnableLiveTvAccess)) return Forbid(); var item = channelId == Guid.Empty ? null : library.GetItemById(channelId, user); return WebPlaybackPolicy.For(item, Plugin.Instance?.Configuration?.PreferH264ForWebLiveTv ?? false); } [HttpGet("Status")] [Authorize(Policy="RequiresElevation")] public IActionResult Status() => Ok(new { epg.LastSuccess,epg.MatchedImages,epg.LastError, Source="https://epg.polishteam.one/guide.xml", Streams=relay.Status(), ProbeError=probe.LastError, ChannelSync=channelSync==null?null:new {channelSync.LastCheckedUtc,channelSync.LastSynchronizedUtc,channelSync.LastError} }); }