Initial changes for 10.9.1 (#48)

This commit is contained in:
emveepee 2024-05-21 12:10:18 -04:00 committed by GitHub
parent 4e170ab567
commit d375758b21
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 127 additions and 103 deletions

2
.gitignore vendored
View File

@ -230,5 +230,3 @@ pip-log.txt
.mr.developer.cfg .mr.developer.cfg
.idea .idea
artifacts
.idea

View File

@ -1,7 +1,7 @@
<Project> <Project>
<PropertyGroup> <PropertyGroup>
<Version>0.0.0.0</Version> <Version>9.0.0.0</Version>
<AssemblyVersion>0.0.0.0</AssemblyVersion> <AssemblyVersion>9.0.0.0</AssemblyVersion>
<FileVersion>0.0.0.0</FileVersion> <FileVersion>9.0.0.0</FileVersion>
</PropertyGroup> </PropertyGroup>
</Project> </Project>

View File

@ -0,0 +1,17 @@
using MediaBrowser.Model.LiveTv;
namespace Jellyfin.Plugin.NextPVR.Helpers;
public static class ChannelHelper
{
public static ChannelType GetChannelType(int channelType)
{
ChannelType type = channelType switch
{
1 => ChannelType.TV,
10 => ChannelType.Radio,
_ => ChannelType.TV
};
return type;
}
}

View File

@ -1,4 +1,4 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using Jellyfin.Plugin.NextPVR.Configuration; using Jellyfin.Plugin.NextPVR.Configuration;
@ -45,29 +45,29 @@ public class GenreMapper
{ {
if (genreMappings != null) if (genreMappings != null)
{ {
if (_configuration.GenreMappings.ContainsKey(GenreMovie) && _configuration.GenreMappings[GenreMovie] != null) if (_configuration.GenreMappings.TryGetValue(GenreMovie, out var value) && value != null)
{ {
_movieGenres.AddRange(_configuration.GenreMappings[GenreMovie]); _movieGenres.AddRange(value);
} }
if (_configuration.GenreMappings.ContainsKey(GenreSport) && _configuration.GenreMappings[GenreSport] != null) if (_configuration.GenreMappings.TryGetValue(GenreSport, out value) && value != null)
{ {
_sportGenres.AddRange(_configuration.GenreMappings[GenreSport]); _sportGenres.AddRange(value);
} }
if (_configuration.GenreMappings.ContainsKey(GenreNews) && _configuration.GenreMappings[GenreNews] != null) if (_configuration.GenreMappings.TryGetValue(GenreNews, out value) && value != null)
{ {
_newsGenres.AddRange(_configuration.GenreMappings[GenreNews]); _newsGenres.AddRange(value);
} }
if (_configuration.GenreMappings.ContainsKey(GenreKids) && _configuration.GenreMappings[GenreKids] != null) if (_configuration.GenreMappings.TryGetValue(GenreKids, out value) && value != null)
{ {
_kidsGenres.AddRange(_configuration.GenreMappings[GenreKids]); _kidsGenres.AddRange(value);
} }
if (_configuration.GenreMappings.ContainsKey(GenreLive) && _configuration.GenreMappings[GenreLive] != null) if (_configuration.GenreMappings.TryGetValue(GenreLive, out value) && value != null)
{ {
_liveGenres.AddRange(_configuration.GenreMappings[GenreLive]); _liveGenres.AddRange(value);
} }
} }
} }

View File

@ -1,23 +1,7 @@
using MediaBrowser.Model.LiveTv; using MediaBrowser.Model.LiveTv;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.NextPVR.Helpers; namespace Jellyfin.Plugin.NextPVR.Helpers;
public static class ChannelHelper
{
public static ChannelType GetChannelType(int channelType)
{
ChannelType type = channelType switch
{
1 => ChannelType.TV,
10 => ChannelType.Radio,
_ => ChannelType.TV
};
return type;
}
}
public static class UtilsHelper public static class UtilsHelper
{ {
public static void DebugInformation(ILogger<LiveTvService> logger, string message) public static void DebugInformation(ILogger<LiveTvService> logger, string message)

View File

@ -1,7 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net6.0</TargetFramework> <TargetFramework>net8.0</TargetFramework>
<AssemblyVersion>6.0.0.0</AssemblyVersion>
<FileVersion>6.0.0.0</FileVersion>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors> <TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<AnalysisMode>AllEnabledByDefault</AnalysisMode> <AnalysisMode>AllEnabledByDefault</AnalysisMode>
<GenerateDocumentationFile>true</GenerateDocumentationFile> <GenerateDocumentationFile>true</GenerateDocumentationFile>
@ -20,12 +22,9 @@
<PackageReference Include="Jellyfin.Controller" Version="10.*-*" /> <PackageReference Include="Jellyfin.Controller" Version="10.*-*" />
<PackageReference Include="Jellyfin.Extensions" Version="10.*-*" /> <PackageReference Include="Jellyfin.Extensions" Version="10.*-*" />
<PackageReference Include="System.Memory" Version="4.5.*" /> <PackageReference Include="System.Memory" Version="4.5.*" />
<PackageReference Include="Microsoft.Extensions.Http" Version="6.0.0" /> <PackageReference Include="Microsoft.Extensions.Http" Version="8.0.0" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="SerilogAnalyzer" Version="0.15.0" PrivateAssets="All" /> <PackageReference Include="SerilogAnalyzer" Version="0.15.0" PrivateAssets="All" />
<PackageReference Include="StyleCop.Analyzers" Version="1.2.0-beta.376" PrivateAssets="All" /> <PackageReference Include="StyleCop.Analyzers" Version="1.2.0-beta.435" PrivateAssets="All" />
<PackageReference Include="SmartAnalyzers.MultithreadingAnalyzer" Version="1.1.31" PrivateAssets="All" /> <PackageReference Include="SmartAnalyzers.MultithreadingAnalyzer" Version="1.1.31" PrivateAssets="All" />
</ItemGroup> </ItemGroup>

View File

@ -1,6 +1,7 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Globalization; using System.Globalization;
using System.IO;
using System.Net.Http; using System.Net.Http;
using System.Security.Cryptography; using System.Security.Cryptography;
using System.Text; using System.Text;
@ -35,10 +36,13 @@ public class LiveTvService : ILiveTvService
_httpClientFactory = httpClientFactory; _httpClientFactory = httpClientFactory;
_logger = logger; _logger = logger;
LastUpdatedSidDateTime = DateTime.UtcNow; LastUpdatedSidDateTime = DateTime.UtcNow;
Instance = this;
} }
private string Sid { get; set; } private string Sid { get; set; }
public static LiveTvService Instance { get; private set; }
public bool IsActive => Sid != null; public bool IsActive => Sid != null;
private DateTimeOffset LastUpdatedSidDateTime { get; set; } private DateTimeOffset LastUpdatedSidDateTime { get; set; }
@ -58,7 +62,7 @@ public class LiveTvService : ILiveTvService
/// </summary> /// </summary>
/// <param name="cancellationToken">The cancellation token.</param> /// <param name="cancellationToken">The cancellation token.</param>
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns> /// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
private async Task EnsureConnectionAsync(CancellationToken cancellationToken) public async Task EnsureConnectionAsync(CancellationToken cancellationToken)
{ {
var config = Plugin.Instance.Configuration; var config = Plugin.Instance.Configuration;
@ -132,7 +136,7 @@ public class LiveTvService : ILiveTvService
private string GetMd5Hash(string value) private string GetMd5Hash(string value)
{ {
#pragma warning disable CA5351 #pragma warning disable CA5351
var hashValue = MD5.Create().ComputeHash(new UTF8Encoding().GetBytes(value)); var hashValue = MD5.HashData(new UTF8Encoding().GetBytes(value));
#pragma warning restore CA5351 #pragma warning restore CA5351
// Bit convertor return the byte to string as all caps hex values separated by "-" // Bit convertor return the byte to string as all caps hex values separated by "-"
return BitConverter.ToString(hashValue).Replace("-", string.Empty, StringComparison.Ordinal).ToLowerInvariant(); return BitConverter.ToString(hashValue).Replace("-", string.Empty, StringComparison.Ordinal).ToLowerInvariant();
@ -587,19 +591,19 @@ public class LiveTvService : ILiveTvService
throw new NotImplementedException(); throw new NotImplementedException();
} }
public Task<ImageStream> GetChannelImageAsync(string channelId, CancellationToken cancellationToken) public Task<Stream> GetChannelImageAsync(string channelId, CancellationToken cancellationToken)
{ {
// Leave as is. This is handled by supplying image url to ChannelInfo // Leave as is. This is handled by supplying image url to ChannelInfo
throw new NotImplementedException(); throw new NotImplementedException();
} }
public Task<ImageStream> GetProgramImageAsync(string programId, string channelId, CancellationToken cancellationToken) public Task<Stream> GetProgramImageAsync(string programId, string channelId, CancellationToken cancellationToken)
{ {
// Leave as is. This is handled by supplying image url to ProgramInfo // Leave as is. This is handled by supplying image url to ProgramInfo
throw new NotImplementedException(); throw new NotImplementedException();
} }
public Task<ImageStream> GetRecordingImageAsync(string recordingId, CancellationToken cancellationToken) public Task<Stream> GetRecordingImageAsync(string recordingId, CancellationToken cancellationToken)
{ {
// Leave as is. This is handled by supplying image url to RecordingInfo // Leave as is. This is handled by supplying image url to RecordingInfo
throw new NotImplementedException(); throw new NotImplementedException();

View File

@ -1,4 +1,4 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Globalization; using System.Globalization;
using System.Linq; using System.Linq;
@ -8,7 +8,6 @@ using Jellyfin.Plugin.NextPVR.Entities;
using MediaBrowser.Common.Extensions; using MediaBrowser.Common.Extensions;
using MediaBrowser.Controller.Channels; using MediaBrowser.Controller.Channels;
using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.LiveTv;
using MediaBrowser.Controller.Providers; using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.Channels; using MediaBrowser.Model.Channels;
using MediaBrowser.Model.Dto; using MediaBrowser.Model.Dto;
@ -20,7 +19,6 @@ namespace Jellyfin.Plugin.NextPVR;
public class RecordingsChannel : IChannel, IHasCacheKey, ISupportsDelete, ISupportsLatestMedia, ISupportsMediaProbe, IHasFolderAttributes, IDisposable public class RecordingsChannel : IChannel, IHasCacheKey, ISupportsDelete, ISupportsLatestMedia, ISupportsMediaProbe, IHasFolderAttributes, IDisposable
{ {
private readonly ILiveTvManager _liveTvManager;
private readonly CancellationTokenSource _cancellationToken; private readonly CancellationTokenSource _cancellationToken;
private Timer _updateTimer; private Timer _updateTimer;
private DateTimeOffset _lastUpdate = DateTimeOffset.FromUnixTimeSeconds(0); private DateTimeOffset _lastUpdate = DateTimeOffset.FromUnixTimeSeconds(0);
@ -28,9 +26,8 @@ public class RecordingsChannel : IChannel, IHasCacheKey, ISupportsDelete, ISuppo
private IEnumerable<MyRecordingInfo> _allRecordings; private IEnumerable<MyRecordingInfo> _allRecordings;
private bool _useCachedRecordings; private bool _useCachedRecordings;
public RecordingsChannel(ILiveTvManager liveTvManager) public RecordingsChannel()
{ {
_liveTvManager = liveTvManager;
var interval = TimeSpan.FromSeconds(20); var interval = TimeSpan.FromSeconds(20);
_updateTimer = new Timer(OnUpdateTimerCallbackAsync, null, interval, interval); _updateTimer = new Timer(OnUpdateTimerCallbackAsync, null, interval, interval);
if (_updateTimer != null) if (_updateTimer != null)
@ -116,7 +113,13 @@ public class RecordingsChannel : IChannel, IHasCacheKey, ISupportsDelete, ISuppo
private LiveTvService GetService() private LiveTvService GetService()
{ {
return _liveTvManager.Services.OfType<LiveTvService>().FirstOrDefault(); LiveTvService service = LiveTvService.Instance;
if (service is not null && !service.IsActive)
{
service.EnsureConnectionAsync(new System.Threading.CancellationToken(false)).Wait();
}
return service;
} }
public bool CanDelete(BaseItem item) public bool CanDelete(BaseItem item)
@ -338,12 +341,7 @@ public class RecordingsChannel : IChannel, IHasCacheKey, ISupportsDelete, ISuppo
private async void OnUpdateTimerCallbackAsync(object state) private async void OnUpdateTimerCallbackAsync(object state)
{ {
var service = GetService(); var service = GetService();
if (service is null) if (service is not null && service.IsActive)
{
return;
}
if (service.IsActive)
{ {
var backendUpdate = await service.GetLastUpdate(_cancellationToken.Token).ConfigureAwait(false); var backendUpdate = await service.GetLastUpdate(_cancellationToken.Token).ConfigureAwait(false);
if (backendUpdate > _lastUpdate) if (backendUpdate > _lastUpdate)

View File

@ -1,4 +1,4 @@
using System.IO; using System.IO;
using System.Text.Json; using System.Text.Json;
using System.Threading.Tasks; using System.Threading.Tasks;
using Jellyfin.Extensions.Json; using Jellyfin.Extensions.Json;
@ -24,7 +24,7 @@ public class CancelDeleteRecordingResponse
return false; return false;
} }
private class RootObject private sealed class RootObject
{ {
public string Stat { get; set; } public string Stat { get; set; }
} }

View File

@ -1,4 +1,4 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.Globalization; using System.Globalization;
using System.IO; using System.IO;
using System.Linq; using System.Linq;
@ -49,7 +49,7 @@ public class ChannelResponse
} }
// Classes created with http://json2csharp.com/ // Classes created with http://json2csharp.com/
private class Channel private sealed class Channel
{ {
public int ChannelId { get; set; } public int ChannelId { get; set; }
@ -68,7 +68,7 @@ public class ChannelResponse
public bool ChannelIcon { get; set; } public bool ChannelIcon { get; set; }
} }
private class RootObject private sealed class RootObject
{ {
public List<Channel> Channels { get; set; } public List<Channel> Channels { get; set; }
} }

View File

@ -1,4 +1,4 @@
using System.IO; using System.IO;
using System.Text.Json; using System.Text.Json;
using System.Threading.Tasks; using System.Threading.Tasks;
using Jellyfin.Extensions.Json; using Jellyfin.Extensions.Json;
@ -25,7 +25,7 @@ public class InitializeResponse
throw new JsonException("Failed to validate your connection with NextPVR."); throw new JsonException("Failed to validate your connection with NextPVR.");
} }
private class RootObject private sealed class RootObject
{ {
public string Stat { get; set; } public string Stat { get; set; }

View File

@ -19,11 +19,8 @@ public class LastUpdateResponse
UtilsHelper.DebugInformation(logger, $"[NextPVR] LastUpdate Response: {JsonSerializer.Serialize(root, _jsonOptions)}"); UtilsHelper.DebugInformation(logger, $"[NextPVR] LastUpdate Response: {JsonSerializer.Serialize(root, _jsonOptions)}");
return DateTimeOffset.FromUnixTimeSeconds(root.LastUpdate); return DateTimeOffset.FromUnixTimeSeconds(root.LastUpdate);
} }
}
// Classes created with http://json2csharp.com/ private sealed class RootObject
public class RootObject
{ {
[JsonPropertyName("last_update")] [JsonPropertyName("last_update")]
public int LastUpdate { get; set; } public int LastUpdate { get; set; }
@ -34,3 +31,4 @@ public class RootObject
public string Msg { get; set; } public string Msg { get; set; }
} }
}

View File

@ -79,7 +79,7 @@ public class ListingsResponse
// Classes created with http://json2csharp.com/ // Classes created with http://json2csharp.com/
private class Listing private sealed class Listing
{ {
public int Id { get; set; } public int Id { get; set; }
@ -114,7 +114,7 @@ public class ListingsResponse
public int RecordingId { get; set; } public int RecordingId { get; set; }
} }
private class RootObject private sealed class RootObject
{ {
public List<Listing> Listings { get; set; } public List<Listing> Listings { get; set; }
} }

View File

@ -98,7 +98,7 @@ public class RecordingResponse
} }
else else
{ {
info.Url = $"{_baseUrl}/live?recording={i.Id}"; info.Url = $"{_baseUrl}/live?recording={i.Id}&sid=jellyfin";
} }
} }
@ -215,7 +215,7 @@ public class RecordingResponse
return RecordingStatus.New; return RecordingStatus.New;
} }
private class Recording private sealed class Recording
{ {
public int Id { get; set; } public int Id { get; set; }
@ -280,7 +280,7 @@ public class RecordingResponse
public int? Year { get; set; } public int? Year { get; set; }
} }
private class RootObject private sealed class RootObject
{ {
public List<Recording> Recordings { get; set; } public List<Recording> Recordings { get; set; }
} }

View File

@ -1,4 +1,4 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Globalization; using System.Globalization;
using System.IO; using System.IO;
@ -12,7 +12,7 @@ using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.NextPVR.Responses; namespace Jellyfin.Plugin.NextPVR.Responses;
internal class RecurringResponse internal sealed class RecurringResponse
{ {
private readonly ILogger<LiveTvService> _logger; private readonly ILogger<LiveTvService> _logger;
private readonly JsonSerializerOptions _jsonOptions = JsonDefaults.CamelCaseOptions; private readonly JsonSerializerOptions _jsonOptions = JsonDefaults.CamelCaseOptions;
@ -70,7 +70,7 @@ internal class RecurringResponse
return info; return info;
} }
private class Recurring private sealed class Recurring
{ {
public int Id { get; set; } public int Id { get; set; }
@ -107,7 +107,7 @@ internal class RecurringResponse
public string AdvancedRules { get; set; } public string AdvancedRules { get; set; }
} }
private class RootObject private sealed class RootObject
{ {
public List<Recurring> Recurrings { get; set; } public List<Recurring> Recurrings { get; set; }
} }

View File

@ -1,4 +1,4 @@
using System.Globalization; using System.Globalization;
using System.IO; using System.IO;
using System.Text.Json; using System.Text.Json;
using System.Threading.Tasks; using System.Threading.Tasks;
@ -31,7 +31,7 @@ public class SettingResponse
// Classes created with http://json2csharp.com/ // Classes created with http://json2csharp.com/
private class ScheduleSettings private sealed class ScheduleSettings
{ {
public string Version { get; set; } public string Version { get; set; }
@ -78,7 +78,7 @@ public class SettingResponse
public int TimeEpoch { get; set; } public int TimeEpoch { get; set; }
} }
private class SettingValue private sealed class SettingValue
{ {
public string Value { get; set; } public string Value { get; set; }
} }

View File

@ -1,4 +1,4 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.Globalization; using System.Globalization;
using System.IO; using System.IO;
using System.Linq; using System.Linq;
@ -14,27 +14,29 @@ public class TunerResponse
{ {
private readonly JsonSerializerOptions _jsonOptions = JsonDefaults.CamelCaseOptions; private readonly JsonSerializerOptions _jsonOptions = JsonDefaults.CamelCaseOptions;
public async Task<List<LiveTvTunerInfo>> LiveTvTunerInfos(Stream stream) public async Task<List<TunerHostInfo>> LiveTvTunerInfo(Stream stream)
{ {
var root = await JsonSerializer.DeserializeAsync<RootObject>(stream, _jsonOptions).ConfigureAwait(false); var root = await JsonSerializer.DeserializeAsync<RootObject>(stream, _jsonOptions).ConfigureAwait(false);
return root.Tuners.Select(GetTunerInformation).ToList(); return root.Tuners.Select(GetTunerInformation).ToList();
} }
private LiveTvTunerInfo GetTunerInformation(Tuner i) private TunerHostInfo GetTunerInformation(Tuner i)
{ {
LiveTvTunerInfo tunerinfo = new LiveTvTunerInfo(); TunerHostInfo tunerinfo = new TunerHostInfo();
tunerinfo.Name = i.TunerName; tunerinfo.FriendlyName = i.TunerName;
/*
tunerinfo.Status = GetStatus(i); tunerinfo.Status = GetStatus(i);
if (i.Recordings.Count > 0) if (i.Recordings.Count > 0)
{ {
tunerinfo.ChannelId = i.Recordings.Single().Recording.ChannelOid.ToString(CultureInfo.InvariantCulture); tunerinfo.ChannelId = i.Recordings.Single().Recording.ChannelOid.ToString(CultureInfo.InvariantCulture);
} }
*/
return tunerinfo; return tunerinfo;
} }
/*
private LiveTvTunerStatus GetStatus(Tuner i) private LiveTvTunerStatus GetStatus(Tuner i)
{ {
if (i.Recordings.Count > 0) if (i.Recordings.Count > 0)
@ -49,8 +51,9 @@ public class TunerResponse
return LiveTvTunerStatus.Available; return LiveTvTunerStatus.Available;
} }
*/
private class Recording private sealed class Recording
{ {
public int TunerOid { get; set; } public int TunerOid { get; set; }
@ -61,12 +64,12 @@ public class TunerResponse
public int RecordingOid { get; set; } public int RecordingOid { get; set; }
} }
private class Recordings private sealed class Recordings
{ {
public Recording Recording { get; set; } public Recording Recording { get; set; }
} }
private class Tuner private sealed class Tuner
{ {
public string TunerName { get; set; } public string TunerName { get; set; }
@ -77,7 +80,7 @@ public class TunerResponse
public List<object> LiveTv { get; set; } public List<object> LiveTv { get; set; }
} }
private class RootObject private sealed class RootObject
{ {
public List<Tuner> Tuners { get; set; } public List<Tuner> Tuners { get; set; }
} }

View File

@ -0,0 +1,23 @@
using Jellyfin.Plugin.NextPVR;
using MediaBrowser.Controller;
using MediaBrowser.Controller.Authentication;
using MediaBrowser.Controller.Channels;
using MediaBrowser.Controller.LiveTv;
using MediaBrowser.Controller.Plugins;
using Microsoft.Extensions.DependencyInjection;
namespace Jellyfin.Plugin.NextPVR;
/// <summary>
/// Register NextPVR services.
/// </summary>
///
public class ServiceRegistrator : IPluginServiceRegistrator
{
/// <inheritdoc />
public void RegisterServices(IServiceCollection serviceCollection, IServerApplicationHost applicationHost)
{
serviceCollection.AddSingleton<ILiveTvService, LiveTvService>();
serviceCollection.AddSingleton<IChannel, RecordingsChannel>();
}
}

View File

@ -26,7 +26,7 @@ This plugin provides access to live TV, program guide, and recordings from a [Ne
## Build ## Build
1. To build this plugin you will need [.Net 5.x](https://dotnet.microsoft.com/download/dotnet/5.0). 1. To build this plugin you will need [.Net 8.x](https://dotnet.microsoft.com/download/dotnet/8.0).
2. Build plugin with following command 2. Build plugin with following command
``` ```

View File

@ -1,9 +1,9 @@
name: "NextPVR" name: "NextPVR"
guid: "9574ac10-bf23-49bc-949f-924f23cfa48f" guid: "9574ac10-bf23-49bc-949f-924f23cfa48f"
imageUrl: "https://repo.jellyfin.org/releases/plugin/images/jellyfin-plugin-nextpvr.png" imageUrl: "https://repo.jellyfin.org/releases/plugin/images/jellyfin-plugin-nextpvr.png"
version: "8" version: 9
targetAbi: "10.8.0.0" targetAbi: "10.9.0.0"
framework: "net6.0" framework: "net8.0"
overview: "Live TV plugin for NextPVR" overview: "Live TV plugin for NextPVR"
description: > description: >
Provides access to live TV, program guide, and recordings from NextPVR. Provides access to live TV, program guide, and recordings from NextPVR.