From 051fa04a803068b5fe8c86e1f991e85a9fbc4d04 Mon Sep 17 00:00:00 2001 From: Patrick Barron Date: Thu, 28 Dec 2023 14:34:44 -0500 Subject: Move GetRecordingStreamMediaSources to IMediaSourceManager --- .../Library/MediaSourceManager.cs | 35 +++++++++++++++++++++- .../LiveTv/EmbyTV/EmbyTV.cs | 32 -------------------- .../LiveTv/LiveTvMediaSourceProvider.cs | 2 +- 3 files changed, 35 insertions(+), 34 deletions(-) (limited to 'Emby.Server.Implementations') diff --git a/Emby.Server.Implementations/Library/MediaSourceManager.cs b/Emby.Server.Implementations/Library/MediaSourceManager.cs index 96fad9bca..68eccf311 100644 --- a/Emby.Server.Implementations/Library/MediaSourceManager.cs +++ b/Emby.Server.Implementations/Library/MediaSourceManager.cs @@ -11,14 +11,15 @@ using System.Linq; using System.Text.Json; using System.Threading; using System.Threading.Tasks; -using EasyCaching.Core.Configurations; using Jellyfin.Data.Entities; using Jellyfin.Data.Enums; using Jellyfin.Extensions.Json; using MediaBrowser.Common.Configuration; using MediaBrowser.Common.Extensions; +using MediaBrowser.Controller; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.LiveTv; using MediaBrowser.Controller.MediaEncoding; using MediaBrowser.Controller.Persistence; using MediaBrowser.Controller.Providers; @@ -37,6 +38,7 @@ namespace Emby.Server.Implementations.Library // Do not use a pipe here because Roku http requests to the server will fail, without any explicit error message. private const char LiveStreamIdDelimeter = '_'; + private readonly IServerApplicationHost _appHost; private readonly IItemRepository _itemRepo; private readonly IUserManager _userManager; private readonly ILibraryManager _libraryManager; @@ -55,6 +57,7 @@ namespace Emby.Server.Implementations.Library private IMediaSourceProvider[] _providers; public MediaSourceManager( + IServerApplicationHost appHost, IItemRepository itemRepo, IApplicationPaths applicationPaths, ILocalizationManager localizationManager, @@ -66,6 +69,7 @@ namespace Emby.Server.Implementations.Library IMediaEncoder mediaEncoder, IDirectoryService directoryService) { + _appHost = appHost; _itemRepo = itemRepo; _userManager = userManager; _libraryManager = libraryManager; @@ -799,6 +803,35 @@ namespace Emby.Server.Implementations.Library return result.Item1; } + public async Task> GetRecordingStreamMediaSources(ActiveRecordingInfo info, CancellationToken cancellationToken) + { + var stream = new MediaSourceInfo + { + EncoderPath = _appHost.GetApiUrlForLocalAccess() + "/LiveTv/LiveRecordings/" + info.Id + "/stream", + EncoderProtocol = MediaProtocol.Http, + Path = info.Path, + Protocol = MediaProtocol.File, + Id = info.Id, + SupportsDirectPlay = false, + SupportsDirectStream = true, + SupportsTranscoding = true, + IsInfiniteStream = true, + RequiresOpening = false, + RequiresClosing = false, + BufferMs = 0, + IgnoreDts = true, + IgnoreIndex = true + }; + + await new LiveStreamHelper(_mediaEncoder, _logger, _appPaths) + .AddMediaInfoWithProbe(stream, false, false, cancellationToken).ConfigureAwait(false); + + return new List + { + stream + }; + } + public async Task CloseLiveStream(string id) { ArgumentException.ThrowIfNullOrEmpty(id); diff --git a/Emby.Server.Implementations/LiveTv/EmbyTV/EmbyTV.cs b/Emby.Server.Implementations/LiveTv/EmbyTV/EmbyTV.cs index abe3ff349..e2e0dfb2b 100644 --- a/Emby.Server.Implementations/LiveTv/EmbyTV/EmbyTV.cs +++ b/Emby.Server.Implementations/LiveTv/EmbyTV/EmbyTV.cs @@ -47,7 +47,6 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV private const int TunerDiscoveryDurationMs = 3000; - private readonly IServerApplicationHost _appHost; private readonly ILogger _logger; private readonly IHttpClientFactory _httpClientFactory; private readonly IServerConfigurationManager _config; @@ -76,7 +75,6 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV private bool _disposed; public EmbyTV( - IServerApplicationHost appHost, IStreamHelper streamHelper, IMediaSourceManager mediaSourceManager, ILogger logger, @@ -91,7 +89,6 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV { Current = this; - _appHost = appHost; _logger = logger; _httpClientFactory = httpClientFactory; _config = config; @@ -1021,35 +1018,6 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV throw new NotImplementedException(); } - public async Task> GetRecordingStreamMediaSources(ActiveRecordingInfo info, CancellationToken cancellationToken) - { - var stream = new MediaSourceInfo - { - EncoderPath = _appHost.GetApiUrlForLocalAccess() + "/LiveTv/LiveRecordings/" + info.Id + "/stream", - EncoderProtocol = MediaProtocol.Http, - Path = info.Path, - Protocol = MediaProtocol.File, - Id = info.Id, - SupportsDirectPlay = false, - SupportsDirectStream = true, - SupportsTranscoding = true, - IsInfiniteStream = true, - RequiresOpening = false, - RequiresClosing = false, - BufferMs = 0, - IgnoreDts = true, - IgnoreIndex = true - }; - - await new LiveStreamHelper(_mediaEncoder, _logger, _config.CommonApplicationPaths) - .AddMediaInfoWithProbe(stream, false, false, cancellationToken).ConfigureAwait(false); - - return new List - { - stream - }; - } - public Task CloseLiveStream(string id, CancellationToken cancellationToken) { return Task.CompletedTask; diff --git a/Emby.Server.Implementations/LiveTv/LiveTvMediaSourceProvider.cs b/Emby.Server.Implementations/LiveTv/LiveTvMediaSourceProvider.cs index 6a92fc599..a5a1a4a4c 100644 --- a/Emby.Server.Implementations/LiveTv/LiveTvMediaSourceProvider.cs +++ b/Emby.Server.Implementations/LiveTv/LiveTvMediaSourceProvider.cs @@ -61,7 +61,7 @@ namespace Emby.Server.Implementations.LiveTv { if (activeRecordingInfo is not null) { - sources = await EmbyTV.EmbyTV.Current.GetRecordingStreamMediaSources(activeRecordingInfo, cancellationToken) + sources = await _mediaSourceManager.GetRecordingStreamMediaSources(activeRecordingInfo, cancellationToken) .ConfigureAwait(false); } else -- cgit v1.2.3 From 7eba162879f6d1ff04539cac5c0d6372a955d82b Mon Sep 17 00:00:00 2001 From: Patrick Barron Date: Thu, 28 Dec 2023 14:49:35 -0500 Subject: Move LiveTv tests to separate project --- .../Properties/AssemblyInfo.cs | 1 + Jellyfin.sln | 7 + tests/Jellyfin.LiveTv.Tests/HdHomerunHostTests.cs | 156 ++++++++++ .../Jellyfin.LiveTv.Tests/HdHomerunManagerTests.cs | 326 +++++++++++++++++++++ .../Jellyfin.LiveTv.Tests.csproj | 29 ++ .../Listings/XmlTvListingsProviderTests.cs | 89 ++++++ .../Jellyfin.LiveTv.Tests/RecordingHelperTests.cs | 109 +++++++ .../SchedulesDirectDeserializeTests.cs | 240 +++++++++++++++ .../Test Data/LiveTv/10.10.10.100/discover.json | 1 + .../Test Data/LiveTv/10.10.10.100/lineup.json | 1 + .../Test Data/LiveTv/192.168.1.182/discover.json | 1 + .../Test Data/LiveTv/192.168.1.182/lineup.json | 1 + .../LiveTv/Listings/XmlTv/emptycategory.xml | 6 + .../Test Data/LiveTv/Listings/XmlTv/notitle.xml | 10 + .../SchedulesDirect/headends_response.json | 1 + .../Test Data/SchedulesDirect/lineup_response.json | 1 + .../SchedulesDirect/lineups_response.json | 1 + .../metadata_programs_response.json | 1 + .../SchedulesDirect/programs_response.json | 1 + .../SchedulesDirect/schedules_request.json | 1 + .../SchedulesDirect/schedules_response.json | 1 + .../SchedulesDirect/token_live_response.json | 1 + .../SchedulesDirect/token_offline_response.json | 1 + .../LiveTv/HdHomerunHostTests.cs | 156 ---------- .../LiveTv/HdHomerunManagerTests.cs | 326 --------------------- .../LiveTv/Listings/XmlTvListingsProviderTests.cs | 89 ------ .../LiveTv/RecordingHelperTests.cs | 109 ------- .../SchedulesDirectDeserializeTests.cs | 240 --------------- .../Test Data/LiveTv/10.10.10.100/discover.json | 1 - .../Test Data/LiveTv/10.10.10.100/lineup.json | 1 - .../Test Data/LiveTv/192.168.1.182/discover.json | 1 - .../Test Data/LiveTv/192.168.1.182/lineup.json | 1 - .../LiveTv/Listings/XmlTv/emptycategory.xml | 6 - .../Test Data/LiveTv/Listings/XmlTv/notitle.xml | 10 - .../SchedulesDirect/headends_response.json | 1 - .../Test Data/SchedulesDirect/lineup_response.json | 1 - .../SchedulesDirect/lineups_response.json | 1 - .../metadata_programs_response.json | 1 - .../SchedulesDirect/programs_response.json | 1 - .../SchedulesDirect/schedules_request.json | 1 - .../SchedulesDirect/schedules_response.json | 1 - .../SchedulesDirect/token_live_response.json | 1 - .../SchedulesDirect/token_offline_response.json | 1 - 43 files changed, 986 insertions(+), 949 deletions(-) create mode 100644 tests/Jellyfin.LiveTv.Tests/HdHomerunHostTests.cs create mode 100644 tests/Jellyfin.LiveTv.Tests/HdHomerunManagerTests.cs create mode 100644 tests/Jellyfin.LiveTv.Tests/Jellyfin.LiveTv.Tests.csproj create mode 100644 tests/Jellyfin.LiveTv.Tests/Listings/XmlTvListingsProviderTests.cs create mode 100644 tests/Jellyfin.LiveTv.Tests/RecordingHelperTests.cs create mode 100644 tests/Jellyfin.LiveTv.Tests/SchedulesDirect/SchedulesDirectDeserializeTests.cs create mode 100644 tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/10.10.10.100/discover.json create mode 100644 tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/10.10.10.100/lineup.json create mode 100644 tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/192.168.1.182/discover.json create mode 100644 tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/192.168.1.182/lineup.json create mode 100644 tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/emptycategory.xml create mode 100644 tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/notitle.xml create mode 100644 tests/Jellyfin.LiveTv.Tests/Test Data/SchedulesDirect/headends_response.json create mode 100644 tests/Jellyfin.LiveTv.Tests/Test Data/SchedulesDirect/lineup_response.json create mode 100644 tests/Jellyfin.LiveTv.Tests/Test Data/SchedulesDirect/lineups_response.json create mode 100644 tests/Jellyfin.LiveTv.Tests/Test Data/SchedulesDirect/metadata_programs_response.json create mode 100644 tests/Jellyfin.LiveTv.Tests/Test Data/SchedulesDirect/programs_response.json create mode 100644 tests/Jellyfin.LiveTv.Tests/Test Data/SchedulesDirect/schedules_request.json create mode 100644 tests/Jellyfin.LiveTv.Tests/Test Data/SchedulesDirect/schedules_response.json create mode 100644 tests/Jellyfin.LiveTv.Tests/Test Data/SchedulesDirect/token_live_response.json create mode 100644 tests/Jellyfin.LiveTv.Tests/Test Data/SchedulesDirect/token_offline_response.json delete mode 100644 tests/Jellyfin.Server.Implementations.Tests/LiveTv/HdHomerunHostTests.cs delete mode 100644 tests/Jellyfin.Server.Implementations.Tests/LiveTv/HdHomerunManagerTests.cs delete mode 100644 tests/Jellyfin.Server.Implementations.Tests/LiveTv/Listings/XmlTvListingsProviderTests.cs delete mode 100644 tests/Jellyfin.Server.Implementations.Tests/LiveTv/RecordingHelperTests.cs delete mode 100644 tests/Jellyfin.Server.Implementations.Tests/LiveTv/SchedulesDirect/SchedulesDirectDeserializeTests.cs delete mode 100644 tests/Jellyfin.Server.Implementations.Tests/Test Data/LiveTv/10.10.10.100/discover.json delete mode 100644 tests/Jellyfin.Server.Implementations.Tests/Test Data/LiveTv/10.10.10.100/lineup.json delete mode 100644 tests/Jellyfin.Server.Implementations.Tests/Test Data/LiveTv/192.168.1.182/discover.json delete mode 100644 tests/Jellyfin.Server.Implementations.Tests/Test Data/LiveTv/192.168.1.182/lineup.json delete mode 100644 tests/Jellyfin.Server.Implementations.Tests/Test Data/LiveTv/Listings/XmlTv/emptycategory.xml delete mode 100644 tests/Jellyfin.Server.Implementations.Tests/Test Data/LiveTv/Listings/XmlTv/notitle.xml delete mode 100644 tests/Jellyfin.Server.Implementations.Tests/Test Data/SchedulesDirect/headends_response.json delete mode 100644 tests/Jellyfin.Server.Implementations.Tests/Test Data/SchedulesDirect/lineup_response.json delete mode 100644 tests/Jellyfin.Server.Implementations.Tests/Test Data/SchedulesDirect/lineups_response.json delete mode 100644 tests/Jellyfin.Server.Implementations.Tests/Test Data/SchedulesDirect/metadata_programs_response.json delete mode 100644 tests/Jellyfin.Server.Implementations.Tests/Test Data/SchedulesDirect/programs_response.json delete mode 100644 tests/Jellyfin.Server.Implementations.Tests/Test Data/SchedulesDirect/schedules_request.json delete mode 100644 tests/Jellyfin.Server.Implementations.Tests/Test Data/SchedulesDirect/schedules_response.json delete mode 100644 tests/Jellyfin.Server.Implementations.Tests/Test Data/SchedulesDirect/token_live_response.json delete mode 100644 tests/Jellyfin.Server.Implementations.Tests/Test Data/SchedulesDirect/token_offline_response.json (limited to 'Emby.Server.Implementations') diff --git a/Emby.Server.Implementations/Properties/AssemblyInfo.cs b/Emby.Server.Implementations/Properties/AssemblyInfo.cs index 41c396ac1..fb7377b1d 100644 --- a/Emby.Server.Implementations/Properties/AssemblyInfo.cs +++ b/Emby.Server.Implementations/Properties/AssemblyInfo.cs @@ -17,6 +17,7 @@ using System.Runtime.InteropServices; [assembly: NeutralResourcesLanguage("en")] [assembly: InternalsVisibleTo("Jellyfin.Server.Implementations.Tests")] [assembly: InternalsVisibleTo("Emby.Server.Implementations.Fuzz")] +[assembly: InternalsVisibleTo("Jellyfin.LiveTv.Tests")] // Setting ComVisible to false makes the types in this assembly not visible // to COM components. If you need to access a type in this assembly from diff --git a/Jellyfin.sln b/Jellyfin.sln index 4385ac241..31e302d94 100644 --- a/Jellyfin.sln +++ b/Jellyfin.sln @@ -87,6 +87,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jellyfin.MediaEncoding.Hls. EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jellyfin.MediaEncoding.Keyframes.Tests", "tests\Jellyfin.MediaEncoding.Keyframes.Tests\Jellyfin.MediaEncoding.Keyframes.Tests.csproj", "{24960660-DE6C-47BF-AEEF-CEE8F19FE6C2}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jellyfin.LiveTv.Tests", "tests\Jellyfin.LiveTv.Tests\Jellyfin.LiveTv.Tests.csproj", "{C4F71272-C6BE-4C30-BE0D-4E6ED651D6D3}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -233,6 +235,10 @@ Global {24960660-DE6C-47BF-AEEF-CEE8F19FE6C2}.Debug|Any CPU.Build.0 = Debug|Any CPU {24960660-DE6C-47BF-AEEF-CEE8F19FE6C2}.Release|Any CPU.ActiveCfg = Release|Any CPU {24960660-DE6C-47BF-AEEF-CEE8F19FE6C2}.Release|Any CPU.Build.0 = Release|Any CPU + {C4F71272-C6BE-4C30-BE0D-4E6ED651D6D3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C4F71272-C6BE-4C30-BE0D-4E6ED651D6D3}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C4F71272-C6BE-4C30-BE0D-4E6ED651D6D3}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C4F71272-C6BE-4C30-BE0D-4E6ED651D6D3}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -259,6 +265,7 @@ Global {08FFF49B-F175-4807-A2B5-73B0EBD9F716} = {C9F0AB5D-F4D7-40C8-A353-3305C86D6D4C} {154872D9-6C12-4007-96E3-8F70A58386CE} = {C9F0AB5D-F4D7-40C8-A353-3305C86D6D4C} {0A3FCC4D-C714-4072-B90F-E374A15F9FF9} = {C9F0AB5D-F4D7-40C8-A353-3305C86D6D4C} + {C4F71272-C6BE-4C30-BE0D-4E6ED651D6D3} = {FBBB5129-006E-4AD7-BAD5-8B7CA1D10ED6} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {3448830C-EBDC-426C-85CD-7BBB9651A7FE} diff --git a/tests/Jellyfin.LiveTv.Tests/HdHomerunHostTests.cs b/tests/Jellyfin.LiveTv.Tests/HdHomerunHostTests.cs new file mode 100644 index 000000000..bc4b2da5b --- /dev/null +++ b/tests/Jellyfin.LiveTv.Tests/HdHomerunHostTests.cs @@ -0,0 +1,156 @@ +using System; +using System.IO; +using System.Net.Http; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using AutoFixture; +using AutoFixture.AutoMoq; +using Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun; +using MediaBrowser.Model.LiveTv; +using Moq; +using Moq.Protected; +using Xunit; + +namespace Jellyfin.LiveTv.Tests +{ + public class HdHomerunHostTests + { + private readonly Fixture _fixture; + private readonly HdHomerunHost _hdHomerunHost; + + public HdHomerunHostTests() + { + var messageHandler = new Mock(); + messageHandler.Protected() + .Setup>("SendAsync", ItExpr.IsAny(), ItExpr.IsAny()) + .Returns( + (m, _) => + { + return Task.FromResult(new HttpResponseMessage() + { + Content = new StreamContent(File.OpenRead(Path.Combine("Test Data/LiveTv", m.RequestUri!.Host, m.RequestUri.Segments[^1]))) + }); + }); + + var http = new Mock(); + http.Setup(x => x.CreateClient(It.IsAny())) + .Returns(new HttpClient(messageHandler.Object)); + _fixture = new Fixture(); + _fixture.Customize(new AutoMoqCustomization + { + ConfigureMembers = true + }).Inject(http); + _hdHomerunHost = _fixture.Create(); + } + + [Fact] + public async Task GetModelInfo_Valid_Success() + { + var host = new TunerHostInfo() + { + Url = "192.168.1.182" + }; + + var modelInfo = await _hdHomerunHost.GetModelInfo(host, true, CancellationToken.None); + Assert.Equal("HDHomeRun PRIME", modelInfo.FriendlyName); + Assert.Equal("HDHR3-CC", modelInfo.ModelNumber); + Assert.Equal("hdhomerun3_cablecard", modelInfo.FirmwareName); + Assert.Equal("20160630atest2", modelInfo.FirmwareVersion); + Assert.Equal("FFFFFFFF", modelInfo.DeviceID); + Assert.Equal("FFFFFFFF", modelInfo.DeviceAuth); + Assert.Equal(3, modelInfo.TunerCount); + Assert.Equal("http://192.168.1.182:80", modelInfo.BaseURL); + Assert.Equal("http://192.168.1.182:80/lineup.json", modelInfo.LineupURL); + } + + [Fact] + public async Task GetModelInfo_Legacy_Success() + { + var host = new TunerHostInfo() + { + Url = "10.10.10.100" + }; + + var modelInfo = await _hdHomerunHost.GetModelInfo(host, true, CancellationToken.None); + Assert.Equal("HDHomeRun DUAL", modelInfo.FriendlyName); + Assert.Equal("HDHR3-US", modelInfo.ModelNumber); + Assert.Equal("hdhomerun3_atsc", modelInfo.FirmwareName); + Assert.Equal("20200225", modelInfo.FirmwareVersion); + Assert.Equal("10xxxxx5", modelInfo.DeviceID); + Assert.Null(modelInfo.DeviceAuth); + Assert.Equal(2, modelInfo.TunerCount); + Assert.Equal("http://10.10.10.100:80", modelInfo.BaseURL); + Assert.Null(modelInfo.LineupURL); + } + + [Fact] + public async Task GetModelInfo_EmptyUrl_ArgumentException() + { + var host = new TunerHostInfo() + { + Url = string.Empty + }; + + await Assert.ThrowsAsync(() => _hdHomerunHost.GetModelInfo(host, true, CancellationToken.None)); + } + + [Fact] + public async Task GetLineup_Valid_Success() + { + var host = new TunerHostInfo() + { + Url = "192.168.1.182" + }; + + var channels = await _hdHomerunHost.GetLineup(host, CancellationToken.None); + Assert.Equal(6, channels.Count); + Assert.Equal("4.1", channels[0].GuideNumber); + Assert.Equal("WCMH-DT", channels[0].GuideName); + Assert.True(channels[0].HD); + Assert.True(channels[0].Favorite); + Assert.Equal("http://192.168.1.111:5004/auto/v4.1", channels[0].URL); + } + + [Fact] + public async Task GetLineup_Legacy_Success() + { + var host = new TunerHostInfo() + { + Url = "10.10.10.100" + }; + + // Placeholder json is invalid, just need to make sure we can reach it + await Assert.ThrowsAsync(() => _hdHomerunHost.GetLineup(host, CancellationToken.None)); + } + + [Fact] + public async Task GetLineup_ImportFavoritesOnly_Success() + { + var host = new TunerHostInfo() + { + Url = "192.168.1.182", + ImportFavoritesOnly = true + }; + + var channels = await _hdHomerunHost.GetLineup(host, CancellationToken.None); + Assert.Single(channels); + Assert.Equal("4.1", channels[0].GuideNumber); + Assert.Equal("WCMH-DT", channels[0].GuideName); + Assert.True(channels[0].HD); + Assert.True(channels[0].Favorite); + Assert.Equal("http://192.168.1.111:5004/auto/v4.1", channels[0].URL); + } + + [Fact] + public async Task TryGetTunerHostInfo_Valid_Success() + { + var host = await _hdHomerunHost.TryGetTunerHostInfo("192.168.1.182", CancellationToken.None); + Assert.Equal(_hdHomerunHost.Type, host.Type); + Assert.Equal("192.168.1.182", host.Url); + Assert.Equal("HDHomeRun PRIME", host.FriendlyName); + Assert.Equal("FFFFFFFF", host.DeviceId); + Assert.Equal(3, host.TunerCount); + } + } +} diff --git a/tests/Jellyfin.LiveTv.Tests/HdHomerunManagerTests.cs b/tests/Jellyfin.LiveTv.Tests/HdHomerunManagerTests.cs new file mode 100644 index 000000000..fcebc0ff7 --- /dev/null +++ b/tests/Jellyfin.LiveTv.Tests/HdHomerunManagerTests.cs @@ -0,0 +1,326 @@ +using System; +using System.Text; +using Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun; +using Xunit; + +namespace Jellyfin.LiveTv.Tests +{ + public class HdHomerunManagerTests + { + [Fact] + public void WriteNullTerminatedString_Empty_Success() + { + ReadOnlySpan expected = stackalloc byte[] + { + 1, 0 + }; + + Span buffer = stackalloc byte[128]; + int len = HdHomerunManager.WriteNullTerminatedString(buffer, string.Empty); + + Assert.Equal( + Convert.ToHexString(expected), + Convert.ToHexString(buffer.Slice(0, len))); + } + + [Fact] + public void WriteNullTerminatedString_Valid_Success() + { + ReadOnlySpan expected = stackalloc byte[] + { + 10, (byte)'T', (byte)'h', (byte)'e', (byte)' ', (byte)'q', (byte)'u', (byte)'i', (byte)'c', (byte)'k', 0 + }; + + Span buffer = stackalloc byte[128]; + int len = HdHomerunManager.WriteNullTerminatedString(buffer, "The quick"); + + Assert.Equal( + Convert.ToHexString(expected), + Convert.ToHexString(buffer.Slice(0, len))); + } + + [Fact] + public void WriteGetMessage_Valid_Success() + { + ReadOnlySpan expected = stackalloc byte[] + { + 0, 4, + 0, 12, + 3, + 10, (byte)'/', (byte)'t', (byte)'u', (byte)'n', (byte)'e', (byte)'r', (byte)'0', (byte)'/', (byte)'N', 0, + 0xc0, 0xc9, 0x87, 0x33 + }; + + Span buffer = stackalloc byte[128]; + int len = HdHomerunManager.WriteGetMessage(buffer, 0, "N"); + + Assert.Equal( + Convert.ToHexString(expected), + Convert.ToHexString(buffer.Slice(0, len))); + } + + [Fact] + public void WriteSetMessage_NoLockKey_Success() + { + ReadOnlySpan expected = stackalloc byte[] + { + 0, 4, + 0, 20, + 3, + 10, (byte)'/', (byte)'t', (byte)'u', (byte)'n', (byte)'e', (byte)'r', (byte)'0', (byte)'/', (byte)'N', 0, + 4, + 6, (byte)'v', (byte)'a', (byte)'l', (byte)'u', (byte)'e', 0, + 0xa9, 0x49, 0xd0, 0x68 + }; + + Span buffer = stackalloc byte[128]; + int len = HdHomerunManager.WriteSetMessage(buffer, 0, "N", "value", null); + + Assert.Equal( + Convert.ToHexString(expected), + Convert.ToHexString(buffer.Slice(0, len))); + } + + [Fact] + public void WriteSetMessage_LockKey_Success() + { + ReadOnlySpan expected = stackalloc byte[] + { + 0, 4, + 0, 26, + 3, + 10, (byte)'/', (byte)'t', (byte)'u', (byte)'n', (byte)'e', (byte)'r', (byte)'0', (byte)'/', (byte)'N', 0, + 4, + 6, (byte)'v', (byte)'a', (byte)'l', (byte)'u', (byte)'e', 0, + 21, + 4, 0x00, 0x01, 0x38, 0xd5, + 0x8e, 0xb6, 0x06, 0x82 + }; + + Span buffer = stackalloc byte[128]; + int len = HdHomerunManager.WriteSetMessage(buffer, 0, "N", "value", 80085); + + Assert.Equal( + Convert.ToHexString(expected), + Convert.ToHexString(buffer.Slice(0, len))); + } + + [Fact] + public void TryGetReturnValueOfGetSet_Valid_Success() + { + ReadOnlySpan packet = new byte[] + { + 0, 5, + 0, 20, + 3, + 10, (byte)'/', (byte)'t', (byte)'u', (byte)'n', (byte)'e', (byte)'r', (byte)'0', (byte)'/', (byte)'N', 0, + 4, + 6, (byte)'v', (byte)'a', (byte)'l', (byte)'u', (byte)'e', 0, + 0x7d, 0xa3, 0xa3, 0xf3 + }; + + Assert.True(HdHomerunManager.TryGetReturnValueOfGetSet(packet, out var value)); + Assert.Equal("value", Encoding.UTF8.GetString(value)); + } + + [Fact] + public void TryGetReturnValueOfGetSet_InvalidCrc_False() + { + ReadOnlySpan packet = new byte[] + { + 0, 5, + 0, 20, + 3, + 10, (byte)'/', (byte)'t', (byte)'u', (byte)'n', (byte)'e', (byte)'r', (byte)'0', (byte)'/', (byte)'N', 0, + 4, + 6, (byte)'v', (byte)'a', (byte)'l', (byte)'u', (byte)'e', 0, + 0x7d, 0xa3, 0xa3, 0xf4 + }; + + Assert.False(HdHomerunManager.TryGetReturnValueOfGetSet(packet, out _)); + } + + [Fact] + public void TryGetReturnValueOfGetSet_InvalidPacketType_False() + { + ReadOnlySpan packet = new byte[] + { + 0, 4, + 0, 20, + 3, + 10, (byte)'/', (byte)'t', (byte)'u', (byte)'n', (byte)'e', (byte)'r', (byte)'0', (byte)'/', (byte)'N', 0, + 4, + 6, (byte)'v', (byte)'a', (byte)'l', (byte)'u', (byte)'e', 0, + 0xa9, 0x49, 0xd0, 0x68 + }; + + Assert.False(HdHomerunManager.TryGetReturnValueOfGetSet(packet, out _)); + } + + [Fact] + public void TryGetReturnValueOfGetSet_InvalidPacket_False() + { + ReadOnlySpan packet = new byte[] + { + 0, 5, + 0, 20, + 0x7d, 0xa3, 0xa3 + }; + + Assert.False(HdHomerunManager.TryGetReturnValueOfGetSet(packet, out _)); + } + + [Fact] + public void TryGetReturnValueOfGetSet_TooSmallMessageLength_False() + { + ReadOnlySpan packet = new byte[] + { + 0, 5, + 0, 19, + 3, + 10, (byte)'/', (byte)'t', (byte)'u', (byte)'n', (byte)'e', (byte)'r', (byte)'0', (byte)'/', (byte)'N', 0, + 4, + 6, (byte)'v', (byte)'a', (byte)'l', (byte)'u', (byte)'e', 0, + 0x25, 0x25, 0x44, 0x9a + }; + + Assert.False(HdHomerunManager.TryGetReturnValueOfGetSet(packet, out _)); + } + + [Fact] + public void TryGetReturnValueOfGetSet_TooLargeMessageLength_False() + { + ReadOnlySpan packet = new byte[] + { + 0, 5, + 0, 21, + 3, + 10, (byte)'/', (byte)'t', (byte)'u', (byte)'n', (byte)'e', (byte)'r', (byte)'0', (byte)'/', (byte)'N', 0, + 4, + 6, (byte)'v', (byte)'a', (byte)'l', (byte)'u', (byte)'e', 0, + 0xe3, 0x20, 0x79, 0x6c + }; + + Assert.False(HdHomerunManager.TryGetReturnValueOfGetSet(packet, out _)); + } + + [Fact] + public void TryGetReturnValueOfGetSet_TooLargeNameLength_False() + { + ReadOnlySpan packet = new byte[] + { + 0, 5, + 0, 20, + 3, + 20, (byte)'/', (byte)'t', (byte)'u', (byte)'n', (byte)'e', (byte)'r', (byte)'0', (byte)'/', (byte)'N', 0, + 4, + 6, (byte)'v', (byte)'a', (byte)'l', (byte)'u', (byte)'e', 0, + 0xe1, 0x8e, 0x9c, 0x74 + }; + + Assert.False(HdHomerunManager.TryGetReturnValueOfGetSet(packet, out _)); + } + + [Fact] + public void TryGetReturnValueOfGetSet_InvalidGetSetNameTag_False() + { + ReadOnlySpan packet = new byte[] + { + 0, 5, + 0, 20, + 4, + 10, (byte)'/', (byte)'t', (byte)'u', (byte)'n', (byte)'e', (byte)'r', (byte)'0', (byte)'/', (byte)'N', 0, + 4, + 6, (byte)'v', (byte)'a', (byte)'l', (byte)'u', (byte)'e', 0, + 0xee, 0x05, 0xe7, 0x12 + }; + + Assert.False(HdHomerunManager.TryGetReturnValueOfGetSet(packet, out _)); + } + + [Fact] + public void TryGetReturnValueOfGetSet_InvalidGetSetValueTag_False() + { + ReadOnlySpan packet = new byte[] + { + 0, 5, + 0, 20, + 3, + 10, (byte)'/', (byte)'t', (byte)'u', (byte)'n', (byte)'e', (byte)'r', (byte)'0', (byte)'/', (byte)'N', 0, + 3, + 6, (byte)'v', (byte)'a', (byte)'l', (byte)'u', (byte)'e', 0, + 0x64, 0xaa, 0x66, 0xf9 + }; + + Assert.False(HdHomerunManager.TryGetReturnValueOfGetSet(packet, out _)); + } + + [Fact] + public void TryGetReturnValueOfGetSet_TooLargeValueLength_False() + { + ReadOnlySpan packet = new byte[] + { + 0, 5, + 0, 20, + 3, + 10, (byte)'/', (byte)'t', (byte)'u', (byte)'n', (byte)'e', (byte)'r', (byte)'0', (byte)'/', (byte)'N', 0, + 4, + 7, (byte)'v', (byte)'a', (byte)'l', (byte)'u', (byte)'e', 0, + 0xc9, 0xa8, 0xd4, 0x55 + }; + + Assert.False(HdHomerunManager.TryGetReturnValueOfGetSet(packet, out _)); + } + + [Fact] + public void VerifyReturnValueOfGetSet_Valid_True() + { + ReadOnlySpan packet = new byte[] + { + 0, 5, + 0, 20, + 3, + 10, (byte)'/', (byte)'t', (byte)'u', (byte)'n', (byte)'e', (byte)'r', (byte)'0', (byte)'/', (byte)'N', 0, + 4, + 6, (byte)'v', (byte)'a', (byte)'l', (byte)'u', (byte)'e', 0, + 0x7d, 0xa3, 0xa3, 0xf3 + }; + + Assert.True(HdHomerunManager.VerifyReturnValueOfGetSet(packet, "value")); + } + + [Fact] + public void VerifyReturnValueOfGetSet_WrongValue_False() + { + ReadOnlySpan packet = new byte[] + { + 0, 5, + 0, 20, + 3, + 10, (byte)'/', (byte)'t', (byte)'u', (byte)'n', (byte)'e', (byte)'r', (byte)'0', (byte)'/', (byte)'N', 0, + 4, + 6, (byte)'v', (byte)'a', (byte)'l', (byte)'u', (byte)'e', 0, + 0x7d, 0xa3, 0xa3, 0xf3 + }; + + Assert.False(HdHomerunManager.VerifyReturnValueOfGetSet(packet, "none")); + } + + [Fact] + public void VerifyReturnValueOfGetSet_InvalidPacket_False() + { + ReadOnlySpan packet = new byte[] + { + 0, 4, + 0, 20, + 3, + 10, (byte)'/', (byte)'t', (byte)'u', (byte)'n', (byte)'e', (byte)'r', (byte)'0', (byte)'/', (byte)'N', 0, + 4, + 6, (byte)'v', (byte)'a', (byte)'l', (byte)'u', (byte)'e', 0, + 0x7d, 0xa3, 0xa3, 0xf3 + }; + + Assert.False(HdHomerunManager.VerifyReturnValueOfGetSet(packet, "value")); + } + } +} diff --git a/tests/Jellyfin.LiveTv.Tests/Jellyfin.LiveTv.Tests.csproj b/tests/Jellyfin.LiveTv.Tests/Jellyfin.LiveTv.Tests.csproj new file mode 100644 index 000000000..de448bada --- /dev/null +++ b/tests/Jellyfin.LiveTv.Tests/Jellyfin.LiveTv.Tests.csproj @@ -0,0 +1,29 @@ + + + net8.0 + + + + + PreserveNewest + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + diff --git a/tests/Jellyfin.LiveTv.Tests/Listings/XmlTvListingsProviderTests.cs b/tests/Jellyfin.LiveTv.Tests/Listings/XmlTvListingsProviderTests.cs new file mode 100644 index 000000000..71d135735 --- /dev/null +++ b/tests/Jellyfin.LiveTv.Tests/Listings/XmlTvListingsProviderTests.cs @@ -0,0 +1,89 @@ +using System; +using System.IO; +using System.Linq; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using AutoFixture; +using AutoFixture.AutoMoq; +using Emby.Server.Implementations.LiveTv.Listings; +using MediaBrowser.Model.LiveTv; +using Moq; +using Moq.Protected; +using Xunit; + +namespace Jellyfin.LiveTv.Tests.Listings; + +public class XmlTvListingsProviderTests +{ + private readonly Fixture _fixture; + private readonly XmlTvListingsProvider _xmlTvListingsProvider; + + public XmlTvListingsProviderTests() + { + var messageHandler = new Mock(); + messageHandler.Protected() + .Setup>("SendAsync", ItExpr.IsAny(), ItExpr.IsAny()) + .Returns( + (m, _) => + { + return Task.FromResult(new HttpResponseMessage() + { + Content = new StreamContent(File.OpenRead(Path.Combine("Test Data/LiveTv/Listings/XmlTv", m.RequestUri!.Segments[^1]))) + }); + }); + + var http = new Mock(); + http.Setup(x => x.CreateClient(It.IsAny())) + .Returns(new HttpClient(messageHandler.Object)); + _fixture = new Fixture(); + _fixture.Customize(new AutoMoqCustomization + { + ConfigureMembers = true + }).Inject(http); + _xmlTvListingsProvider = _fixture.Create(); + } + + [Theory] + [InlineData("Test Data/LiveTv/Listings/XmlTv/notitle.xml")] + [InlineData("https://example.com/notitle.xml")] + public async Task GetProgramsAsync_NoTitle_Success(string path) + { + var info = new ListingsProviderInfo() + { + Path = path + }; + + var startDate = new DateTime(2022, 11, 4); + var programs = await _xmlTvListingsProvider.GetProgramsAsync(info, "3297", startDate, startDate.AddDays(1), CancellationToken.None); + var programsList = programs.ToList(); + Assert.Single(programsList); + var program = programsList[0]; + Assert.Null(program.Name); + Assert.Null(program.SeriesId); + Assert.Null(program.EpisodeTitle); + Assert.True(program.IsSports); + Assert.True(program.HasImage); + Assert.Equal("https://domain.tld/image.png", program.ImageUrl); + Assert.Equal("3297", program.ChannelId); + } + + [Theory] + [InlineData("Test Data/LiveTv/Listings/XmlTv/emptycategory.xml")] + [InlineData("https://example.com/emptycategory.xml")] + public async Task GetProgramsAsync_EmptyCategories_Success(string path) + { + var info = new ListingsProviderInfo() + { + Path = path + }; + + var startDate = new DateTime(2022, 11, 4); + var programs = await _xmlTvListingsProvider.GetProgramsAsync(info, "3297", startDate, startDate.AddDays(1), CancellationToken.None); + var programsList = programs.ToList(); + Assert.Single(programsList); + var program = programsList[0]; + Assert.DoesNotContain(program.Genres, g => string.IsNullOrEmpty(g)); + Assert.Equal("3297", program.ChannelId); + } +} diff --git a/tests/Jellyfin.LiveTv.Tests/RecordingHelperTests.cs b/tests/Jellyfin.LiveTv.Tests/RecordingHelperTests.cs new file mode 100644 index 000000000..a5941e7f0 --- /dev/null +++ b/tests/Jellyfin.LiveTv.Tests/RecordingHelperTests.cs @@ -0,0 +1,109 @@ +using System; +using Emby.Server.Implementations.LiveTv.EmbyTV; +using MediaBrowser.Controller.LiveTv; +using Xunit; + +namespace Jellyfin.LiveTv.Tests +{ + public static class RecordingHelperTests + { + public static TheoryData GetRecordingName_Success_TestData() + { + var data = new TheoryData(); + + data.Add( + "The Incredibles 2020_04_20_21_06_00", + new TimerInfo + { + Name = "The Incredibles", + StartDate = new DateTime(2020, 4, 20, 21, 6, 0, DateTimeKind.Local), + IsMovie = true + }); + + data.Add( + "The Incredibles (2004)", + new TimerInfo + { + Name = "The Incredibles", + IsMovie = true, + ProductionYear = 2004 + }); + data.Add( + "The Big Bang Theory 2020_04_20_21_06_00", + new TimerInfo + { + Name = "The Big Bang Theory", + StartDate = new DateTime(2020, 4, 20, 21, 6, 0, DateTimeKind.Local), + IsProgramSeries = true, + }); + data.Add( + "The Big Bang Theory S12E10", + new TimerInfo + { + Name = "The Big Bang Theory", + IsProgramSeries = true, + SeasonNumber = 12, + EpisodeNumber = 10 + }); + data.Add( + "The Big Bang Theory S12E10 The VCR Illumination", + new TimerInfo + { + Name = "The Big Bang Theory", + IsProgramSeries = true, + SeasonNumber = 12, + EpisodeNumber = 10, + EpisodeTitle = "The VCR Illumination" + }); + data.Add( + "The Big Bang Theory 2018-12-06", + new TimerInfo + { + Name = "The Big Bang Theory", + IsProgramSeries = true, + OriginalAirDate = new DateTime(2018, 12, 6, 0, 0, 0, DateTimeKind.Local) + }); + + data.Add( + "The Big Bang Theory 2018-12-06 - The VCR Illumination", + new TimerInfo + { + Name = "The Big Bang Theory", + IsProgramSeries = true, + OriginalAirDate = new DateTime(2018, 12, 6, 0, 0, 0, DateTimeKind.Local), + EpisodeTitle = "The VCR Illumination" + }); + + data.Add( + "The Big Bang Theory 2018_12_06_21_06_00 - The VCR Illumination", + new TimerInfo + { + Name = "The Big Bang Theory", + StartDate = new DateTime(2018, 12, 6, 21, 6, 0, DateTimeKind.Local), + IsProgramSeries = true, + OriginalAirDate = new DateTime(2018, 12, 6), + EpisodeTitle = "The VCR Illumination" + }); + + data.Add( + "Lorem ipsum dolor sit amet: consect 2018_12_06_21_06_00", + new TimerInfo + { + Name = "Lorem ipsum dolor sit amet: consect", + IsProgramSeries = true, + StartDate = new DateTime(2018, 12, 6, 21, 6, 0, DateTimeKind.Local), + OriginalAirDate = new DateTime(2018, 12, 6), + EpisodeTitle = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor" + }); + + return data; + } + + [Theory] + [MemberData(nameof(GetRecordingName_Success_TestData))] + public static void GetRecordingName_Success(string expected, TimerInfo timerInfo) + { + Assert.Equal(expected, RecordingHelper.GetRecordingName(timerInfo)); + } + } +} diff --git a/tests/Jellyfin.LiveTv.Tests/SchedulesDirect/SchedulesDirectDeserializeTests.cs b/tests/Jellyfin.LiveTv.Tests/SchedulesDirect/SchedulesDirectDeserializeTests.cs new file mode 100644 index 000000000..6b1f4d416 --- /dev/null +++ b/tests/Jellyfin.LiveTv.Tests/SchedulesDirect/SchedulesDirectDeserializeTests.cs @@ -0,0 +1,240 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text.Json; +using Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos; +using Jellyfin.Extensions.Json; +using Xunit; + +namespace Jellyfin.LiveTv.Tests.SchedulesDirect +{ + public class SchedulesDirectDeserializeTests + { + private readonly JsonSerializerOptions _jsonOptions; + + public SchedulesDirectDeserializeTests() + { + _jsonOptions = JsonDefaults.Options; + } + + /// + /// /token response. + /// + [Fact] + public void Deserialize_Token_Response_Live_Success() + { + var bytes = File.ReadAllBytes("Test Data/SchedulesDirect/token_live_response.json"); + var tokenDto = JsonSerializer.Deserialize(bytes, _jsonOptions); + + Assert.NotNull(tokenDto); + Assert.Equal(0, tokenDto!.Code); + Assert.Equal("OK", tokenDto.Message); + Assert.Equal("AWS-SD-web.1", tokenDto.ServerId); + Assert.Equal(new DateTime(2016, 08, 23, 13, 55, 25, DateTimeKind.Utc), tokenDto.TokenTimestamp); + Assert.Equal("f3fca79989cafe7dead71beefedc812b", tokenDto.Token); + } + + /// + /// /token response. + /// + [Fact] + public void Deserialize_Token_Response_Offline_Success() + { + var bytes = File.ReadAllBytes("Test Data/SchedulesDirect/token_offline_response.json"); + var tokenDto = JsonSerializer.Deserialize(bytes, _jsonOptions); + + Assert.NotNull(tokenDto); + Assert.Equal(3_000, tokenDto!.Code); + Assert.Equal("Server offline for maintenance.", tokenDto.Message); + Assert.Equal("20141201.web.1", tokenDto.ServerId); + Assert.Equal(new DateTime(2015, 04, 23, 00, 03, 32, DateTimeKind.Utc), tokenDto.TokenTimestamp); + Assert.Equal("CAFEDEADBEEFCAFEDEADBEEFCAFEDEADBEEFCAFE", tokenDto.Token); + Assert.Equal("SERVICE_OFFLINE", tokenDto.Response); + } + + /// + /// /schedules request. + /// + [Fact] + public void Serialize_Schedule_Request_Success() + { + var expectedString = File.ReadAllText("Test Data/SchedulesDirect/schedules_request.json").Trim(); + + var requestObject = new RequestScheduleForChannelDto[] + { + new RequestScheduleForChannelDto + { + StationId = "20454", + Date = new[] + { + "2015-03-13", + "2015-03-17" + } + }, + new RequestScheduleForChannelDto + { + StationId = "10021", + Date = new[] + { + "2015-03-12", + "2015-03-13" + } + } + }; + + var requestString = JsonSerializer.Serialize(requestObject, _jsonOptions); + Assert.Equal(expectedString, requestString); + } + + /// + /// /schedules response. + /// + [Fact] + public void Deserialize_Schedule_Response_Success() + { + var bytes = File.ReadAllBytes("Test Data/SchedulesDirect/schedules_response.json"); + var days = JsonSerializer.Deserialize>(bytes, _jsonOptions); + + Assert.NotNull(days); + Assert.Single(days); + + var dayDto = days[0]; + Assert.Equal("20454", dayDto.StationId); + Assert.Equal(2, dayDto.Programs.Count); + + Assert.Equal("SH005371070000", dayDto.Programs[0].ProgramId); + Assert.Equal(new DateTime(2015, 03, 03, 00, 00, 00, DateTimeKind.Utc), dayDto.Programs[0].AirDateTime); + Assert.Equal(1_800, dayDto.Programs[0].Duration); + Assert.Equal("Sy8HEMBPcuiAx3FBukUhKQ", dayDto.Programs[0].Md5); + Assert.True(dayDto.Programs[0].New); + Assert.Equal(2, dayDto.Programs[0].AudioProperties.Count); + Assert.Equal("stereo", dayDto.Programs[0].AudioProperties[0]); + Assert.Equal("cc", dayDto.Programs[0].AudioProperties[1]); + Assert.Single(dayDto.Programs[0].VideoProperties); + Assert.Equal("hdtv", dayDto.Programs[0].VideoProperties[0]); + } + + /// + /// /programs response. + /// + [Fact] + public void Deserialize_Program_Response_Success() + { + var bytes = File.ReadAllBytes("Test Data/SchedulesDirect/programs_response.json"); + var programDtos = JsonSerializer.Deserialize>(bytes, _jsonOptions); + + Assert.NotNull(programDtos); + Assert.Equal(2, programDtos!.Count); + Assert.Equal("EP000000060003", programDtos[0].ProgramId); + Assert.Single(programDtos[0].Titles); + Assert.Equal("'Allo 'Allo!", programDtos[0].Titles[0].Title120); + Assert.Equal("Series", programDtos[0].EventDetails?.SubType); + Assert.Equal("en", programDtos[0].Descriptions?.Description1000[0].DescriptionLanguage); + Assert.Equal("A disguised British Intelligence officer is sent to help the airmen.", programDtos[0].Descriptions?.Description1000[0].Description); + Assert.Equal(new DateTime(1985, 11, 04), programDtos[0].OriginalAirDate); + Assert.Single(programDtos[0].Genres); + Assert.Equal("Sitcom", programDtos[0].Genres[0]); + Assert.Equal("The Poloceman Cometh", programDtos[0].EpisodeTitle150); + Assert.Equal(2, programDtos[0].Metadata[0].Gracenote?.Season); + Assert.Equal(3, programDtos[0].Metadata[0].Gracenote?.Episode); + Assert.Equal(13, programDtos[0].Cast.Count); + Assert.Equal("383774", programDtos[0].Cast[0].PersonId); + Assert.Equal("392649", programDtos[0].Cast[0].NameId); + Assert.Equal("Gorden Kaye", programDtos[0].Cast[0].Name); + Assert.Equal("Actor", programDtos[0].Cast[0].Role); + Assert.Equal("01", programDtos[0].Cast[0].BillingOrder); + Assert.Equal(3, programDtos[0].Crew.Count); + Assert.Equal("354407", programDtos[0].Crew[0].PersonId); + Assert.Equal("363281", programDtos[0].Crew[0].NameId); + Assert.Equal("David Croft", programDtos[0].Crew[0].Name); + Assert.Equal("Director", programDtos[0].Crew[0].Role); + Assert.Equal("01", programDtos[0].Crew[0].BillingOrder); + } + + /// + /// /metadata/programs response. + /// + [Fact] + public void Deserialize_Metadata_Programs_Response_Success() + { + var bytes = File.ReadAllBytes("Test Data/SchedulesDirect/metadata_programs_response.json"); + var showImagesDtos = JsonSerializer.Deserialize>(bytes, _jsonOptions); + + Assert.NotNull(showImagesDtos); + Assert.Single(showImagesDtos!); + Assert.Equal("SH00712240", showImagesDtos[0].ProgramId); + Assert.Equal(4, showImagesDtos[0].Data.Count); + Assert.Equal("135", showImagesDtos[0].Data[0].Width); + Assert.Equal("180", showImagesDtos[0].Data[0].Height); + Assert.Equal("assets/p282288_b_v2_aa.jpg", showImagesDtos[0].Data[0].Uri); + Assert.Equal("Sm", showImagesDtos[0].Data[0].Size); + Assert.Equal("3x4", showImagesDtos[0].Data[0].Aspect); + Assert.Equal("Banner-L3", showImagesDtos[0].Data[0].Category); + Assert.Equal("yes", showImagesDtos[0].Data[0].Text); + Assert.Equal("true", showImagesDtos[0].Data[0].Primary); + Assert.Equal("Series", showImagesDtos[0].Data[0].Tier); + } + + /// + /// /headends response. + /// + [Fact] + public void Deserialize_Headends_Response_Success() + { + var bytes = File.ReadAllBytes("Test Data/SchedulesDirect/headends_response.json"); + var headendsDtos = JsonSerializer.Deserialize>(bytes, _jsonOptions); + + Assert.NotNull(headendsDtos); + Assert.Equal(8, headendsDtos!.Count); + Assert.Equal("CA00053", headendsDtos[0].Headend); + Assert.Equal("Cable", headendsDtos[0].Transport); + Assert.Equal("Beverly Hills", headendsDtos[0].Location); + Assert.Equal(2, headendsDtos[0].Lineups.Count); + Assert.Equal("Time Warner Cable - Cable", headendsDtos[0].Lineups[0].Name); + Assert.Equal("USA-CA00053-DEFAULT", headendsDtos[0].Lineups[0].Lineup); + Assert.Equal("/20141201/lineups/USA-CA00053-DEFAULT", headendsDtos[0].Lineups[0].Uri); + } + + /// + /// /lineups response. + /// + [Fact] + public void Deserialize_Lineups_Response_Success() + { + var bytes = File.ReadAllBytes("Test Data/SchedulesDirect/lineups_response.json"); + var lineupsDto = JsonSerializer.Deserialize(bytes, _jsonOptions); + + Assert.NotNull(lineupsDto); + Assert.Equal(0, lineupsDto!.Code); + Assert.Equal("20141201.web.1", lineupsDto.ServerId); + Assert.Equal(new DateTime(2015, 04, 17, 14, 22, 17, DateTimeKind.Utc), lineupsDto.LineupTimestamp); + Assert.Equal(5, lineupsDto.Lineups.Count); + Assert.Equal("GBR-0001317-DEFAULT", lineupsDto.Lineups[0].Lineup); + Assert.Equal("Freeview - Carlton - LWT (Southeast)", lineupsDto.Lineups[0].Name); + Assert.Equal("DVB-T", lineupsDto.Lineups[0].Transport); + Assert.Equal("London", lineupsDto.Lineups[0].Location); + Assert.Equal("/20141201/lineups/GBR-0001317-DEFAULT", lineupsDto.Lineups[0].Uri); + + Assert.Equal("DELETED LINEUP", lineupsDto.Lineups[4].Name); + Assert.True(lineupsDto.Lineups[4].IsDeleted); + } + + /// + /// /lineup/:id response. + /// + [Fact] + public void Deserialize_Lineup_Response_Success() + { + var bytes = File.ReadAllBytes("Test Data/SchedulesDirect/lineup_response.json"); + var channelDto = JsonSerializer.Deserialize(bytes, _jsonOptions); + + Assert.NotNull(channelDto); + Assert.Equal(2, channelDto!.Map.Count); + Assert.Equal("24326", channelDto.Map[0].StationId); + Assert.Equal("001", channelDto.Map[0].Channel); + Assert.Equal("BBC ONE South", channelDto.Map[0].ProvderCallsign); + Assert.Equal("1", channelDto.Map[0].LogicalChannelNumber); + Assert.Equal("providerCallsign", channelDto.Map[0].MatchType); + } + } +} diff --git a/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/10.10.10.100/discover.json b/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/10.10.10.100/discover.json new file mode 100644 index 000000000..a4ad4ed44 --- /dev/null +++ b/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/10.10.10.100/discover.json @@ -0,0 +1 @@ +{"FriendlyName":"HDHomeRun DUAL","ModelNumber":"HDHR3-US","Legacy":1,"FirmwareName":"hdhomerun3_atsc","FirmwareVersion":"20200225","DeviceID":"10xxxxx5","TunerCount":2,"BaseURL":"http://10.10.10.100:80"} diff --git a/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/10.10.10.100/lineup.json b/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/10.10.10.100/lineup.json new file mode 100644 index 000000000..0967ef424 --- /dev/null +++ b/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/10.10.10.100/lineup.json @@ -0,0 +1 @@ +{} diff --git a/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/192.168.1.182/discover.json b/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/192.168.1.182/discover.json new file mode 100644 index 000000000..851f17bb2 --- /dev/null +++ b/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/192.168.1.182/discover.json @@ -0,0 +1 @@ +{"FriendlyName":"HDHomeRun PRIME","ModelNumber":"HDHR3-CC","FirmwareName":"hdhomerun3_cablecard","FirmwareVersion":"20160630atest2","DeviceID":"FFFFFFFF","DeviceAuth":"FFFFFFFF","TunerCount":3,"ConditionalAccess":1,"BaseURL":"http://192.168.1.182:80","LineupURL":"http://192.168.1.182:80/lineup.json"} diff --git a/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/192.168.1.182/lineup.json b/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/192.168.1.182/lineup.json new file mode 100644 index 000000000..4cb5ebc8e --- /dev/null +++ b/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/192.168.1.182/lineup.json @@ -0,0 +1 @@ +[ { "GuideNumber": "4.1", "GuideName": "WCMH-DT", "HD": 1, "Favorite": 1, "URL": "http://192.168.1.111:5004/auto/v4.1" }, { "GuideNumber": "4.2", "GuideName": "MeTV", "URL": "http://192.168.1.111:5004/auto/v4.2" }, { "GuideNumber": "4.3", "GuideName": "ION TV", "URL": "http://192.168.1.111:5004/auto/v4.3" }, { "GuideNumber": "6.1", "GuideName": "WSYX DT", "HD": 1, "URL": "http://192.168.1.111:5004/auto/v6.1" }, { "GuideNumber": "6.2", "GuideName": "MYTV", "URL": "http://192.168.1.111:5004/auto/v6.2" }, { "GuideNumber": "6.3", "GuideName": "ANTENNA", "URL": "http://192.168.1.111:5004/auto/v6.3" } ] diff --git a/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/emptycategory.xml b/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/emptycategory.xml new file mode 100644 index 000000000..dd4aa8977 --- /dev/null +++ b/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/emptycategory.xml @@ -0,0 +1,6 @@ + + + + sports + + diff --git a/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/notitle.xml b/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/notitle.xml new file mode 100644 index 000000000..5a5be7997 --- /dev/null +++ b/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/notitle.xml @@ -0,0 +1,10 @@ + + + sports + 2022-11-04 13:00:00 + + + + diff --git a/tests/Jellyfin.LiveTv.Tests/Test Data/SchedulesDirect/headends_response.json b/tests/Jellyfin.LiveTv.Tests/Test Data/SchedulesDirect/headends_response.json new file mode 100644 index 000000000..015afeecc --- /dev/null +++ b/tests/Jellyfin.LiveTv.Tests/Test Data/SchedulesDirect/headends_response.json @@ -0,0 +1 @@ +[{"headend":"CA00053","transport":"Cable","location":"Beverly Hills","lineups":[{"name":"Time Warner Cable - Cable","lineup":"USA-CA00053-DEFAULT","uri":"/20141201/lineups/USA-CA00053-DEFAULT"},{"name":"Time Warner Cable - Digital","lineup":"USA-CA00053-X","uri":"/20141201/lineups/USA-CA00053-X"}]},{"headend":"CA61222","transport":"Cable","location":"Beverly Hills","lineups":[{"name":"Mulholland Estates - Cable","lineup":"USA-CA61222-DEFAULT","uri":"/20141201/lineups/USA-CA61222-DEFAULT"}]},{"headend":"CA66511","transport":"Cable","location":"Los Angeles","lineups":[{"name":"AT&T U-verse TV - Digital","lineup":"USA-CA66511-X","uri":"/20141201/lineups/USA-CA66511-X"}]},{"headend":"CA67309","transport":"Cable","location":"Westchester","lineups":[{"name":"Time Warner Cable Sherman Oaks - Cable","lineup":"USA-CA67309-DEFAULT","uri":"/20141201/lineups/USA-CA67309-DEFAULT"},{"name":"Time Warner Cable Sherman Oaks - Digital","lineup":"USA-CA67309-X","uri":"/20141201/lineups/USA-CA67309-X"}]},{"headend":"CA67310","transport":"Cable","location":"Eagle Rock","lineups":[{"name":"Time Warner Cable City of Los Angeles - Cable","lineup":"USA-CA67310-DEFAULT","uri":"/20141201/lineups/USA-CA67310-DEFAULT"},{"name":"Time Warner Cable City of Los Angeles - Digital","lineup":"USA-CA67310-X","uri":"/20141201/lineups/USA-CA67310-X"}]},{"headend":"DISH803","transport":"Satellite","location":"Los Angeles","lineups":[{"name":"DISH Los Angeles - Satellite","lineup":"USA-DISH803-DEFAULT","uri":"/20141201/lineups/USA-DISH803-DEFAULT"}]},{"headend":"DITV803","transport":"Satellite","location":"Los Angeles","lineups":[{"name":"DIRECTV Los Angeles - Satellite","lineup":"USA-DITV803-DEFAULT","uri":"/20141201/lineups/USA-DITV803-DEFAULT"}]},{"headend":"90210","transport":"Antenna","location":"90210","lineups":[{"name":"Antenna","lineup":"USA-OTA-90210","uri":"/20141201/lineups/USA-OTA-90210"}]}] diff --git a/tests/Jellyfin.LiveTv.Tests/Test Data/SchedulesDirect/lineup_response.json b/tests/Jellyfin.LiveTv.Tests/Test Data/SchedulesDirect/lineup_response.json new file mode 100644 index 000000000..072089470 --- /dev/null +++ b/tests/Jellyfin.LiveTv.Tests/Test Data/SchedulesDirect/lineup_response.json @@ -0,0 +1 @@ +{"map":[{"stationID":"24326","channel":"001","providerCallsign":"BBC ONE South","logicalChannelNumber":"1","matchType":"providerCallsign"},{"stationID":"17154","channel":"002","providerCallsign":"BBC TWO","logicalChannelNumber":"2","matchType":"providerCallsign"}]} diff --git a/tests/Jellyfin.LiveTv.Tests/Test Data/SchedulesDirect/lineups_response.json b/tests/Jellyfin.LiveTv.Tests/Test Data/SchedulesDirect/lineups_response.json new file mode 100644 index 000000000..032a84e59 --- /dev/null +++ b/tests/Jellyfin.LiveTv.Tests/Test Data/SchedulesDirect/lineups_response.json @@ -0,0 +1 @@ +{"code":0,"serverID":"20141201.web.1","datetime":"2015-04-17T14:22:17Z","lineups":[{"lineup":"GBR-0001317-DEFAULT","name":"Freeview - Carlton - LWT (Southeast)","transport":"DVB-T","location":"London","uri":"/20141201/lineups/GBR-0001317-DEFAULT"},{"lineup":"USA-IL57303-X","name":"Comcast Waukegan/Lake Forest Area - Digital","transport":"Cable","location":"Lake Forest","uri":"/20141201/lineups/USA-IL57303-X"},{"lineup":"USA-NY67791-X","name":"Verizon Fios Queens - Digital","transport":"Cable","location":"Fresh Meadows","uri":"/20141201/lineups/USA-NY67791-X"},{"lineup":"USA-OTA-60030","name":"Local Over the Air Broadcast","transport":"Antenna","location":"60030","uri":"/20141201/lineups/USA-OTA-60030"},{"lineup":"USA-WI61859-DEFAULT","name":"DELETED LINEUP","isDeleted":true}]} diff --git a/tests/Jellyfin.LiveTv.Tests/Test Data/SchedulesDirect/metadata_programs_response.json b/tests/Jellyfin.LiveTv.Tests/Test Data/SchedulesDirect/metadata_programs_response.json new file mode 100644 index 000000000..78166f09a --- /dev/null +++ b/tests/Jellyfin.LiveTv.Tests/Test Data/SchedulesDirect/metadata_programs_response.json @@ -0,0 +1 @@ +[{"programID":"SH00712240","data":[{"width":"135","height":"180","uri":"assets/p282288_b_v2_aa.jpg","size":"Sm","aspect":"3x4","category":"Banner-L3","text":"yes","primary":"true","tier":"Series"},{"width":"720","height":"540","uri":"assets/p282288_b_h6_aa.jpg","size":"Lg","aspect":"4x3","category":"Banner-L3","text":"yes","primary":"true","tier":"Series"},{"width":"960","height":"1440","uri":"assets/p282288_b_v8_aa.jpg","size":"Ms","aspect":"2x3","category":"Banner-L3","text":"yes","primary":"true","tier":"Series"},{"width":"180","height":"135","uri":"assets/p282288_b_h5_aa.jpg","size":"Sm","aspect":"4x3","category":"Banner-L3","text":"yes","primary":"true","tier":"Series"}]}] diff --git a/tests/Jellyfin.LiveTv.Tests/Test Data/SchedulesDirect/programs_response.json b/tests/Jellyfin.LiveTv.Tests/Test Data/SchedulesDirect/programs_response.json new file mode 100644 index 000000000..fe2a94436 --- /dev/null +++ b/tests/Jellyfin.LiveTv.Tests/Test Data/SchedulesDirect/programs_response.json @@ -0,0 +1 @@ +[{"programID":"EP000000060003","titles":[{"title120":"'Allo 'Allo!"}],"eventDetails":{"subType":"Series"},"descriptions":{"description1000":[{"descriptionLanguage":"en","description":"A disguised British Intelligence officer is sent to help the airmen."}]},"originalAirDate":"1985-11-04","genres":["Sitcom"],"episodeTitle150":"The Poloceman Cometh","metadata":[{"Gracenote":{"season":2,"episode":3}}],"cast":[{"personId":"383774","nameId":"392649","name":"Gorden Kaye","role":"Actor","billingOrder":"01"},{"personId":"246840","nameId":"250387","name":"Carmen Silvera","role":"Actor","billingOrder":"02"},{"personId":"376955","nameId":"385830","name":"Rose Hill","role":"Actor","billingOrder":"03"},{"personId":"259773","nameId":"263340","name":"Vicki Michelle","role":"Actor","billingOrder":"04"},{"personId":"353113","nameId":"361987","name":"Kirsten Cooke","role":"Actor","billingOrder":"05"},{"personId":"77787","nameId":"77787","name":"Richard Marner","role":"Actor","billingOrder":"06"},{"personId":"230921","nameId":"234193","name":"Guy Siner","role":"Actor","billingOrder":"07"},{"personId":"374934","nameId":"383809","name":"Kim Hartman","role":"Actor","billingOrder":"08"},{"personId":"369151","nameId":"378026","name":"Richard Gibson","role":"Actor","billingOrder":"09"},{"personId":"343690","nameId":"352564","name":"Arthur Bostrom","role":"Actor","billingOrder":"10"},{"personId":"352557","nameId":"361431","name":"John D. Collins","role":"Actor","billingOrder":"11"},{"personId":"605275","nameId":"627734","name":"Nicholas Frankau","role":"Actor","billingOrder":"12"},{"personId":"373394","nameId":"382269","name":"Jack Haig","role":"Actor","billingOrder":"13"}],"crew":[{"personId":"354407","nameId":"363281","name":"David Croft","role":"Director","billingOrder":"01"},{"personId":"354407","nameId":"363281","name":"David Croft","role":"Writer","billingOrder":"02"},{"personId":"105145","nameId":"105145","name":"Jeremy Lloyd","role":"Writer","billingOrder":"03"}],"showType":"Series","hasImageArtwork":true,"md5":"Jo5NKxoo44xRvBCAq8QT2A"},{"programID":"EP000000510142","titles":[{"title120":"A Different World"}],"eventDetails":{"subType":"Series"},"descriptions":{"description1000":[{"descriptionLanguage":"en","description":"Whitley and Dwayne tell new students about their honeymoon in Los Angeles."}]},"originalAirDate":"1992-09-24","genres":["Sitcom"],"episodeTitle150":"Honeymoon in L.A.","metadata":[{"Gracenote":{"season":6,"episode":1}}],"cast":[{"personId":"700","nameId":"700","name":"Jasmine Guy","role":"Actor","billingOrder":"01"},{"personId":"729","nameId":"729","name":"Kadeem Hardison","role":"Actor","billingOrder":"02"},{"personId":"120","nameId":"120","name":"Darryl M. Bell","role":"Actor","billingOrder":"03"},{"personId":"1729","nameId":"1729","name":"Cree Summer","role":"Actor","billingOrder":"04"},{"personId":"217","nameId":"217","name":"Charnele Brown","role":"Actor","billingOrder":"05"},{"personId":"1811","nameId":"1811","name":"Glynn Turman","role":"Actor","billingOrder":"06"},{"personId":"1232","nameId":"1232","name":"Lou Myers","role":"Actor","billingOrder":"07"},{"personId":"1363","nameId":"1363","name":"Jada Pinkett","role":"Guest Star","billingOrder":"08"},{"personId":"222967","nameId":"225536","name":"Ajai Sanders","role":"Guest Star","billingOrder":"09"},{"personId":"181744","nameId":"183292","name":"Karen Malina White","role":"Guest Star","billingOrder":"10"},{"personId":"305017","nameId":"318897","name":"Patrick Y. Malone","role":"Guest Star","billingOrder":"11"},{"personId":"9841","nameId":"9841","name":"Bumper Robinson","role":"Guest Star","billingOrder":"12"},{"personId":"426422","nameId":"435297","name":"Sister Souljah","role":"Guest Star","billingOrder":"13"},{"personId":"25","nameId":"25","name":"Debbie Allen","role":"Guest Star","billingOrder":"14"},{"personId":"668","nameId":"668","name":"Gilbert Gottfried","role":"Guest Star","billingOrder":"15"}],"showType":"Series","hasImageArtwork":true,"md5":"P5kz0QmCeYxIA+yL0H4DWw"}] diff --git a/tests/Jellyfin.LiveTv.Tests/Test Data/SchedulesDirect/schedules_request.json b/tests/Jellyfin.LiveTv.Tests/Test Data/SchedulesDirect/schedules_request.json new file mode 100644 index 000000000..5ef1bfb1c --- /dev/null +++ b/tests/Jellyfin.LiveTv.Tests/Test Data/SchedulesDirect/schedules_request.json @@ -0,0 +1 @@ +[{"stationID":"20454","date":["2015-03-13","2015-03-17"]},{"stationID":"10021","date":["2015-03-12","2015-03-13"]}] diff --git a/tests/Jellyfin.LiveTv.Tests/Test Data/SchedulesDirect/schedules_response.json b/tests/Jellyfin.LiveTv.Tests/Test Data/SchedulesDirect/schedules_response.json new file mode 100644 index 000000000..4a97e5517 --- /dev/null +++ b/tests/Jellyfin.LiveTv.Tests/Test Data/SchedulesDirect/schedules_response.json @@ -0,0 +1 @@ +[{"stationID":"20454","programs":[{"programID":"SH005371070000","airDateTime":"2015-03-03T00:00:00Z","duration":1800,"md5":"Sy8HEMBPcuiAx3FBukUhKQ","new":true,"audioProperties":["stereo","cc"],"videoProperties":["hdtv"]},{"programID":"EP000014577244","airDateTime":"2015-03-03T00:30:00Z","duration":1800,"md5":"25DNXVXO192JI7Y9vSW9lQ","new":true,"audioProperties":["stereo","cc"],"videoProperties":["hdtv"]}]}] diff --git a/tests/Jellyfin.LiveTv.Tests/Test Data/SchedulesDirect/token_live_response.json b/tests/Jellyfin.LiveTv.Tests/Test Data/SchedulesDirect/token_live_response.json new file mode 100644 index 000000000..e5fb64a6f --- /dev/null +++ b/tests/Jellyfin.LiveTv.Tests/Test Data/SchedulesDirect/token_live_response.json @@ -0,0 +1 @@ +{"code":0,"message":"OK","serverID":"AWS-SD-web.1","datetime":"2016-08-23T13:55:25Z","token":"f3fca79989cafe7dead71beefedc812b"} diff --git a/tests/Jellyfin.LiveTv.Tests/Test Data/SchedulesDirect/token_offline_response.json b/tests/Jellyfin.LiveTv.Tests/Test Data/SchedulesDirect/token_offline_response.json new file mode 100644 index 000000000..b66a4ed0c --- /dev/null +++ b/tests/Jellyfin.LiveTv.Tests/Test Data/SchedulesDirect/token_offline_response.json @@ -0,0 +1 @@ +{"response":"SERVICE_OFFLINE","code":3000,"serverID":"20141201.web.1","message":"Server offline for maintenance.","datetime":"2015-04-23T00:03:32Z","token":"CAFEDEADBEEFCAFEDEADBEEFCAFEDEADBEEFCAFE"} diff --git a/tests/Jellyfin.Server.Implementations.Tests/LiveTv/HdHomerunHostTests.cs b/tests/Jellyfin.Server.Implementations.Tests/LiveTv/HdHomerunHostTests.cs deleted file mode 100644 index 13ac3ddb0..000000000 --- a/tests/Jellyfin.Server.Implementations.Tests/LiveTv/HdHomerunHostTests.cs +++ /dev/null @@ -1,156 +0,0 @@ -using System; -using System.IO; -using System.Net.Http; -using System.Text.Json; -using System.Threading; -using System.Threading.Tasks; -using AutoFixture; -using AutoFixture.AutoMoq; -using Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun; -using MediaBrowser.Model.LiveTv; -using Moq; -using Moq.Protected; -using Xunit; - -namespace Jellyfin.Server.Implementations.Tests.LiveTv -{ - public class HdHomerunHostTests - { - private readonly Fixture _fixture; - private readonly HdHomerunHost _hdHomerunHost; - - public HdHomerunHostTests() - { - var messageHandler = new Mock(); - messageHandler.Protected() - .Setup>("SendAsync", ItExpr.IsAny(), ItExpr.IsAny()) - .Returns( - (m, _) => - { - return Task.FromResult(new HttpResponseMessage() - { - Content = new StreamContent(File.OpenRead(Path.Combine("Test Data/LiveTv", m.RequestUri!.Host, m.RequestUri.Segments[^1]))) - }); - }); - - var http = new Mock(); - http.Setup(x => x.CreateClient(It.IsAny())) - .Returns(new HttpClient(messageHandler.Object)); - _fixture = new Fixture(); - _fixture.Customize(new AutoMoqCustomization - { - ConfigureMembers = true - }).Inject(http); - _hdHomerunHost = _fixture.Create(); - } - - [Fact] - public async Task GetModelInfo_Valid_Success() - { - var host = new TunerHostInfo() - { - Url = "192.168.1.182" - }; - - var modelInfo = await _hdHomerunHost.GetModelInfo(host, true, CancellationToken.None); - Assert.Equal("HDHomeRun PRIME", modelInfo.FriendlyName); - Assert.Equal("HDHR3-CC", modelInfo.ModelNumber); - Assert.Equal("hdhomerun3_cablecard", modelInfo.FirmwareName); - Assert.Equal("20160630atest2", modelInfo.FirmwareVersion); - Assert.Equal("FFFFFFFF", modelInfo.DeviceID); - Assert.Equal("FFFFFFFF", modelInfo.DeviceAuth); - Assert.Equal(3, modelInfo.TunerCount); - Assert.Equal("http://192.168.1.182:80", modelInfo.BaseURL); - Assert.Equal("http://192.168.1.182:80/lineup.json", modelInfo.LineupURL); - } - - [Fact] - public async Task GetModelInfo_Legacy_Success() - { - var host = new TunerHostInfo() - { - Url = "10.10.10.100" - }; - - var modelInfo = await _hdHomerunHost.GetModelInfo(host, true, CancellationToken.None); - Assert.Equal("HDHomeRun DUAL", modelInfo.FriendlyName); - Assert.Equal("HDHR3-US", modelInfo.ModelNumber); - Assert.Equal("hdhomerun3_atsc", modelInfo.FirmwareName); - Assert.Equal("20200225", modelInfo.FirmwareVersion); - Assert.Equal("10xxxxx5", modelInfo.DeviceID); - Assert.Null(modelInfo.DeviceAuth); - Assert.Equal(2, modelInfo.TunerCount); - Assert.Equal("http://10.10.10.100:80", modelInfo.BaseURL); - Assert.Null(modelInfo.LineupURL); - } - - [Fact] - public async Task GetModelInfo_EmptyUrl_ArgumentException() - { - var host = new TunerHostInfo() - { - Url = string.Empty - }; - - await Assert.ThrowsAsync(() => _hdHomerunHost.GetModelInfo(host, true, CancellationToken.None)); - } - - [Fact] - public async Task GetLineup_Valid_Success() - { - var host = new TunerHostInfo() - { - Url = "192.168.1.182" - }; - - var channels = await _hdHomerunHost.GetLineup(host, CancellationToken.None); - Assert.Equal(6, channels.Count); - Assert.Equal("4.1", channels[0].GuideNumber); - Assert.Equal("WCMH-DT", channels[0].GuideName); - Assert.True(channels[0].HD); - Assert.True(channels[0].Favorite); - Assert.Equal("http://192.168.1.111:5004/auto/v4.1", channels[0].URL); - } - - [Fact] - public async Task GetLineup_Legacy_Success() - { - var host = new TunerHostInfo() - { - Url = "10.10.10.100" - }; - - // Placeholder json is invalid, just need to make sure we can reach it - await Assert.ThrowsAsync(() => _hdHomerunHost.GetLineup(host, CancellationToken.None)); - } - - [Fact] - public async Task GetLineup_ImportFavoritesOnly_Success() - { - var host = new TunerHostInfo() - { - Url = "192.168.1.182", - ImportFavoritesOnly = true - }; - - var channels = await _hdHomerunHost.GetLineup(host, CancellationToken.None); - Assert.Single(channels); - Assert.Equal("4.1", channels[0].GuideNumber); - Assert.Equal("WCMH-DT", channels[0].GuideName); - Assert.True(channels[0].HD); - Assert.True(channels[0].Favorite); - Assert.Equal("http://192.168.1.111:5004/auto/v4.1", channels[0].URL); - } - - [Fact] - public async Task TryGetTunerHostInfo_Valid_Success() - { - var host = await _hdHomerunHost.TryGetTunerHostInfo("192.168.1.182", CancellationToken.None); - Assert.Equal(_hdHomerunHost.Type, host.Type); - Assert.Equal("192.168.1.182", host.Url); - Assert.Equal("HDHomeRun PRIME", host.FriendlyName); - Assert.Equal("FFFFFFFF", host.DeviceId); - Assert.Equal(3, host.TunerCount); - } - } -} diff --git a/tests/Jellyfin.Server.Implementations.Tests/LiveTv/HdHomerunManagerTests.cs b/tests/Jellyfin.Server.Implementations.Tests/LiveTv/HdHomerunManagerTests.cs deleted file mode 100644 index fd499d9cf..000000000 --- a/tests/Jellyfin.Server.Implementations.Tests/LiveTv/HdHomerunManagerTests.cs +++ /dev/null @@ -1,326 +0,0 @@ -using System; -using System.Text; -using Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun; -using Xunit; - -namespace Jellyfin.Server.Implementations.Tests.LiveTv -{ - public class HdHomerunManagerTests - { - [Fact] - public void WriteNullTerminatedString_Empty_Success() - { - ReadOnlySpan expected = stackalloc byte[] - { - 1, 0 - }; - - Span buffer = stackalloc byte[128]; - int len = HdHomerunManager.WriteNullTerminatedString(buffer, string.Empty); - - Assert.Equal( - Convert.ToHexString(expected), - Convert.ToHexString(buffer.Slice(0, len))); - } - - [Fact] - public void WriteNullTerminatedString_Valid_Success() - { - ReadOnlySpan expected = stackalloc byte[] - { - 10, (byte)'T', (byte)'h', (byte)'e', (byte)' ', (byte)'q', (byte)'u', (byte)'i', (byte)'c', (byte)'k', 0 - }; - - Span buffer = stackalloc byte[128]; - int len = HdHomerunManager.WriteNullTerminatedString(buffer, "The quick"); - - Assert.Equal( - Convert.ToHexString(expected), - Convert.ToHexString(buffer.Slice(0, len))); - } - - [Fact] - public void WriteGetMessage_Valid_Success() - { - ReadOnlySpan expected = stackalloc byte[] - { - 0, 4, - 0, 12, - 3, - 10, (byte)'/', (byte)'t', (byte)'u', (byte)'n', (byte)'e', (byte)'r', (byte)'0', (byte)'/', (byte)'N', 0, - 0xc0, 0xc9, 0x87, 0x33 - }; - - Span buffer = stackalloc byte[128]; - int len = HdHomerunManager.WriteGetMessage(buffer, 0, "N"); - - Assert.Equal( - Convert.ToHexString(expected), - Convert.ToHexString(buffer.Slice(0, len))); - } - - [Fact] - public void WriteSetMessage_NoLockKey_Success() - { - ReadOnlySpan expected = stackalloc byte[] - { - 0, 4, - 0, 20, - 3, - 10, (byte)'/', (byte)'t', (byte)'u', (byte)'n', (byte)'e', (byte)'r', (byte)'0', (byte)'/', (byte)'N', 0, - 4, - 6, (byte)'v', (byte)'a', (byte)'l', (byte)'u', (byte)'e', 0, - 0xa9, 0x49, 0xd0, 0x68 - }; - - Span buffer = stackalloc byte[128]; - int len = HdHomerunManager.WriteSetMessage(buffer, 0, "N", "value", null); - - Assert.Equal( - Convert.ToHexString(expected), - Convert.ToHexString(buffer.Slice(0, len))); - } - - [Fact] - public void WriteSetMessage_LockKey_Success() - { - ReadOnlySpan expected = stackalloc byte[] - { - 0, 4, - 0, 26, - 3, - 10, (byte)'/', (byte)'t', (byte)'u', (byte)'n', (byte)'e', (byte)'r', (byte)'0', (byte)'/', (byte)'N', 0, - 4, - 6, (byte)'v', (byte)'a', (byte)'l', (byte)'u', (byte)'e', 0, - 21, - 4, 0x00, 0x01, 0x38, 0xd5, - 0x8e, 0xb6, 0x06, 0x82 - }; - - Span buffer = stackalloc byte[128]; - int len = HdHomerunManager.WriteSetMessage(buffer, 0, "N", "value", 80085); - - Assert.Equal( - Convert.ToHexString(expected), - Convert.ToHexString(buffer.Slice(0, len))); - } - - [Fact] - public void TryGetReturnValueOfGetSet_Valid_Success() - { - ReadOnlySpan packet = new byte[] - { - 0, 5, - 0, 20, - 3, - 10, (byte)'/', (byte)'t', (byte)'u', (byte)'n', (byte)'e', (byte)'r', (byte)'0', (byte)'/', (byte)'N', 0, - 4, - 6, (byte)'v', (byte)'a', (byte)'l', (byte)'u', (byte)'e', 0, - 0x7d, 0xa3, 0xa3, 0xf3 - }; - - Assert.True(HdHomerunManager.TryGetReturnValueOfGetSet(packet, out var value)); - Assert.Equal("value", Encoding.UTF8.GetString(value)); - } - - [Fact] - public void TryGetReturnValueOfGetSet_InvalidCrc_False() - { - ReadOnlySpan packet = new byte[] - { - 0, 5, - 0, 20, - 3, - 10, (byte)'/', (byte)'t', (byte)'u', (byte)'n', (byte)'e', (byte)'r', (byte)'0', (byte)'/', (byte)'N', 0, - 4, - 6, (byte)'v', (byte)'a', (byte)'l', (byte)'u', (byte)'e', 0, - 0x7d, 0xa3, 0xa3, 0xf4 - }; - - Assert.False(HdHomerunManager.TryGetReturnValueOfGetSet(packet, out _)); - } - - [Fact] - public void TryGetReturnValueOfGetSet_InvalidPacketType_False() - { - ReadOnlySpan packet = new byte[] - { - 0, 4, - 0, 20, - 3, - 10, (byte)'/', (byte)'t', (byte)'u', (byte)'n', (byte)'e', (byte)'r', (byte)'0', (byte)'/', (byte)'N', 0, - 4, - 6, (byte)'v', (byte)'a', (byte)'l', (byte)'u', (byte)'e', 0, - 0xa9, 0x49, 0xd0, 0x68 - }; - - Assert.False(HdHomerunManager.TryGetReturnValueOfGetSet(packet, out _)); - } - - [Fact] - public void TryGetReturnValueOfGetSet_InvalidPacket_False() - { - ReadOnlySpan packet = new byte[] - { - 0, 5, - 0, 20, - 0x7d, 0xa3, 0xa3 - }; - - Assert.False(HdHomerunManager.TryGetReturnValueOfGetSet(packet, out _)); - } - - [Fact] - public void TryGetReturnValueOfGetSet_TooSmallMessageLength_False() - { - ReadOnlySpan packet = new byte[] - { - 0, 5, - 0, 19, - 3, - 10, (byte)'/', (byte)'t', (byte)'u', (byte)'n', (byte)'e', (byte)'r', (byte)'0', (byte)'/', (byte)'N', 0, - 4, - 6, (byte)'v', (byte)'a', (byte)'l', (byte)'u', (byte)'e', 0, - 0x25, 0x25, 0x44, 0x9a - }; - - Assert.False(HdHomerunManager.TryGetReturnValueOfGetSet(packet, out _)); - } - - [Fact] - public void TryGetReturnValueOfGetSet_TooLargeMessageLength_False() - { - ReadOnlySpan packet = new byte[] - { - 0, 5, - 0, 21, - 3, - 10, (byte)'/', (byte)'t', (byte)'u', (byte)'n', (byte)'e', (byte)'r', (byte)'0', (byte)'/', (byte)'N', 0, - 4, - 6, (byte)'v', (byte)'a', (byte)'l', (byte)'u', (byte)'e', 0, - 0xe3, 0x20, 0x79, 0x6c - }; - - Assert.False(HdHomerunManager.TryGetReturnValueOfGetSet(packet, out _)); - } - - [Fact] - public void TryGetReturnValueOfGetSet_TooLargeNameLength_False() - { - ReadOnlySpan packet = new byte[] - { - 0, 5, - 0, 20, - 3, - 20, (byte)'/', (byte)'t', (byte)'u', (byte)'n', (byte)'e', (byte)'r', (byte)'0', (byte)'/', (byte)'N', 0, - 4, - 6, (byte)'v', (byte)'a', (byte)'l', (byte)'u', (byte)'e', 0, - 0xe1, 0x8e, 0x9c, 0x74 - }; - - Assert.False(HdHomerunManager.TryGetReturnValueOfGetSet(packet, out _)); - } - - [Fact] - public void TryGetReturnValueOfGetSet_InvalidGetSetNameTag_False() - { - ReadOnlySpan packet = new byte[] - { - 0, 5, - 0, 20, - 4, - 10, (byte)'/', (byte)'t', (byte)'u', (byte)'n', (byte)'e', (byte)'r', (byte)'0', (byte)'/', (byte)'N', 0, - 4, - 6, (byte)'v', (byte)'a', (byte)'l', (byte)'u', (byte)'e', 0, - 0xee, 0x05, 0xe7, 0x12 - }; - - Assert.False(HdHomerunManager.TryGetReturnValueOfGetSet(packet, out _)); - } - - [Fact] - public void TryGetReturnValueOfGetSet_InvalidGetSetValueTag_False() - { - ReadOnlySpan packet = new byte[] - { - 0, 5, - 0, 20, - 3, - 10, (byte)'/', (byte)'t', (byte)'u', (byte)'n', (byte)'e', (byte)'r', (byte)'0', (byte)'/', (byte)'N', 0, - 3, - 6, (byte)'v', (byte)'a', (byte)'l', (byte)'u', (byte)'e', 0, - 0x64, 0xaa, 0x66, 0xf9 - }; - - Assert.False(HdHomerunManager.TryGetReturnValueOfGetSet(packet, out _)); - } - - [Fact] - public void TryGetReturnValueOfGetSet_TooLargeValueLength_False() - { - ReadOnlySpan packet = new byte[] - { - 0, 5, - 0, 20, - 3, - 10, (byte)'/', (byte)'t', (byte)'u', (byte)'n', (byte)'e', (byte)'r', (byte)'0', (byte)'/', (byte)'N', 0, - 4, - 7, (byte)'v', (byte)'a', (byte)'l', (byte)'u', (byte)'e', 0, - 0xc9, 0xa8, 0xd4, 0x55 - }; - - Assert.False(HdHomerunManager.TryGetReturnValueOfGetSet(packet, out _)); - } - - [Fact] - public void VerifyReturnValueOfGetSet_Valid_True() - { - ReadOnlySpan packet = new byte[] - { - 0, 5, - 0, 20, - 3, - 10, (byte)'/', (byte)'t', (byte)'u', (byte)'n', (byte)'e', (byte)'r', (byte)'0', (byte)'/', (byte)'N', 0, - 4, - 6, (byte)'v', (byte)'a', (byte)'l', (byte)'u', (byte)'e', 0, - 0x7d, 0xa3, 0xa3, 0xf3 - }; - - Assert.True(HdHomerunManager.VerifyReturnValueOfGetSet(packet, "value")); - } - - [Fact] - public void VerifyReturnValueOfGetSet_WrongValue_False() - { - ReadOnlySpan packet = new byte[] - { - 0, 5, - 0, 20, - 3, - 10, (byte)'/', (byte)'t', (byte)'u', (byte)'n', (byte)'e', (byte)'r', (byte)'0', (byte)'/', (byte)'N', 0, - 4, - 6, (byte)'v', (byte)'a', (byte)'l', (byte)'u', (byte)'e', 0, - 0x7d, 0xa3, 0xa3, 0xf3 - }; - - Assert.False(HdHomerunManager.VerifyReturnValueOfGetSet(packet, "none")); - } - - [Fact] - public void VerifyReturnValueOfGetSet_InvalidPacket_False() - { - ReadOnlySpan packet = new byte[] - { - 0, 4, - 0, 20, - 3, - 10, (byte)'/', (byte)'t', (byte)'u', (byte)'n', (byte)'e', (byte)'r', (byte)'0', (byte)'/', (byte)'N', 0, - 4, - 6, (byte)'v', (byte)'a', (byte)'l', (byte)'u', (byte)'e', 0, - 0x7d, 0xa3, 0xa3, 0xf3 - }; - - Assert.False(HdHomerunManager.VerifyReturnValueOfGetSet(packet, "value")); - } - } -} diff --git a/tests/Jellyfin.Server.Implementations.Tests/LiveTv/Listings/XmlTvListingsProviderTests.cs b/tests/Jellyfin.Server.Implementations.Tests/LiveTv/Listings/XmlTvListingsProviderTests.cs deleted file mode 100644 index 92b4178fd..000000000 --- a/tests/Jellyfin.Server.Implementations.Tests/LiveTv/Listings/XmlTvListingsProviderTests.cs +++ /dev/null @@ -1,89 +0,0 @@ -using System; -using System.IO; -using System.Linq; -using System.Net.Http; -using System.Threading; -using System.Threading.Tasks; -using AutoFixture; -using AutoFixture.AutoMoq; -using Emby.Server.Implementations.LiveTv.Listings; -using MediaBrowser.Model.LiveTv; -using Moq; -using Moq.Protected; -using Xunit; - -namespace Jellyfin.Server.Implementations.Tests.LiveTv.Listings; - -public class XmlTvListingsProviderTests -{ - private readonly Fixture _fixture; - private readonly XmlTvListingsProvider _xmlTvListingsProvider; - - public XmlTvListingsProviderTests() - { - var messageHandler = new Mock(); - messageHandler.Protected() - .Setup>("SendAsync", ItExpr.IsAny(), ItExpr.IsAny()) - .Returns( - (m, _) => - { - return Task.FromResult(new HttpResponseMessage() - { - Content = new StreamContent(File.OpenRead(Path.Combine("Test Data/LiveTv/Listings/XmlTv", m.RequestUri!.Segments[^1]))) - }); - }); - - var http = new Mock(); - http.Setup(x => x.CreateClient(It.IsAny())) - .Returns(new HttpClient(messageHandler.Object)); - _fixture = new Fixture(); - _fixture.Customize(new AutoMoqCustomization - { - ConfigureMembers = true - }).Inject(http); - _xmlTvListingsProvider = _fixture.Create(); - } - - [Theory] - [InlineData("Test Data/LiveTv/Listings/XmlTv/notitle.xml")] - [InlineData("https://example.com/notitle.xml")] - public async Task GetProgramsAsync_NoTitle_Success(string path) - { - var info = new ListingsProviderInfo() - { - Path = path - }; - - var startDate = new DateTime(2022, 11, 4); - var programs = await _xmlTvListingsProvider.GetProgramsAsync(info, "3297", startDate, startDate.AddDays(1), CancellationToken.None); - var programsList = programs.ToList(); - Assert.Single(programsList); - var program = programsList[0]; - Assert.Null(program.Name); - Assert.Null(program.SeriesId); - Assert.Null(program.EpisodeTitle); - Assert.True(program.IsSports); - Assert.True(program.HasImage); - Assert.Equal("https://domain.tld/image.png", program.ImageUrl); - Assert.Equal("3297", program.ChannelId); - } - - [Theory] - [InlineData("Test Data/LiveTv/Listings/XmlTv/emptycategory.xml")] - [InlineData("https://example.com/emptycategory.xml")] - public async Task GetProgramsAsync_EmptyCategories_Success(string path) - { - var info = new ListingsProviderInfo() - { - Path = path - }; - - var startDate = new DateTime(2022, 11, 4); - var programs = await _xmlTvListingsProvider.GetProgramsAsync(info, "3297", startDate, startDate.AddDays(1), CancellationToken.None); - var programsList = programs.ToList(); - Assert.Single(programsList); - var program = programsList[0]; - Assert.DoesNotContain(program.Genres, g => string.IsNullOrEmpty(g)); - Assert.Equal("3297", program.ChannelId); - } -} diff --git a/tests/Jellyfin.Server.Implementations.Tests/LiveTv/RecordingHelperTests.cs b/tests/Jellyfin.Server.Implementations.Tests/LiveTv/RecordingHelperTests.cs deleted file mode 100644 index f107b1ef9..000000000 --- a/tests/Jellyfin.Server.Implementations.Tests/LiveTv/RecordingHelperTests.cs +++ /dev/null @@ -1,109 +0,0 @@ -using System; -using Emby.Server.Implementations.LiveTv.EmbyTV; -using MediaBrowser.Controller.LiveTv; -using Xunit; - -namespace Jellyfin.Server.Implementations.Tests.LiveTv -{ - public static class RecordingHelperTests - { - public static TheoryData GetRecordingName_Success_TestData() - { - var data = new TheoryData(); - - data.Add( - "The Incredibles 2020_04_20_21_06_00", - new TimerInfo - { - Name = "The Incredibles", - StartDate = new DateTime(2020, 4, 20, 21, 6, 0, DateTimeKind.Local), - IsMovie = true - }); - - data.Add( - "The Incredibles (2004)", - new TimerInfo - { - Name = "The Incredibles", - IsMovie = true, - ProductionYear = 2004 - }); - data.Add( - "The Big Bang Theory 2020_04_20_21_06_00", - new TimerInfo - { - Name = "The Big Bang Theory", - StartDate = new DateTime(2020, 4, 20, 21, 6, 0, DateTimeKind.Local), - IsProgramSeries = true, - }); - data.Add( - "The Big Bang Theory S12E10", - new TimerInfo - { - Name = "The Big Bang Theory", - IsProgramSeries = true, - SeasonNumber = 12, - EpisodeNumber = 10 - }); - data.Add( - "The Big Bang Theory S12E10 The VCR Illumination", - new TimerInfo - { - Name = "The Big Bang Theory", - IsProgramSeries = true, - SeasonNumber = 12, - EpisodeNumber = 10, - EpisodeTitle = "The VCR Illumination" - }); - data.Add( - "The Big Bang Theory 2018-12-06", - new TimerInfo - { - Name = "The Big Bang Theory", - IsProgramSeries = true, - OriginalAirDate = new DateTime(2018, 12, 6, 0, 0, 0, DateTimeKind.Local) - }); - - data.Add( - "The Big Bang Theory 2018-12-06 - The VCR Illumination", - new TimerInfo - { - Name = "The Big Bang Theory", - IsProgramSeries = true, - OriginalAirDate = new DateTime(2018, 12, 6, 0, 0, 0, DateTimeKind.Local), - EpisodeTitle = "The VCR Illumination" - }); - - data.Add( - "The Big Bang Theory 2018_12_06_21_06_00 - The VCR Illumination", - new TimerInfo - { - Name = "The Big Bang Theory", - StartDate = new DateTime(2018, 12, 6, 21, 6, 0, DateTimeKind.Local), - IsProgramSeries = true, - OriginalAirDate = new DateTime(2018, 12, 6), - EpisodeTitle = "The VCR Illumination" - }); - - data.Add( - "Lorem ipsum dolor sit amet: consect 2018_12_06_21_06_00", - new TimerInfo - { - Name = "Lorem ipsum dolor sit amet: consect", - IsProgramSeries = true, - StartDate = new DateTime(2018, 12, 6, 21, 6, 0, DateTimeKind.Local), - OriginalAirDate = new DateTime(2018, 12, 6), - EpisodeTitle = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor" - }); - - return data; - } - - [Theory] - [MemberData(nameof(GetRecordingName_Success_TestData))] - public static void GetRecordingName_Success(string expected, TimerInfo timerInfo) - { - Assert.Equal(expected, RecordingHelper.GetRecordingName(timerInfo)); - } - } -} diff --git a/tests/Jellyfin.Server.Implementations.Tests/LiveTv/SchedulesDirect/SchedulesDirectDeserializeTests.cs b/tests/Jellyfin.Server.Implementations.Tests/LiveTv/SchedulesDirect/SchedulesDirectDeserializeTests.cs deleted file mode 100644 index d4f28f327..000000000 --- a/tests/Jellyfin.Server.Implementations.Tests/LiveTv/SchedulesDirect/SchedulesDirectDeserializeTests.cs +++ /dev/null @@ -1,240 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Text.Json; -using Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos; -using Jellyfin.Extensions.Json; -using Xunit; - -namespace Jellyfin.Server.Implementations.Tests.LiveTv.SchedulesDirect -{ - public class SchedulesDirectDeserializeTests - { - private readonly JsonSerializerOptions _jsonOptions; - - public SchedulesDirectDeserializeTests() - { - _jsonOptions = JsonDefaults.Options; - } - - /// - /// /token response. - /// - [Fact] - public void Deserialize_Token_Response_Live_Success() - { - var bytes = File.ReadAllBytes("Test Data/SchedulesDirect/token_live_response.json"); - var tokenDto = JsonSerializer.Deserialize(bytes, _jsonOptions); - - Assert.NotNull(tokenDto); - Assert.Equal(0, tokenDto!.Code); - Assert.Equal("OK", tokenDto.Message); - Assert.Equal("AWS-SD-web.1", tokenDto.ServerId); - Assert.Equal(new DateTime(2016, 08, 23, 13, 55, 25, DateTimeKind.Utc), tokenDto.TokenTimestamp); - Assert.Equal("f3fca79989cafe7dead71beefedc812b", tokenDto.Token); - } - - /// - /// /token response. - /// - [Fact] - public void Deserialize_Token_Response_Offline_Success() - { - var bytes = File.ReadAllBytes("Test Data/SchedulesDirect/token_offline_response.json"); - var tokenDto = JsonSerializer.Deserialize(bytes, _jsonOptions); - - Assert.NotNull(tokenDto); - Assert.Equal(3_000, tokenDto!.Code); - Assert.Equal("Server offline for maintenance.", tokenDto.Message); - Assert.Equal("20141201.web.1", tokenDto.ServerId); - Assert.Equal(new DateTime(2015, 04, 23, 00, 03, 32, DateTimeKind.Utc), tokenDto.TokenTimestamp); - Assert.Equal("CAFEDEADBEEFCAFEDEADBEEFCAFEDEADBEEFCAFE", tokenDto.Token); - Assert.Equal("SERVICE_OFFLINE", tokenDto.Response); - } - - /// - /// /schedules request. - /// - [Fact] - public void Serialize_Schedule_Request_Success() - { - var expectedString = File.ReadAllText("Test Data/SchedulesDirect/schedules_request.json").Trim(); - - var requestObject = new RequestScheduleForChannelDto[] - { - new RequestScheduleForChannelDto - { - StationId = "20454", - Date = new[] - { - "2015-03-13", - "2015-03-17" - } - }, - new RequestScheduleForChannelDto - { - StationId = "10021", - Date = new[] - { - "2015-03-12", - "2015-03-13" - } - } - }; - - var requestString = JsonSerializer.Serialize(requestObject, _jsonOptions); - Assert.Equal(expectedString, requestString); - } - - /// - /// /schedules response. - /// - [Fact] - public void Deserialize_Schedule_Response_Success() - { - var bytes = File.ReadAllBytes("Test Data/SchedulesDirect/schedules_response.json"); - var days = JsonSerializer.Deserialize>(bytes, _jsonOptions); - - Assert.NotNull(days); - Assert.Single(days); - - var dayDto = days[0]; - Assert.Equal("20454", dayDto.StationId); - Assert.Equal(2, dayDto.Programs.Count); - - Assert.Equal("SH005371070000", dayDto.Programs[0].ProgramId); - Assert.Equal(new DateTime(2015, 03, 03, 00, 00, 00, DateTimeKind.Utc), dayDto.Programs[0].AirDateTime); - Assert.Equal(1_800, dayDto.Programs[0].Duration); - Assert.Equal("Sy8HEMBPcuiAx3FBukUhKQ", dayDto.Programs[0].Md5); - Assert.True(dayDto.Programs[0].New); - Assert.Equal(2, dayDto.Programs[0].AudioProperties.Count); - Assert.Equal("stereo", dayDto.Programs[0].AudioProperties[0]); - Assert.Equal("cc", dayDto.Programs[0].AudioProperties[1]); - Assert.Single(dayDto.Programs[0].VideoProperties); - Assert.Equal("hdtv", dayDto.Programs[0].VideoProperties[0]); - } - - /// - /// /programs response. - /// - [Fact] - public void Deserialize_Program_Response_Success() - { - var bytes = File.ReadAllBytes("Test Data/SchedulesDirect/programs_response.json"); - var programDtos = JsonSerializer.Deserialize>(bytes, _jsonOptions); - - Assert.NotNull(programDtos); - Assert.Equal(2, programDtos!.Count); - Assert.Equal("EP000000060003", programDtos[0].ProgramId); - Assert.Single(programDtos[0].Titles); - Assert.Equal("'Allo 'Allo!", programDtos[0].Titles[0].Title120); - Assert.Equal("Series", programDtos[0].EventDetails?.SubType); - Assert.Equal("en", programDtos[0].Descriptions?.Description1000[0].DescriptionLanguage); - Assert.Equal("A disguised British Intelligence officer is sent to help the airmen.", programDtos[0].Descriptions?.Description1000[0].Description); - Assert.Equal(new DateTime(1985, 11, 04), programDtos[0].OriginalAirDate); - Assert.Single(programDtos[0].Genres); - Assert.Equal("Sitcom", programDtos[0].Genres[0]); - Assert.Equal("The Poloceman Cometh", programDtos[0].EpisodeTitle150); - Assert.Equal(2, programDtos[0].Metadata[0].Gracenote?.Season); - Assert.Equal(3, programDtos[0].Metadata[0].Gracenote?.Episode); - Assert.Equal(13, programDtos[0].Cast.Count); - Assert.Equal("383774", programDtos[0].Cast[0].PersonId); - Assert.Equal("392649", programDtos[0].Cast[0].NameId); - Assert.Equal("Gorden Kaye", programDtos[0].Cast[0].Name); - Assert.Equal("Actor", programDtos[0].Cast[0].Role); - Assert.Equal("01", programDtos[0].Cast[0].BillingOrder); - Assert.Equal(3, programDtos[0].Crew.Count); - Assert.Equal("354407", programDtos[0].Crew[0].PersonId); - Assert.Equal("363281", programDtos[0].Crew[0].NameId); - Assert.Equal("David Croft", programDtos[0].Crew[0].Name); - Assert.Equal("Director", programDtos[0].Crew[0].Role); - Assert.Equal("01", programDtos[0].Crew[0].BillingOrder); - } - - /// - /// /metadata/programs response. - /// - [Fact] - public void Deserialize_Metadata_Programs_Response_Success() - { - var bytes = File.ReadAllBytes("Test Data/SchedulesDirect/metadata_programs_response.json"); - var showImagesDtos = JsonSerializer.Deserialize>(bytes, _jsonOptions); - - Assert.NotNull(showImagesDtos); - Assert.Single(showImagesDtos!); - Assert.Equal("SH00712240", showImagesDtos[0].ProgramId); - Assert.Equal(4, showImagesDtos[0].Data.Count); - Assert.Equal("135", showImagesDtos[0].Data[0].Width); - Assert.Equal("180", showImagesDtos[0].Data[0].Height); - Assert.Equal("assets/p282288_b_v2_aa.jpg", showImagesDtos[0].Data[0].Uri); - Assert.Equal("Sm", showImagesDtos[0].Data[0].Size); - Assert.Equal("3x4", showImagesDtos[0].Data[0].Aspect); - Assert.Equal("Banner-L3", showImagesDtos[0].Data[0].Category); - Assert.Equal("yes", showImagesDtos[0].Data[0].Text); - Assert.Equal("true", showImagesDtos[0].Data[0].Primary); - Assert.Equal("Series", showImagesDtos[0].Data[0].Tier); - } - - /// - /// /headends response. - /// - [Fact] - public void Deserialize_Headends_Response_Success() - { - var bytes = File.ReadAllBytes("Test Data/SchedulesDirect/headends_response.json"); - var headendsDtos = JsonSerializer.Deserialize>(bytes, _jsonOptions); - - Assert.NotNull(headendsDtos); - Assert.Equal(8, headendsDtos!.Count); - Assert.Equal("CA00053", headendsDtos[0].Headend); - Assert.Equal("Cable", headendsDtos[0].Transport); - Assert.Equal("Beverly Hills", headendsDtos[0].Location); - Assert.Equal(2, headendsDtos[0].Lineups.Count); - Assert.Equal("Time Warner Cable - Cable", headendsDtos[0].Lineups[0].Name); - Assert.Equal("USA-CA00053-DEFAULT", headendsDtos[0].Lineups[0].Lineup); - Assert.Equal("/20141201/lineups/USA-CA00053-DEFAULT", headendsDtos[0].Lineups[0].Uri); - } - - /// - /// /lineups response. - /// - [Fact] - public void Deserialize_Lineups_Response_Success() - { - var bytes = File.ReadAllBytes("Test Data/SchedulesDirect/lineups_response.json"); - var lineupsDto = JsonSerializer.Deserialize(bytes, _jsonOptions); - - Assert.NotNull(lineupsDto); - Assert.Equal(0, lineupsDto!.Code); - Assert.Equal("20141201.web.1", lineupsDto.ServerId); - Assert.Equal(new DateTime(2015, 04, 17, 14, 22, 17, DateTimeKind.Utc), lineupsDto.LineupTimestamp); - Assert.Equal(5, lineupsDto.Lineups.Count); - Assert.Equal("GBR-0001317-DEFAULT", lineupsDto.Lineups[0].Lineup); - Assert.Equal("Freeview - Carlton - LWT (Southeast)", lineupsDto.Lineups[0].Name); - Assert.Equal("DVB-T", lineupsDto.Lineups[0].Transport); - Assert.Equal("London", lineupsDto.Lineups[0].Location); - Assert.Equal("/20141201/lineups/GBR-0001317-DEFAULT", lineupsDto.Lineups[0].Uri); - - Assert.Equal("DELETED LINEUP", lineupsDto.Lineups[4].Name); - Assert.True(lineupsDto.Lineups[4].IsDeleted); - } - - /// - /// /lineup/:id response. - /// - [Fact] - public void Deserialize_Lineup_Response_Success() - { - var bytes = File.ReadAllBytes("Test Data/SchedulesDirect/lineup_response.json"); - var channelDto = JsonSerializer.Deserialize(bytes, _jsonOptions); - - Assert.NotNull(channelDto); - Assert.Equal(2, channelDto!.Map.Count); - Assert.Equal("24326", channelDto.Map[0].StationId); - Assert.Equal("001", channelDto.Map[0].Channel); - Assert.Equal("BBC ONE South", channelDto.Map[0].ProvderCallsign); - Assert.Equal("1", channelDto.Map[0].LogicalChannelNumber); - Assert.Equal("providerCallsign", channelDto.Map[0].MatchType); - } - } -} diff --git a/tests/Jellyfin.Server.Implementations.Tests/Test Data/LiveTv/10.10.10.100/discover.json b/tests/Jellyfin.Server.Implementations.Tests/Test Data/LiveTv/10.10.10.100/discover.json deleted file mode 100644 index a4ad4ed44..000000000 --- a/tests/Jellyfin.Server.Implementations.Tests/Test Data/LiveTv/10.10.10.100/discover.json +++ /dev/null @@ -1 +0,0 @@ -{"FriendlyName":"HDHomeRun DUAL","ModelNumber":"HDHR3-US","Legacy":1,"FirmwareName":"hdhomerun3_atsc","FirmwareVersion":"20200225","DeviceID":"10xxxxx5","TunerCount":2,"BaseURL":"http://10.10.10.100:80"} diff --git a/tests/Jellyfin.Server.Implementations.Tests/Test Data/LiveTv/10.10.10.100/lineup.json b/tests/Jellyfin.Server.Implementations.Tests/Test Data/LiveTv/10.10.10.100/lineup.json deleted file mode 100644 index 0967ef424..000000000 --- a/tests/Jellyfin.Server.Implementations.Tests/Test Data/LiveTv/10.10.10.100/lineup.json +++ /dev/null @@ -1 +0,0 @@ -{} diff --git a/tests/Jellyfin.Server.Implementations.Tests/Test Data/LiveTv/192.168.1.182/discover.json b/tests/Jellyfin.Server.Implementations.Tests/Test Data/LiveTv/192.168.1.182/discover.json deleted file mode 100644 index 851f17bb2..000000000 --- a/tests/Jellyfin.Server.Implementations.Tests/Test Data/LiveTv/192.168.1.182/discover.json +++ /dev/null @@ -1 +0,0 @@ -{"FriendlyName":"HDHomeRun PRIME","ModelNumber":"HDHR3-CC","FirmwareName":"hdhomerun3_cablecard","FirmwareVersion":"20160630atest2","DeviceID":"FFFFFFFF","DeviceAuth":"FFFFFFFF","TunerCount":3,"ConditionalAccess":1,"BaseURL":"http://192.168.1.182:80","LineupURL":"http://192.168.1.182:80/lineup.json"} diff --git a/tests/Jellyfin.Server.Implementations.Tests/Test Data/LiveTv/192.168.1.182/lineup.json b/tests/Jellyfin.Server.Implementations.Tests/Test Data/LiveTv/192.168.1.182/lineup.json deleted file mode 100644 index 4cb5ebc8e..000000000 --- a/tests/Jellyfin.Server.Implementations.Tests/Test Data/LiveTv/192.168.1.182/lineup.json +++ /dev/null @@ -1 +0,0 @@ -[ { "GuideNumber": "4.1", "GuideName": "WCMH-DT", "HD": 1, "Favorite": 1, "URL": "http://192.168.1.111:5004/auto/v4.1" }, { "GuideNumber": "4.2", "GuideName": "MeTV", "URL": "http://192.168.1.111:5004/auto/v4.2" }, { "GuideNumber": "4.3", "GuideName": "ION TV", "URL": "http://192.168.1.111:5004/auto/v4.3" }, { "GuideNumber": "6.1", "GuideName": "WSYX DT", "HD": 1, "URL": "http://192.168.1.111:5004/auto/v6.1" }, { "GuideNumber": "6.2", "GuideName": "MYTV", "URL": "http://192.168.1.111:5004/auto/v6.2" }, { "GuideNumber": "6.3", "GuideName": "ANTENNA", "URL": "http://192.168.1.111:5004/auto/v6.3" } ] diff --git a/tests/Jellyfin.Server.Implementations.Tests/Test Data/LiveTv/Listings/XmlTv/emptycategory.xml b/tests/Jellyfin.Server.Implementations.Tests/Test Data/LiveTv/Listings/XmlTv/emptycategory.xml deleted file mode 100644 index dd4aa8977..000000000 --- a/tests/Jellyfin.Server.Implementations.Tests/Test Data/LiveTv/Listings/XmlTv/emptycategory.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - sports - - diff --git a/tests/Jellyfin.Server.Implementations.Tests/Test Data/LiveTv/Listings/XmlTv/notitle.xml b/tests/Jellyfin.Server.Implementations.Tests/Test Data/LiveTv/Listings/XmlTv/notitle.xml deleted file mode 100644 index 5a5be7997..000000000 --- a/tests/Jellyfin.Server.Implementations.Tests/Test Data/LiveTv/Listings/XmlTv/notitle.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - sports - 2022-11-04 13:00:00 - - - - diff --git a/tests/Jellyfin.Server.Implementations.Tests/Test Data/SchedulesDirect/headends_response.json b/tests/Jellyfin.Server.Implementations.Tests/Test Data/SchedulesDirect/headends_response.json deleted file mode 100644 index 015afeecc..000000000 --- a/tests/Jellyfin.Server.Implementations.Tests/Test Data/SchedulesDirect/headends_response.json +++ /dev/null @@ -1 +0,0 @@ -[{"headend":"CA00053","transport":"Cable","location":"Beverly Hills","lineups":[{"name":"Time Warner Cable - Cable","lineup":"USA-CA00053-DEFAULT","uri":"/20141201/lineups/USA-CA00053-DEFAULT"},{"name":"Time Warner Cable - Digital","lineup":"USA-CA00053-X","uri":"/20141201/lineups/USA-CA00053-X"}]},{"headend":"CA61222","transport":"Cable","location":"Beverly Hills","lineups":[{"name":"Mulholland Estates - Cable","lineup":"USA-CA61222-DEFAULT","uri":"/20141201/lineups/USA-CA61222-DEFAULT"}]},{"headend":"CA66511","transport":"Cable","location":"Los Angeles","lineups":[{"name":"AT&T U-verse TV - Digital","lineup":"USA-CA66511-X","uri":"/20141201/lineups/USA-CA66511-X"}]},{"headend":"CA67309","transport":"Cable","location":"Westchester","lineups":[{"name":"Time Warner Cable Sherman Oaks - Cable","lineup":"USA-CA67309-DEFAULT","uri":"/20141201/lineups/USA-CA67309-DEFAULT"},{"name":"Time Warner Cable Sherman Oaks - Digital","lineup":"USA-CA67309-X","uri":"/20141201/lineups/USA-CA67309-X"}]},{"headend":"CA67310","transport":"Cable","location":"Eagle Rock","lineups":[{"name":"Time Warner Cable City of Los Angeles - Cable","lineup":"USA-CA67310-DEFAULT","uri":"/20141201/lineups/USA-CA67310-DEFAULT"},{"name":"Time Warner Cable City of Los Angeles - Digital","lineup":"USA-CA67310-X","uri":"/20141201/lineups/USA-CA67310-X"}]},{"headend":"DISH803","transport":"Satellite","location":"Los Angeles","lineups":[{"name":"DISH Los Angeles - Satellite","lineup":"USA-DISH803-DEFAULT","uri":"/20141201/lineups/USA-DISH803-DEFAULT"}]},{"headend":"DITV803","transport":"Satellite","location":"Los Angeles","lineups":[{"name":"DIRECTV Los Angeles - Satellite","lineup":"USA-DITV803-DEFAULT","uri":"/20141201/lineups/USA-DITV803-DEFAULT"}]},{"headend":"90210","transport":"Antenna","location":"90210","lineups":[{"name":"Antenna","lineup":"USA-OTA-90210","uri":"/20141201/lineups/USA-OTA-90210"}]}] diff --git a/tests/Jellyfin.Server.Implementations.Tests/Test Data/SchedulesDirect/lineup_response.json b/tests/Jellyfin.Server.Implementations.Tests/Test Data/SchedulesDirect/lineup_response.json deleted file mode 100644 index 072089470..000000000 --- a/tests/Jellyfin.Server.Implementations.Tests/Test Data/SchedulesDirect/lineup_response.json +++ /dev/null @@ -1 +0,0 @@ -{"map":[{"stationID":"24326","channel":"001","providerCallsign":"BBC ONE South","logicalChannelNumber":"1","matchType":"providerCallsign"},{"stationID":"17154","channel":"002","providerCallsign":"BBC TWO","logicalChannelNumber":"2","matchType":"providerCallsign"}]} diff --git a/tests/Jellyfin.Server.Implementations.Tests/Test Data/SchedulesDirect/lineups_response.json b/tests/Jellyfin.Server.Implementations.Tests/Test Data/SchedulesDirect/lineups_response.json deleted file mode 100644 index 032a84e59..000000000 --- a/tests/Jellyfin.Server.Implementations.Tests/Test Data/SchedulesDirect/lineups_response.json +++ /dev/null @@ -1 +0,0 @@ -{"code":0,"serverID":"20141201.web.1","datetime":"2015-04-17T14:22:17Z","lineups":[{"lineup":"GBR-0001317-DEFAULT","name":"Freeview - Carlton - LWT (Southeast)","transport":"DVB-T","location":"London","uri":"/20141201/lineups/GBR-0001317-DEFAULT"},{"lineup":"USA-IL57303-X","name":"Comcast Waukegan/Lake Forest Area - Digital","transport":"Cable","location":"Lake Forest","uri":"/20141201/lineups/USA-IL57303-X"},{"lineup":"USA-NY67791-X","name":"Verizon Fios Queens - Digital","transport":"Cable","location":"Fresh Meadows","uri":"/20141201/lineups/USA-NY67791-X"},{"lineup":"USA-OTA-60030","name":"Local Over the Air Broadcast","transport":"Antenna","location":"60030","uri":"/20141201/lineups/USA-OTA-60030"},{"lineup":"USA-WI61859-DEFAULT","name":"DELETED LINEUP","isDeleted":true}]} diff --git a/tests/Jellyfin.Server.Implementations.Tests/Test Data/SchedulesDirect/metadata_programs_response.json b/tests/Jellyfin.Server.Implementations.Tests/Test Data/SchedulesDirect/metadata_programs_response.json deleted file mode 100644 index 78166f09a..000000000 --- a/tests/Jellyfin.Server.Implementations.Tests/Test Data/SchedulesDirect/metadata_programs_response.json +++ /dev/null @@ -1 +0,0 @@ -[{"programID":"SH00712240","data":[{"width":"135","height":"180","uri":"assets/p282288_b_v2_aa.jpg","size":"Sm","aspect":"3x4","category":"Banner-L3","text":"yes","primary":"true","tier":"Series"},{"width":"720","height":"540","uri":"assets/p282288_b_h6_aa.jpg","size":"Lg","aspect":"4x3","category":"Banner-L3","text":"yes","primary":"true","tier":"Series"},{"width":"960","height":"1440","uri":"assets/p282288_b_v8_aa.jpg","size":"Ms","aspect":"2x3","category":"Banner-L3","text":"yes","primary":"true","tier":"Series"},{"width":"180","height":"135","uri":"assets/p282288_b_h5_aa.jpg","size":"Sm","aspect":"4x3","category":"Banner-L3","text":"yes","primary":"true","tier":"Series"}]}] diff --git a/tests/Jellyfin.Server.Implementations.Tests/Test Data/SchedulesDirect/programs_response.json b/tests/Jellyfin.Server.Implementations.Tests/Test Data/SchedulesDirect/programs_response.json deleted file mode 100644 index fe2a94436..000000000 --- a/tests/Jellyfin.Server.Implementations.Tests/Test Data/SchedulesDirect/programs_response.json +++ /dev/null @@ -1 +0,0 @@ -[{"programID":"EP000000060003","titles":[{"title120":"'Allo 'Allo!"}],"eventDetails":{"subType":"Series"},"descriptions":{"description1000":[{"descriptionLanguage":"en","description":"A disguised British Intelligence officer is sent to help the airmen."}]},"originalAirDate":"1985-11-04","genres":["Sitcom"],"episodeTitle150":"The Poloceman Cometh","metadata":[{"Gracenote":{"season":2,"episode":3}}],"cast":[{"personId":"383774","nameId":"392649","name":"Gorden Kaye","role":"Actor","billingOrder":"01"},{"personId":"246840","nameId":"250387","name":"Carmen Silvera","role":"Actor","billingOrder":"02"},{"personId":"376955","nameId":"385830","name":"Rose Hill","role":"Actor","billingOrder":"03"},{"personId":"259773","nameId":"263340","name":"Vicki Michelle","role":"Actor","billingOrder":"04"},{"personId":"353113","nameId":"361987","name":"Kirsten Cooke","role":"Actor","billingOrder":"05"},{"personId":"77787","nameId":"77787","name":"Richard Marner","role":"Actor","billingOrder":"06"},{"personId":"230921","nameId":"234193","name":"Guy Siner","role":"Actor","billingOrder":"07"},{"personId":"374934","nameId":"383809","name":"Kim Hartman","role":"Actor","billingOrder":"08"},{"personId":"369151","nameId":"378026","name":"Richard Gibson","role":"Actor","billingOrder":"09"},{"personId":"343690","nameId":"352564","name":"Arthur Bostrom","role":"Actor","billingOrder":"10"},{"personId":"352557","nameId":"361431","name":"John D. Collins","role":"Actor","billingOrder":"11"},{"personId":"605275","nameId":"627734","name":"Nicholas Frankau","role":"Actor","billingOrder":"12"},{"personId":"373394","nameId":"382269","name":"Jack Haig","role":"Actor","billingOrder":"13"}],"crew":[{"personId":"354407","nameId":"363281","name":"David Croft","role":"Director","billingOrder":"01"},{"personId":"354407","nameId":"363281","name":"David Croft","role":"Writer","billingOrder":"02"},{"personId":"105145","nameId":"105145","name":"Jeremy Lloyd","role":"Writer","billingOrder":"03"}],"showType":"Series","hasImageArtwork":true,"md5":"Jo5NKxoo44xRvBCAq8QT2A"},{"programID":"EP000000510142","titles":[{"title120":"A Different World"}],"eventDetails":{"subType":"Series"},"descriptions":{"description1000":[{"descriptionLanguage":"en","description":"Whitley and Dwayne tell new students about their honeymoon in Los Angeles."}]},"originalAirDate":"1992-09-24","genres":["Sitcom"],"episodeTitle150":"Honeymoon in L.A.","metadata":[{"Gracenote":{"season":6,"episode":1}}],"cast":[{"personId":"700","nameId":"700","name":"Jasmine Guy","role":"Actor","billingOrder":"01"},{"personId":"729","nameId":"729","name":"Kadeem Hardison","role":"Actor","billingOrder":"02"},{"personId":"120","nameId":"120","name":"Darryl M. Bell","role":"Actor","billingOrder":"03"},{"personId":"1729","nameId":"1729","name":"Cree Summer","role":"Actor","billingOrder":"04"},{"personId":"217","nameId":"217","name":"Charnele Brown","role":"Actor","billingOrder":"05"},{"personId":"1811","nameId":"1811","name":"Glynn Turman","role":"Actor","billingOrder":"06"},{"personId":"1232","nameId":"1232","name":"Lou Myers","role":"Actor","billingOrder":"07"},{"personId":"1363","nameId":"1363","name":"Jada Pinkett","role":"Guest Star","billingOrder":"08"},{"personId":"222967","nameId":"225536","name":"Ajai Sanders","role":"Guest Star","billingOrder":"09"},{"personId":"181744","nameId":"183292","name":"Karen Malina White","role":"Guest Star","billingOrder":"10"},{"personId":"305017","nameId":"318897","name":"Patrick Y. Malone","role":"Guest Star","billingOrder":"11"},{"personId":"9841","nameId":"9841","name":"Bumper Robinson","role":"Guest Star","billingOrder":"12"},{"personId":"426422","nameId":"435297","name":"Sister Souljah","role":"Guest Star","billingOrder":"13"},{"personId":"25","nameId":"25","name":"Debbie Allen","role":"Guest Star","billingOrder":"14"},{"personId":"668","nameId":"668","name":"Gilbert Gottfried","role":"Guest Star","billingOrder":"15"}],"showType":"Series","hasImageArtwork":true,"md5":"P5kz0QmCeYxIA+yL0H4DWw"}] diff --git a/tests/Jellyfin.Server.Implementations.Tests/Test Data/SchedulesDirect/schedules_request.json b/tests/Jellyfin.Server.Implementations.Tests/Test Data/SchedulesDirect/schedules_request.json deleted file mode 100644 index 5ef1bfb1c..000000000 --- a/tests/Jellyfin.Server.Implementations.Tests/Test Data/SchedulesDirect/schedules_request.json +++ /dev/null @@ -1 +0,0 @@ -[{"stationID":"20454","date":["2015-03-13","2015-03-17"]},{"stationID":"10021","date":["2015-03-12","2015-03-13"]}] diff --git a/tests/Jellyfin.Server.Implementations.Tests/Test Data/SchedulesDirect/schedules_response.json b/tests/Jellyfin.Server.Implementations.Tests/Test Data/SchedulesDirect/schedules_response.json deleted file mode 100644 index 4a97e5517..000000000 --- a/tests/Jellyfin.Server.Implementations.Tests/Test Data/SchedulesDirect/schedules_response.json +++ /dev/null @@ -1 +0,0 @@ -[{"stationID":"20454","programs":[{"programID":"SH005371070000","airDateTime":"2015-03-03T00:00:00Z","duration":1800,"md5":"Sy8HEMBPcuiAx3FBukUhKQ","new":true,"audioProperties":["stereo","cc"],"videoProperties":["hdtv"]},{"programID":"EP000014577244","airDateTime":"2015-03-03T00:30:00Z","duration":1800,"md5":"25DNXVXO192JI7Y9vSW9lQ","new":true,"audioProperties":["stereo","cc"],"videoProperties":["hdtv"]}]}] diff --git a/tests/Jellyfin.Server.Implementations.Tests/Test Data/SchedulesDirect/token_live_response.json b/tests/Jellyfin.Server.Implementations.Tests/Test Data/SchedulesDirect/token_live_response.json deleted file mode 100644 index e5fb64a6f..000000000 --- a/tests/Jellyfin.Server.Implementations.Tests/Test Data/SchedulesDirect/token_live_response.json +++ /dev/null @@ -1 +0,0 @@ -{"code":0,"message":"OK","serverID":"AWS-SD-web.1","datetime":"2016-08-23T13:55:25Z","token":"f3fca79989cafe7dead71beefedc812b"} diff --git a/tests/Jellyfin.Server.Implementations.Tests/Test Data/SchedulesDirect/token_offline_response.json b/tests/Jellyfin.Server.Implementations.Tests/Test Data/SchedulesDirect/token_offline_response.json deleted file mode 100644 index b66a4ed0c..000000000 --- a/tests/Jellyfin.Server.Implementations.Tests/Test Data/SchedulesDirect/token_offline_response.json +++ /dev/null @@ -1 +0,0 @@ -{"response":"SERVICE_OFFLINE","code":3000,"serverID":"20141201.web.1","message":"Server offline for maintenance.","datetime":"2015-04-23T00:03:32Z","token":"CAFEDEADBEEFCAFEDEADBEEFCAFEDEADBEEFCAFE"} -- cgit v1.2.3 From c1a3084312fa4fb7796b83640bfe9ad2b5044afa Mon Sep 17 00:00:00 2001 From: Patrick Barron Date: Thu, 28 Dec 2023 15:15:03 -0500 Subject: Move LiveTv to separate project --- Emby.Server.Implementations/ApplicationHost.cs | 4 - .../Emby.Server.Implementations.csproj | 1 - .../Library/ExclusiveLiveStream.cs | 60 - .../LiveTv/EmbyTV/DirectRecorder.cs | 119 - .../LiveTv/EmbyTV/EmbyTV.cs | 2623 -------------------- .../LiveTv/EmbyTV/EncodedRecorder.cs | 362 --- .../LiveTv/EmbyTV/EntryPoint.cs | 21 - .../LiveTv/EmbyTV/EpgChannelData.cs | 54 - .../LiveTv/EmbyTV/IRecorder.cs | 27 - .../LiveTv/EmbyTV/ItemDataProvider.cs | 163 -- .../LiveTv/EmbyTV/NfoConfigurationExtensions.cs | 19 - .../LiveTv/EmbyTV/RecordingHelper.cs | 83 - .../LiveTv/EmbyTV/SeriesTimerManager.cs | 24 - .../LiveTv/EmbyTV/TimerManager.cs | 181 -- .../LiveTv/Listings/SchedulesDirect.cs | 808 ------ .../Listings/SchedulesDirectDtos/BroadcasterDto.cs | 34 - .../Listings/SchedulesDirectDtos/CaptionDto.cs | 22 - .../LiveTv/Listings/SchedulesDirectDtos/CastDto.cs | 46 - .../Listings/SchedulesDirectDtos/ChannelDto.cs | 30 - .../SchedulesDirectDtos/ContentRatingDto.cs | 22 - .../LiveTv/Listings/SchedulesDirectDtos/CrewDto.cs | 40 - .../LiveTv/Listings/SchedulesDirectDtos/DayDto.cs | 30 - .../SchedulesDirectDtos/Description1000Dto.cs | 22 - .../SchedulesDirectDtos/Description100Dto.cs | 22 - .../SchedulesDirectDtos/DescriptionsProgramDto.cs | 24 - .../SchedulesDirectDtos/EventDetailsDto.cs | 16 - .../Listings/SchedulesDirectDtos/GracenoteDto.cs | 22 - .../Listings/SchedulesDirectDtos/HeadendsDto.cs | 36 - .../Listings/SchedulesDirectDtos/ImageDataDto.cs | 70 - .../Listings/SchedulesDirectDtos/LineupDto.cs | 46 - .../Listings/SchedulesDirectDtos/LineupsDto.cs | 36 - .../LiveTv/Listings/SchedulesDirectDtos/LogoDto.cs | 34 - .../LiveTv/Listings/SchedulesDirectDtos/MapDto.cs | 58 - .../Listings/SchedulesDirectDtos/MetadataDto.cs | 28 - .../SchedulesDirectDtos/MetadataProgramsDto.cs | 16 - .../SchedulesDirectDtos/MetadataScheduleDto.cs | 41 - .../Listings/SchedulesDirectDtos/MovieDto.cs | 30 - .../Listings/SchedulesDirectDtos/MultipartDto.cs | 22 - .../SchedulesDirectDtos/ProgramDetailsDto.cs | 156 -- .../Listings/SchedulesDirectDtos/ProgramDto.cs | 90 - .../SchedulesDirectDtos/QualityRatingDto.cs | 40 - .../Listings/SchedulesDirectDtos/RatingDto.cs | 22 - .../SchedulesDirectDtos/RecommendationDto.cs | 22 - .../RequestScheduleForChannelDto.cs | 24 - .../Listings/SchedulesDirectDtos/ShowImagesDto.cs | 24 - .../Listings/SchedulesDirectDtos/StationDto.cs | 66 - .../Listings/SchedulesDirectDtos/TitleDto.cs | 16 - .../Listings/SchedulesDirectDtos/TokenDto.cs | 47 - .../LiveTv/Listings/XmlTvListingsProvider.cs | 267 -- .../LiveTv/LiveTvConfigurationFactory.cs | 25 - .../LiveTv/LiveTvDtoService.cs | 548 ---- .../LiveTv/LiveTvManager.cs | 2408 ------------------ .../LiveTv/LiveTvMediaSourceProvider.cs | 128 - .../LiveTv/RefreshGuideScheduledTask.cs | 75 - .../LiveTv/TunerHosts/BaseTunerHost.cs | 237 -- .../LiveTv/TunerHosts/HdHomerun/Channels.cs | 23 - .../TunerHosts/HdHomerun/DiscoverResponse.cs | 42 - .../HdHomerun/HdHomerunChannelCommands.cs | 35 - .../LiveTv/TunerHosts/HdHomerun/HdHomerunHost.cs | 718 ------ .../TunerHosts/HdHomerun/HdHomerunManager.cs | 351 --- .../TunerHosts/HdHomerun/HdHomerunUdpStream.cs | 218 -- .../HdHomerun/IHdHomerunChannelCommands.cs | 11 - .../HdHomerun/LegacyHdHomerunChannelCommands.cs | 40 - .../LiveTv/TunerHosts/LiveStream.cs | 175 -- .../LiveTv/TunerHosts/M3UTunerHost.cs | 220 -- .../LiveTv/TunerHosts/M3uParser.cs | 326 --- .../LiveTv/TunerHosts/SharedHttpStream.cs | 134 - .../Properties/AssemblyInfo.cs | 1 - Jellyfin.Server/CoreAppHost.cs | 8 + Jellyfin.Server/Jellyfin.Server.csproj | 1 + Jellyfin.sln | 7 + src/Jellyfin.LiveTv/EmbyTV/DirectRecorder.cs | 118 + src/Jellyfin.LiveTv/EmbyTV/EmbyTV.cs | 2621 +++++++++++++++++++ src/Jellyfin.LiveTv/EmbyTV/EncodedRecorder.cs | 362 +++ src/Jellyfin.LiveTv/EmbyTV/EntryPoint.cs | 21 + src/Jellyfin.LiveTv/EmbyTV/EpgChannelData.cs | 54 + src/Jellyfin.LiveTv/EmbyTV/IRecorder.cs | 27 + src/Jellyfin.LiveTv/EmbyTV/ItemDataProvider.cs | 163 ++ .../EmbyTV/NfoConfigurationExtensions.cs | 19 + src/Jellyfin.LiveTv/EmbyTV/RecordingHelper.cs | 83 + src/Jellyfin.LiveTv/EmbyTV/SeriesTimerManager.cs | 24 + src/Jellyfin.LiveTv/EmbyTV/TimerManager.cs | 181 ++ src/Jellyfin.LiveTv/ExclusiveLiveStream.cs | 61 + src/Jellyfin.LiveTv/Jellyfin.LiveTv.csproj | 22 + src/Jellyfin.LiveTv/Listings/SchedulesDirect.cs | 810 ++++++ .../Listings/SchedulesDirectDtos/BroadcasterDto.cs | 34 + .../Listings/SchedulesDirectDtos/CaptionDto.cs | 22 + .../Listings/SchedulesDirectDtos/CastDto.cs | 46 + .../Listings/SchedulesDirectDtos/ChannelDto.cs | 30 + .../SchedulesDirectDtos/ContentRatingDto.cs | 22 + .../Listings/SchedulesDirectDtos/CrewDto.cs | 40 + .../Listings/SchedulesDirectDtos/DayDto.cs | 30 + .../SchedulesDirectDtos/Description1000Dto.cs | 22 + .../SchedulesDirectDtos/Description100Dto.cs | 22 + .../SchedulesDirectDtos/DescriptionsProgramDto.cs | 24 + .../SchedulesDirectDtos/EventDetailsDto.cs | 16 + .../Listings/SchedulesDirectDtos/GracenoteDto.cs | 22 + .../Listings/SchedulesDirectDtos/HeadendsDto.cs | 36 + .../Listings/SchedulesDirectDtos/ImageDataDto.cs | 70 + .../Listings/SchedulesDirectDtos/LineupDto.cs | 46 + .../Listings/SchedulesDirectDtos/LineupsDto.cs | 36 + .../Listings/SchedulesDirectDtos/LogoDto.cs | 34 + .../Listings/SchedulesDirectDtos/MapDto.cs | 58 + .../Listings/SchedulesDirectDtos/MetadataDto.cs | 28 + .../SchedulesDirectDtos/MetadataProgramsDto.cs | 16 + .../SchedulesDirectDtos/MetadataScheduleDto.cs | 41 + .../Listings/SchedulesDirectDtos/MovieDto.cs | 30 + .../Listings/SchedulesDirectDtos/MultipartDto.cs | 22 + .../SchedulesDirectDtos/ProgramDetailsDto.cs | 156 ++ .../Listings/SchedulesDirectDtos/ProgramDto.cs | 90 + .../SchedulesDirectDtos/QualityRatingDto.cs | 40 + .../Listings/SchedulesDirectDtos/RatingDto.cs | 22 + .../SchedulesDirectDtos/RecommendationDto.cs | 22 + .../RequestScheduleForChannelDto.cs | 24 + .../Listings/SchedulesDirectDtos/ShowImagesDto.cs | 24 + .../Listings/SchedulesDirectDtos/StationDto.cs | 66 + .../Listings/SchedulesDirectDtos/TitleDto.cs | 16 + .../Listings/SchedulesDirectDtos/TokenDto.cs | 47 + .../Listings/XmlTvListingsProvider.cs | 267 ++ src/Jellyfin.LiveTv/LiveTvConfigurationFactory.cs | 25 + src/Jellyfin.LiveTv/LiveTvDtoService.cs | 548 ++++ src/Jellyfin.LiveTv/LiveTvManager.cs | 2409 ++++++++++++++++++ src/Jellyfin.LiveTv/LiveTvMediaSourceProvider.cs | 128 + src/Jellyfin.LiveTv/RefreshGuideScheduledTask.cs | 75 + src/Jellyfin.LiveTv/TunerHosts/BaseTunerHost.cs | 237 ++ .../TunerHosts/HdHomerun/Channels.cs | 23 + .../TunerHosts/HdHomerun/DiscoverResponse.cs | 42 + .../HdHomerun/HdHomerunChannelCommands.cs | 35 + .../TunerHosts/HdHomerun/HdHomerunHost.cs | 718 ++++++ .../TunerHosts/HdHomerun/HdHomerunManager.cs | 351 +++ .../TunerHosts/HdHomerun/HdHomerunUdpStream.cs | 219 ++ .../HdHomerun/IHdHomerunChannelCommands.cs | 11 + .../HdHomerun/LegacyHdHomerunChannelCommands.cs | 40 + src/Jellyfin.LiveTv/TunerHosts/LiveStream.cs | 176 ++ src/Jellyfin.LiveTv/TunerHosts/M3UTunerHost.cs | 220 ++ src/Jellyfin.LiveTv/TunerHosts/M3uParser.cs | 326 +++ src/Jellyfin.LiveTv/TunerHosts/SharedHttpStream.cs | 135 + tests/Jellyfin.LiveTv.Tests/HdHomerunHostTests.cs | 2 +- .../Jellyfin.LiveTv.Tests/HdHomerunManagerTests.cs | 2 +- .../Jellyfin.LiveTv.Tests.csproj | 2 +- .../Listings/XmlTvListingsProviderTests.cs | 2 +- .../Jellyfin.LiveTv.Tests/RecordingHelperTests.cs | 2 +- .../SchedulesDirectDeserializeTests.cs | 2 +- 143 files changed, 11827 insertions(+), 11791 deletions(-) delete mode 100644 Emby.Server.Implementations/Library/ExclusiveLiveStream.cs delete mode 100644 Emby.Server.Implementations/LiveTv/EmbyTV/DirectRecorder.cs delete mode 100644 Emby.Server.Implementations/LiveTv/EmbyTV/EmbyTV.cs delete mode 100644 Emby.Server.Implementations/LiveTv/EmbyTV/EncodedRecorder.cs delete mode 100644 Emby.Server.Implementations/LiveTv/EmbyTV/EntryPoint.cs delete mode 100644 Emby.Server.Implementations/LiveTv/EmbyTV/EpgChannelData.cs delete mode 100644 Emby.Server.Implementations/LiveTv/EmbyTV/IRecorder.cs delete mode 100644 Emby.Server.Implementations/LiveTv/EmbyTV/ItemDataProvider.cs delete mode 100644 Emby.Server.Implementations/LiveTv/EmbyTV/NfoConfigurationExtensions.cs delete mode 100644 Emby.Server.Implementations/LiveTv/EmbyTV/RecordingHelper.cs delete mode 100644 Emby.Server.Implementations/LiveTv/EmbyTV/SeriesTimerManager.cs delete mode 100644 Emby.Server.Implementations/LiveTv/EmbyTV/TimerManager.cs delete mode 100644 Emby.Server.Implementations/LiveTv/Listings/SchedulesDirect.cs delete mode 100644 Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/BroadcasterDto.cs delete mode 100644 Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/CaptionDto.cs delete mode 100644 Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/CastDto.cs delete mode 100644 Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/ChannelDto.cs delete mode 100644 Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/ContentRatingDto.cs delete mode 100644 Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/CrewDto.cs delete mode 100644 Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/DayDto.cs delete mode 100644 Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/Description1000Dto.cs delete mode 100644 Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/Description100Dto.cs delete mode 100644 Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/DescriptionsProgramDto.cs delete mode 100644 Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/EventDetailsDto.cs delete mode 100644 Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/GracenoteDto.cs delete mode 100644 Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/HeadendsDto.cs delete mode 100644 Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/ImageDataDto.cs delete mode 100644 Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/LineupDto.cs delete mode 100644 Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/LineupsDto.cs delete mode 100644 Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/LogoDto.cs delete mode 100644 Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/MapDto.cs delete mode 100644 Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/MetadataDto.cs delete mode 100644 Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/MetadataProgramsDto.cs delete mode 100644 Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/MetadataScheduleDto.cs delete mode 100644 Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/MovieDto.cs delete mode 100644 Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/MultipartDto.cs delete mode 100644 Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/ProgramDetailsDto.cs delete mode 100644 Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/ProgramDto.cs delete mode 100644 Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/QualityRatingDto.cs delete mode 100644 Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/RatingDto.cs delete mode 100644 Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/RecommendationDto.cs delete mode 100644 Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/RequestScheduleForChannelDto.cs delete mode 100644 Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/ShowImagesDto.cs delete mode 100644 Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/StationDto.cs delete mode 100644 Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/TitleDto.cs delete mode 100644 Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/TokenDto.cs delete mode 100644 Emby.Server.Implementations/LiveTv/Listings/XmlTvListingsProvider.cs delete mode 100644 Emby.Server.Implementations/LiveTv/LiveTvConfigurationFactory.cs delete mode 100644 Emby.Server.Implementations/LiveTv/LiveTvDtoService.cs delete mode 100644 Emby.Server.Implementations/LiveTv/LiveTvManager.cs delete mode 100644 Emby.Server.Implementations/LiveTv/LiveTvMediaSourceProvider.cs delete mode 100644 Emby.Server.Implementations/LiveTv/RefreshGuideScheduledTask.cs delete mode 100644 Emby.Server.Implementations/LiveTv/TunerHosts/BaseTunerHost.cs delete mode 100644 Emby.Server.Implementations/LiveTv/TunerHosts/HdHomerun/Channels.cs delete mode 100644 Emby.Server.Implementations/LiveTv/TunerHosts/HdHomerun/DiscoverResponse.cs delete mode 100644 Emby.Server.Implementations/LiveTv/TunerHosts/HdHomerun/HdHomerunChannelCommands.cs delete mode 100644 Emby.Server.Implementations/LiveTv/TunerHosts/HdHomerun/HdHomerunHost.cs delete mode 100644 Emby.Server.Implementations/LiveTv/TunerHosts/HdHomerun/HdHomerunManager.cs delete mode 100644 Emby.Server.Implementations/LiveTv/TunerHosts/HdHomerun/HdHomerunUdpStream.cs delete mode 100644 Emby.Server.Implementations/LiveTv/TunerHosts/HdHomerun/IHdHomerunChannelCommands.cs delete mode 100644 Emby.Server.Implementations/LiveTv/TunerHosts/HdHomerun/LegacyHdHomerunChannelCommands.cs delete mode 100644 Emby.Server.Implementations/LiveTv/TunerHosts/LiveStream.cs delete mode 100644 Emby.Server.Implementations/LiveTv/TunerHosts/M3UTunerHost.cs delete mode 100644 Emby.Server.Implementations/LiveTv/TunerHosts/M3uParser.cs delete mode 100644 Emby.Server.Implementations/LiveTv/TunerHosts/SharedHttpStream.cs create mode 100644 src/Jellyfin.LiveTv/EmbyTV/DirectRecorder.cs create mode 100644 src/Jellyfin.LiveTv/EmbyTV/EmbyTV.cs create mode 100644 src/Jellyfin.LiveTv/EmbyTV/EncodedRecorder.cs create mode 100644 src/Jellyfin.LiveTv/EmbyTV/EntryPoint.cs create mode 100644 src/Jellyfin.LiveTv/EmbyTV/EpgChannelData.cs create mode 100644 src/Jellyfin.LiveTv/EmbyTV/IRecorder.cs create mode 100644 src/Jellyfin.LiveTv/EmbyTV/ItemDataProvider.cs create mode 100644 src/Jellyfin.LiveTv/EmbyTV/NfoConfigurationExtensions.cs create mode 100644 src/Jellyfin.LiveTv/EmbyTV/RecordingHelper.cs create mode 100644 src/Jellyfin.LiveTv/EmbyTV/SeriesTimerManager.cs create mode 100644 src/Jellyfin.LiveTv/EmbyTV/TimerManager.cs create mode 100644 src/Jellyfin.LiveTv/ExclusiveLiveStream.cs create mode 100644 src/Jellyfin.LiveTv/Jellyfin.LiveTv.csproj create mode 100644 src/Jellyfin.LiveTv/Listings/SchedulesDirect.cs create mode 100644 src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/BroadcasterDto.cs create mode 100644 src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/CaptionDto.cs create mode 100644 src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/CastDto.cs create mode 100644 src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/ChannelDto.cs create mode 100644 src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/ContentRatingDto.cs create mode 100644 src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/CrewDto.cs create mode 100644 src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/DayDto.cs create mode 100644 src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/Description1000Dto.cs create mode 100644 src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/Description100Dto.cs create mode 100644 src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/DescriptionsProgramDto.cs create mode 100644 src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/EventDetailsDto.cs create mode 100644 src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/GracenoteDto.cs create mode 100644 src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/HeadendsDto.cs create mode 100644 src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/ImageDataDto.cs create mode 100644 src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/LineupDto.cs create mode 100644 src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/LineupsDto.cs create mode 100644 src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/LogoDto.cs create mode 100644 src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/MapDto.cs create mode 100644 src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/MetadataDto.cs create mode 100644 src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/MetadataProgramsDto.cs create mode 100644 src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/MetadataScheduleDto.cs create mode 100644 src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/MovieDto.cs create mode 100644 src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/MultipartDto.cs create mode 100644 src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/ProgramDetailsDto.cs create mode 100644 src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/ProgramDto.cs create mode 100644 src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/QualityRatingDto.cs create mode 100644 src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/RatingDto.cs create mode 100644 src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/RecommendationDto.cs create mode 100644 src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/RequestScheduleForChannelDto.cs create mode 100644 src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/ShowImagesDto.cs create mode 100644 src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/StationDto.cs create mode 100644 src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/TitleDto.cs create mode 100644 src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/TokenDto.cs create mode 100644 src/Jellyfin.LiveTv/Listings/XmlTvListingsProvider.cs create mode 100644 src/Jellyfin.LiveTv/LiveTvConfigurationFactory.cs create mode 100644 src/Jellyfin.LiveTv/LiveTvDtoService.cs create mode 100644 src/Jellyfin.LiveTv/LiveTvManager.cs create mode 100644 src/Jellyfin.LiveTv/LiveTvMediaSourceProvider.cs create mode 100644 src/Jellyfin.LiveTv/RefreshGuideScheduledTask.cs create mode 100644 src/Jellyfin.LiveTv/TunerHosts/BaseTunerHost.cs create mode 100644 src/Jellyfin.LiveTv/TunerHosts/HdHomerun/Channels.cs create mode 100644 src/Jellyfin.LiveTv/TunerHosts/HdHomerun/DiscoverResponse.cs create mode 100644 src/Jellyfin.LiveTv/TunerHosts/HdHomerun/HdHomerunChannelCommands.cs create mode 100644 src/Jellyfin.LiveTv/TunerHosts/HdHomerun/HdHomerunHost.cs create mode 100644 src/Jellyfin.LiveTv/TunerHosts/HdHomerun/HdHomerunManager.cs create mode 100644 src/Jellyfin.LiveTv/TunerHosts/HdHomerun/HdHomerunUdpStream.cs create mode 100644 src/Jellyfin.LiveTv/TunerHosts/HdHomerun/IHdHomerunChannelCommands.cs create mode 100644 src/Jellyfin.LiveTv/TunerHosts/HdHomerun/LegacyHdHomerunChannelCommands.cs create mode 100644 src/Jellyfin.LiveTv/TunerHosts/LiveStream.cs create mode 100644 src/Jellyfin.LiveTv/TunerHosts/M3UTunerHost.cs create mode 100644 src/Jellyfin.LiveTv/TunerHosts/M3uParser.cs create mode 100644 src/Jellyfin.LiveTv/TunerHosts/SharedHttpStream.cs (limited to 'Emby.Server.Implementations') diff --git a/Emby.Server.Implementations/ApplicationHost.cs b/Emby.Server.Implementations/ApplicationHost.cs index f385f6a51..bb565fb2b 100644 --- a/Emby.Server.Implementations/ApplicationHost.cs +++ b/Emby.Server.Implementations/ApplicationHost.cs @@ -25,7 +25,6 @@ using Emby.Server.Implementations.Dto; using Emby.Server.Implementations.HttpServer.Security; using Emby.Server.Implementations.IO; using Emby.Server.Implementations.Library; -using Emby.Server.Implementations.LiveTv; using Emby.Server.Implementations.Localization; using Emby.Server.Implementations.Playlists; using Emby.Server.Implementations.Plugins; @@ -567,9 +566,6 @@ namespace Emby.Server.Implementations serviceCollection.AddSingleton(); - serviceCollection.AddSingleton(); - serviceCollection.AddSingleton(); - serviceCollection.AddSingleton(); serviceCollection.AddSingleton(); diff --git a/Emby.Server.Implementations/Emby.Server.Implementations.csproj b/Emby.Server.Implementations/Emby.Server.Implementations.csproj index b3344bb9f..34276355a 100644 --- a/Emby.Server.Implementations/Emby.Server.Implementations.csproj +++ b/Emby.Server.Implementations/Emby.Server.Implementations.csproj @@ -22,7 +22,6 @@ - diff --git a/Emby.Server.Implementations/Library/ExclusiveLiveStream.cs b/Emby.Server.Implementations/Library/ExclusiveLiveStream.cs deleted file mode 100644 index b1649afad..000000000 --- a/Emby.Server.Implementations/Library/ExclusiveLiveStream.cs +++ /dev/null @@ -1,60 +0,0 @@ -#nullable disable - -#pragma warning disable CS1591 - -using System; -using System.Globalization; -using System.IO; -using System.Threading; -using System.Threading.Tasks; -using MediaBrowser.Controller.Library; -using MediaBrowser.Model.Dto; - -namespace Emby.Server.Implementations.Library -{ - public sealed class ExclusiveLiveStream : ILiveStream - { - private readonly Func _closeFn; - - public ExclusiveLiveStream(MediaSourceInfo mediaSource, Func closeFn) - { - MediaSource = mediaSource; - EnableStreamSharing = false; - _closeFn = closeFn; - ConsumerCount = 1; - UniqueId = Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture); - } - - public int ConsumerCount { get; set; } - - public string OriginalStreamId { get; set; } - - public string TunerHostId => null; - - public bool EnableStreamSharing { get; set; } - - public MediaSourceInfo MediaSource { get; set; } - - public string UniqueId { get; } - - public Task Close() - { - return _closeFn(); - } - - public Stream GetStream() - { - throw new NotSupportedException(); - } - - public Task Open(CancellationToken openCancellationToken) - { - return Task.CompletedTask; - } - - /// - public void Dispose() - { - } - } -} diff --git a/Emby.Server.Implementations/LiveTv/EmbyTV/DirectRecorder.cs b/Emby.Server.Implementations/LiveTv/EmbyTV/DirectRecorder.cs deleted file mode 100644 index 7df66d358..000000000 --- a/Emby.Server.Implementations/LiveTv/EmbyTV/DirectRecorder.cs +++ /dev/null @@ -1,119 +0,0 @@ -#pragma warning disable CS1591 - -using System; -using System.IO; -using System.Net.Http; -using System.Threading; -using System.Threading.Tasks; -using Jellyfin.Api.Helpers; -using MediaBrowser.Common.Net; -using MediaBrowser.Controller.Library; -using MediaBrowser.Controller.Streaming; -using MediaBrowser.Model.Dto; -using MediaBrowser.Model.IO; -using Microsoft.Extensions.Logging; - -namespace Emby.Server.Implementations.LiveTv.EmbyTV -{ - public sealed class DirectRecorder : IRecorder - { - private readonly ILogger _logger; - private readonly IHttpClientFactory _httpClientFactory; - private readonly IStreamHelper _streamHelper; - - public DirectRecorder(ILogger logger, IHttpClientFactory httpClientFactory, IStreamHelper streamHelper) - { - _logger = logger; - _httpClientFactory = httpClientFactory; - _streamHelper = streamHelper; - } - - public string GetOutputPath(MediaSourceInfo mediaSource, string targetFile) - { - return targetFile; - } - - public Task Record(IDirectStreamProvider? directStreamProvider, MediaSourceInfo mediaSource, string targetFile, TimeSpan duration, Action onStarted, CancellationToken cancellationToken) - { - if (directStreamProvider is not null) - { - return RecordFromDirectStreamProvider(directStreamProvider, targetFile, duration, onStarted, cancellationToken); - } - - return RecordFromMediaSource(mediaSource, targetFile, duration, onStarted, cancellationToken); - } - - private async Task RecordFromDirectStreamProvider(IDirectStreamProvider directStreamProvider, string targetFile, TimeSpan duration, Action onStarted, CancellationToken cancellationToken) - { - Directory.CreateDirectory(Path.GetDirectoryName(targetFile) ?? throw new ArgumentException("Path can't be a root directory.", nameof(targetFile))); - - var output = new FileStream( - targetFile, - FileMode.CreateNew, - FileAccess.Write, - FileShare.Read, - IODefaults.FileStreamBufferSize, - FileOptions.Asynchronous); - - await using (output.ConfigureAwait(false)) - { - onStarted(); - - _logger.LogInformation("Copying recording to file {FilePath}", targetFile); - - // The media source is infinite so we need to handle stopping ourselves - using var durationToken = new CancellationTokenSource(duration); - using var cancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, durationToken.Token); - var linkedCancellationToken = cancellationTokenSource.Token; - var fileStream = new ProgressiveFileStream(directStreamProvider.GetStream()); - await using (fileStream.ConfigureAwait(false)) - { - await _streamHelper.CopyToAsync( - fileStream, - output, - IODefaults.CopyToBufferSize, - 1000, - linkedCancellationToken).ConfigureAwait(false); - } - } - - _logger.LogInformation("Recording completed: {FilePath}", targetFile); - } - - private async Task RecordFromMediaSource(MediaSourceInfo mediaSource, string targetFile, TimeSpan duration, Action onStarted, CancellationToken cancellationToken) - { - using var response = await _httpClientFactory.CreateClient(NamedClient.Default) - .GetAsync(mediaSource.Path, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); - - _logger.LogInformation("Opened recording stream from tuner provider"); - - Directory.CreateDirectory(Path.GetDirectoryName(targetFile) ?? throw new ArgumentException("Path can't be a root directory.", nameof(targetFile))); - - var output = new FileStream(targetFile, FileMode.CreateNew, FileAccess.Write, FileShare.Read, IODefaults.CopyToBufferSize, FileOptions.Asynchronous); - await using (output.ConfigureAwait(false)) - { - onStarted(); - - _logger.LogInformation("Copying recording stream to file {0}", targetFile); - - // The media source if infinite so we need to handle stopping ourselves - using var durationToken = new CancellationTokenSource(duration); - using var linkedCancellationToken = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, durationToken.Token); - cancellationToken = linkedCancellationToken.Token; - - await _streamHelper.CopyUntilCancelled( - await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false), - output, - IODefaults.CopyToBufferSize, - cancellationToken).ConfigureAwait(false); - - _logger.LogInformation("Recording completed to file {0}", targetFile); - } - } - - /// - public void Dispose() - { - } - } -} diff --git a/Emby.Server.Implementations/LiveTv/EmbyTV/EmbyTV.cs b/Emby.Server.Implementations/LiveTv/EmbyTV/EmbyTV.cs deleted file mode 100644 index e2e0dfb2b..000000000 --- a/Emby.Server.Implementations/LiveTv/EmbyTV/EmbyTV.cs +++ /dev/null @@ -1,2623 +0,0 @@ -#nullable disable - -#pragma warning disable CS1591 - -using System; -using System.Collections.Concurrent; -using System.Collections.Generic; -using System.Diagnostics; -using System.Globalization; -using System.IO; -using System.Linq; -using System.Net.Http; -using System.Text; -using System.Threading; -using System.Threading.Tasks; -using System.Xml; -using Emby.Server.Implementations.Library; -using Jellyfin.Data.Enums; -using Jellyfin.Data.Events; -using Jellyfin.Extensions; -using MediaBrowser.Common.Configuration; -using MediaBrowser.Common.Extensions; -using MediaBrowser.Common.Progress; -using MediaBrowser.Controller; -using MediaBrowser.Controller.Configuration; -using MediaBrowser.Controller.Dto; -using MediaBrowser.Controller.Entities; -using MediaBrowser.Controller.Entities.TV; -using MediaBrowser.Controller.Library; -using MediaBrowser.Controller.LiveTv; -using MediaBrowser.Controller.MediaEncoding; -using MediaBrowser.Controller.Providers; -using MediaBrowser.Model.Configuration; -using MediaBrowser.Model.Dto; -using MediaBrowser.Model.Entities; -using MediaBrowser.Model.IO; -using MediaBrowser.Model.LiveTv; -using MediaBrowser.Model.MediaInfo; -using MediaBrowser.Model.Providers; -using Microsoft.Extensions.Logging; - -namespace Emby.Server.Implementations.LiveTv.EmbyTV -{ - public sealed class EmbyTV : ILiveTvService, ISupportsDirectStreamProvider, ISupportsNewTimerIds, IDisposable - { - public const string DateAddedFormat = "yyyy-MM-dd HH:mm:ss"; - - private const int TunerDiscoveryDurationMs = 3000; - - private readonly ILogger _logger; - private readonly IHttpClientFactory _httpClientFactory; - private readonly IServerConfigurationManager _config; - - private readonly ItemDataProvider _seriesTimerProvider; - private readonly TimerManager _timerProvider; - - private readonly LiveTvManager _liveTvManager; - private readonly IFileSystem _fileSystem; - - private readonly ILibraryMonitor _libraryMonitor; - private readonly ILibraryManager _libraryManager; - private readonly IProviderManager _providerManager; - private readonly IMediaEncoder _mediaEncoder; - private readonly IMediaSourceManager _mediaSourceManager; - private readonly IStreamHelper _streamHelper; - - private readonly ConcurrentDictionary _activeRecordings = - new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase); - - private readonly ConcurrentDictionary _epgChannels = - new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase); - - private readonly SemaphoreSlim _recordingDeleteSemaphore = new SemaphoreSlim(1, 1); - - private bool _disposed; - - public EmbyTV( - IStreamHelper streamHelper, - IMediaSourceManager mediaSourceManager, - ILogger logger, - IHttpClientFactory httpClientFactory, - IServerConfigurationManager config, - ILiveTvManager liveTvManager, - IFileSystem fileSystem, - ILibraryManager libraryManager, - ILibraryMonitor libraryMonitor, - IProviderManager providerManager, - IMediaEncoder mediaEncoder) - { - Current = this; - - _logger = logger; - _httpClientFactory = httpClientFactory; - _config = config; - _fileSystem = fileSystem; - _libraryManager = libraryManager; - _libraryMonitor = libraryMonitor; - _providerManager = providerManager; - _mediaEncoder = mediaEncoder; - _liveTvManager = (LiveTvManager)liveTvManager; - _mediaSourceManager = mediaSourceManager; - _streamHelper = streamHelper; - - _seriesTimerProvider = new SeriesTimerManager(_logger, Path.Combine(DataPath, "seriestimers.json")); - _timerProvider = new TimerManager(_logger, Path.Combine(DataPath, "timers.json")); - _timerProvider.TimerFired += OnTimerProviderTimerFired; - - _config.NamedConfigurationUpdated += OnNamedConfigurationUpdated; - } - - public event EventHandler> TimerCreated; - - public event EventHandler> TimerCancelled; - - public static EmbyTV Current { get; private set; } - - /// - public string Name => "Emby"; - - public string DataPath => Path.Combine(_config.CommonApplicationPaths.DataPath, "livetv"); - - /// - public string HomePageUrl => "https://github.com/jellyfin/jellyfin"; - - private string DefaultRecordingPath => Path.Combine(DataPath, "recordings"); - - private string RecordingPath - { - get - { - var path = GetConfiguration().RecordingPath; - - return string.IsNullOrWhiteSpace(path) - ? DefaultRecordingPath - : path; - } - } - - private async void OnNamedConfigurationUpdated(object sender, ConfigurationUpdateEventArgs e) - { - if (string.Equals(e.Key, "livetv", StringComparison.OrdinalIgnoreCase)) - { - await CreateRecordingFolders().ConfigureAwait(false); - } - } - - public Task Start() - { - _timerProvider.RestartTimers(); - - return CreateRecordingFolders(); - } - - internal async Task CreateRecordingFolders() - { - try - { - var recordingFolders = GetRecordingFolders().ToArray(); - var virtualFolders = _libraryManager.GetVirtualFolders(); - - var allExistingPaths = virtualFolders.SelectMany(i => i.Locations).ToList(); - - var pathsAdded = new List(); - - foreach (var recordingFolder in recordingFolders) - { - var pathsToCreate = recordingFolder.Locations - .Where(i => !allExistingPaths.Any(p => _fileSystem.AreEqual(p, i))) - .ToList(); - - if (pathsToCreate.Count == 0) - { - continue; - } - - var mediaPathInfos = pathsToCreate.Select(i => new MediaPathInfo(i)).ToArray(); - - var libraryOptions = new LibraryOptions - { - PathInfos = mediaPathInfos - }; - try - { - await _libraryManager.AddVirtualFolder(recordingFolder.Name, recordingFolder.CollectionType, libraryOptions, true).ConfigureAwait(false); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error creating virtual folder"); - } - - pathsAdded.AddRange(pathsToCreate); - } - - var config = GetConfiguration(); - - var pathsToRemove = config.MediaLocationsCreated - .Except(recordingFolders.SelectMany(i => i.Locations)) - .ToList(); - - if (pathsAdded.Count > 0 || pathsToRemove.Count > 0) - { - pathsAdded.InsertRange(0, config.MediaLocationsCreated); - config.MediaLocationsCreated = pathsAdded.Except(pathsToRemove).Distinct(StringComparer.OrdinalIgnoreCase).ToArray(); - _config.SaveConfiguration("livetv", config); - } - - foreach (var path in pathsToRemove) - { - await RemovePathFromLibraryAsync(path).ConfigureAwait(false); - } - } - catch (Exception ex) - { - _logger.LogError(ex, "Error creating recording folders"); - } - } - - private async Task RemovePathFromLibraryAsync(string path) - { - _logger.LogDebug("Removing path from library: {0}", path); - - var requiresRefresh = false; - var virtualFolders = _libraryManager.GetVirtualFolders(); - - foreach (var virtualFolder in virtualFolders) - { - if (!virtualFolder.Locations.Contains(path, StringComparison.OrdinalIgnoreCase)) - { - continue; - } - - if (virtualFolder.Locations.Length == 1) - { - // remove entire virtual folder - try - { - await _libraryManager.RemoveVirtualFolder(virtualFolder.Name, true).ConfigureAwait(false); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error removing virtual folder"); - } - } - else - { - try - { - _libraryManager.RemoveMediaPath(virtualFolder.Name, path); - requiresRefresh = true; - } - catch (Exception ex) - { - _logger.LogError(ex, "Error removing media path"); - } - } - } - - if (requiresRefresh) - { - await _libraryManager.ValidateMediaLibrary(new SimpleProgress(), CancellationToken.None).ConfigureAwait(false); - } - } - - public async Task RefreshSeriesTimers(CancellationToken cancellationToken) - { - var seriesTimers = await GetSeriesTimersAsync(cancellationToken).ConfigureAwait(false); - - foreach (var timer in seriesTimers) - { - UpdateTimersForSeriesTimer(timer, false, true); - } - } - - public async Task RefreshTimers(CancellationToken cancellationToken) - { - var timers = await GetTimersAsync(cancellationToken).ConfigureAwait(false); - - var tempChannelCache = new Dictionary(); - - foreach (var timer in timers) - { - if (DateTime.UtcNow > timer.EndDate && !_activeRecordings.ContainsKey(timer.Id)) - { - OnTimerOutOfDate(timer); - continue; - } - - if (string.IsNullOrWhiteSpace(timer.ProgramId) || string.IsNullOrWhiteSpace(timer.ChannelId)) - { - continue; - } - - var program = GetProgramInfoFromCache(timer); - if (program is null) - { - OnTimerOutOfDate(timer); - continue; - } - - CopyProgramInfoToTimerInfo(program, timer, tempChannelCache); - _timerProvider.Update(timer); - } - } - - private void OnTimerOutOfDate(TimerInfo timer) - { - _timerProvider.Delete(timer); - } - - private async Task> GetChannelsAsync(bool enableCache, CancellationToken cancellationToken) - { - var list = new List(); - - foreach (var hostInstance in _liveTvManager.TunerHosts) - { - try - { - var channels = await hostInstance.GetChannels(enableCache, cancellationToken).ConfigureAwait(false); - - list.AddRange(channels); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error getting channels"); - } - } - - foreach (var provider in GetListingProviders()) - { - var enabledChannels = list - .Where(i => IsListingProviderEnabledForTuner(provider.Item2, i.TunerHostId)) - .ToList(); - - if (enabledChannels.Count > 0) - { - try - { - await AddMetadata(provider.Item1, provider.Item2, enabledChannels, enableCache, cancellationToken).ConfigureAwait(false); - } - catch (NotSupportedException) - { - } - catch (Exception ex) - { - _logger.LogError(ex, "Error adding metadata"); - } - } - } - - return list; - } - - private async Task AddMetadata( - IListingsProvider provider, - ListingsProviderInfo info, - IEnumerable tunerChannels, - bool enableCache, - CancellationToken cancellationToken) - { - var epgChannels = await GetEpgChannels(provider, info, enableCache, cancellationToken).ConfigureAwait(false); - - foreach (var tunerChannel in tunerChannels) - { - var epgChannel = GetEpgChannelFromTunerChannel(info, tunerChannel, epgChannels); - - if (epgChannel is not null) - { - if (!string.IsNullOrWhiteSpace(epgChannel.Name)) - { - // tunerChannel.Name = epgChannel.Name; - } - - if (!string.IsNullOrWhiteSpace(epgChannel.ImageUrl)) - { - tunerChannel.ImageUrl = epgChannel.ImageUrl; - } - } - } - } - - private async Task GetEpgChannels( - IListingsProvider provider, - ListingsProviderInfo info, - bool enableCache, - CancellationToken cancellationToken) - { - if (!enableCache || !_epgChannels.TryGetValue(info.Id, out var result)) - { - var channels = await provider.GetChannels(info, cancellationToken).ConfigureAwait(false); - - foreach (var channel in channels) - { - _logger.LogInformation("Found epg channel in {0} {1} {2} {3}", provider.Name, info.ListingsId, channel.Name, channel.Id); - } - - result = new EpgChannelData(channels); - _epgChannels.AddOrUpdate(info.Id, result, (_, _) => result); - } - - return result; - } - - private async Task GetEpgChannelFromTunerChannel(IListingsProvider provider, ListingsProviderInfo info, ChannelInfo tunerChannel, CancellationToken cancellationToken) - { - var epgChannels = await GetEpgChannels(provider, info, true, cancellationToken).ConfigureAwait(false); - - return GetEpgChannelFromTunerChannel(info, tunerChannel, epgChannels); - } - - private static string GetMappedChannel(string channelId, NameValuePair[] mappings) - { - foreach (NameValuePair mapping in mappings) - { - if (string.Equals(mapping.Name, channelId, StringComparison.OrdinalIgnoreCase)) - { - return mapping.Value; - } - } - - return channelId; - } - - internal ChannelInfo GetEpgChannelFromTunerChannel(NameValuePair[] mappings, ChannelInfo tunerChannel, List epgChannels) - { - return GetEpgChannelFromTunerChannel(mappings, tunerChannel, new EpgChannelData(epgChannels)); - } - - private ChannelInfo GetEpgChannelFromTunerChannel(ListingsProviderInfo info, ChannelInfo tunerChannel, EpgChannelData epgChannels) - { - return GetEpgChannelFromTunerChannel(info.ChannelMappings, tunerChannel, epgChannels); - } - - private ChannelInfo GetEpgChannelFromTunerChannel( - NameValuePair[] mappings, - ChannelInfo tunerChannel, - EpgChannelData epgChannelData) - { - if (!string.IsNullOrWhiteSpace(tunerChannel.Id)) - { - var mappedTunerChannelId = GetMappedChannel(tunerChannel.Id, mappings); - - if (string.IsNullOrWhiteSpace(mappedTunerChannelId)) - { - mappedTunerChannelId = tunerChannel.Id; - } - - var channel = epgChannelData.GetChannelById(mappedTunerChannelId); - - if (channel is not null) - { - return channel; - } - } - - if (!string.IsNullOrWhiteSpace(tunerChannel.TunerChannelId)) - { - var tunerChannelId = tunerChannel.TunerChannelId; - if (tunerChannelId.Contains(".json.schedulesdirect.org", StringComparison.OrdinalIgnoreCase)) - { - tunerChannelId = tunerChannelId.Replace(".json.schedulesdirect.org", string.Empty, StringComparison.OrdinalIgnoreCase).TrimStart('I'); - } - - var mappedTunerChannelId = GetMappedChannel(tunerChannelId, mappings); - - if (string.IsNullOrWhiteSpace(mappedTunerChannelId)) - { - mappedTunerChannelId = tunerChannelId; - } - - var channel = epgChannelData.GetChannelById(mappedTunerChannelId); - - if (channel is not null) - { - return channel; - } - } - - if (!string.IsNullOrWhiteSpace(tunerChannel.Number)) - { - var tunerChannelNumber = GetMappedChannel(tunerChannel.Number, mappings); - - if (string.IsNullOrWhiteSpace(tunerChannelNumber)) - { - tunerChannelNumber = tunerChannel.Number; - } - - var channel = epgChannelData.GetChannelByNumber(tunerChannelNumber); - - if (channel is not null) - { - return channel; - } - } - - if (!string.IsNullOrWhiteSpace(tunerChannel.Name)) - { - var normalizedName = EpgChannelData.NormalizeName(tunerChannel.Name); - - var channel = epgChannelData.GetChannelByName(normalizedName); - - if (channel is not null) - { - return channel; - } - } - - return null; - } - - public async Task> GetChannelsForListingsProvider(ListingsProviderInfo listingsProvider, CancellationToken cancellationToken) - { - var list = new List(); - - foreach (var hostInstance in _liveTvManager.TunerHosts) - { - try - { - var channels = await hostInstance.GetChannels(false, cancellationToken).ConfigureAwait(false); - - list.AddRange(channels); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error getting channels"); - } - } - - return list - .Where(i => IsListingProviderEnabledForTuner(listingsProvider, i.TunerHostId)) - .ToList(); - } - - public Task> GetChannelsAsync(CancellationToken cancellationToken) - { - return GetChannelsAsync(false, cancellationToken); - } - - public Task CancelSeriesTimerAsync(string timerId, CancellationToken cancellationToken) - { - var timers = _timerProvider - .GetAll() - .Where(i => string.Equals(i.SeriesTimerId, timerId, StringComparison.OrdinalIgnoreCase)) - .ToList(); - - foreach (var timer in timers) - { - CancelTimerInternal(timer.Id, true, true); - } - - var remove = _seriesTimerProvider.GetAll().FirstOrDefault(r => string.Equals(r.Id, timerId, StringComparison.OrdinalIgnoreCase)); - if (remove is not null) - { - _seriesTimerProvider.Delete(remove); - } - - return Task.CompletedTask; - } - - private void CancelTimerInternal(string timerId, bool isSeriesCancelled, bool isManualCancellation) - { - var timer = _timerProvider.GetTimer(timerId); - if (timer is not null) - { - var statusChanging = timer.Status != RecordingStatus.Cancelled; - timer.Status = RecordingStatus.Cancelled; - - if (isManualCancellation) - { - timer.IsManual = true; - } - - if (string.IsNullOrWhiteSpace(timer.SeriesTimerId) || isSeriesCancelled) - { - _timerProvider.Delete(timer); - } - else - { - _timerProvider.AddOrUpdate(timer, false); - } - - if (statusChanging && TimerCancelled is not null) - { - TimerCancelled(this, new GenericEventArgs(timerId)); - } - } - - if (_activeRecordings.TryGetValue(timerId, out var activeRecordingInfo)) - { - activeRecordingInfo.Timer = timer; - activeRecordingInfo.CancellationTokenSource.Cancel(); - } - } - - public Task CancelTimerAsync(string timerId, CancellationToken cancellationToken) - { - CancelTimerInternal(timerId, false, true); - return Task.CompletedTask; - } - - public Task CreateSeriesTimerAsync(SeriesTimerInfo info, CancellationToken cancellationToken) - { - throw new NotImplementedException(); - } - - public Task CreateTimerAsync(TimerInfo info, CancellationToken cancellationToken) - { - throw new NotImplementedException(); - } - - public Task CreateTimer(TimerInfo info, CancellationToken cancellationToken) - { - var existingTimer = string.IsNullOrWhiteSpace(info.ProgramId) ? - null : - _timerProvider.GetTimerByProgramId(info.ProgramId); - - if (existingTimer is not null) - { - if (existingTimer.Status == RecordingStatus.Cancelled - || existingTimer.Status == RecordingStatus.Completed) - { - existingTimer.Status = RecordingStatus.New; - existingTimer.IsManual = true; - _timerProvider.Update(existingTimer); - return Task.FromResult(existingTimer.Id); - } - - throw new ArgumentException("A scheduled recording already exists for this program."); - } - - info.Id = Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture); - - LiveTvProgram programInfo = null; - - if (!string.IsNullOrWhiteSpace(info.ProgramId)) - { - programInfo = GetProgramInfoFromCache(info); - } - - if (programInfo is null) - { - _logger.LogInformation("Unable to find program with Id {0}. Will search using start date", info.ProgramId); - programInfo = GetProgramInfoFromCache(info.ChannelId, info.StartDate); - } - - if (programInfo is not null) - { - CopyProgramInfoToTimerInfo(programInfo, info); - } - - info.IsManual = true; - _timerProvider.Add(info); - - TimerCreated?.Invoke(this, new GenericEventArgs(info)); - - return Task.FromResult(info.Id); - } - - public async Task CreateSeriesTimer(SeriesTimerInfo info, CancellationToken cancellationToken) - { - info.Id = Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture); - - // populate info.seriesID - var program = GetProgramInfoFromCache(info.ProgramId); - - if (program is not null) - { - info.SeriesId = program.ExternalSeriesId; - } - else - { - throw new InvalidOperationException("SeriesId for program not found"); - } - - // If any timers have already been manually created, make sure they don't get cancelled - var existingTimers = (await GetTimersAsync(CancellationToken.None).ConfigureAwait(false)) - .Where(i => - { - if (string.Equals(i.ProgramId, info.ProgramId, StringComparison.OrdinalIgnoreCase) && !string.IsNullOrWhiteSpace(info.ProgramId)) - { - return true; - } - - if (string.Equals(i.SeriesId, info.SeriesId, StringComparison.OrdinalIgnoreCase) && !string.IsNullOrWhiteSpace(info.SeriesId)) - { - return true; - } - - return false; - }) - .ToList(); - - _seriesTimerProvider.Add(info); - - foreach (var timer in existingTimers) - { - timer.SeriesTimerId = info.Id; - timer.IsManual = true; - - _timerProvider.AddOrUpdate(timer, false); - } - - UpdateTimersForSeriesTimer(info, true, false); - - return info.Id; - } - - public Task UpdateSeriesTimerAsync(SeriesTimerInfo info, CancellationToken cancellationToken) - { - var instance = _seriesTimerProvider.GetAll().FirstOrDefault(i => string.Equals(i.Id, info.Id, StringComparison.OrdinalIgnoreCase)); - - if (instance is not null) - { - instance.ChannelId = info.ChannelId; - instance.Days = info.Days; - instance.EndDate = info.EndDate; - instance.IsPostPaddingRequired = info.IsPostPaddingRequired; - instance.IsPrePaddingRequired = info.IsPrePaddingRequired; - instance.PostPaddingSeconds = info.PostPaddingSeconds; - instance.PrePaddingSeconds = info.PrePaddingSeconds; - instance.Priority = info.Priority; - instance.RecordAnyChannel = info.RecordAnyChannel; - instance.RecordAnyTime = info.RecordAnyTime; - instance.RecordNewOnly = info.RecordNewOnly; - instance.SkipEpisodesInLibrary = info.SkipEpisodesInLibrary; - instance.KeepUpTo = info.KeepUpTo; - instance.KeepUntil = info.KeepUntil; - instance.StartDate = info.StartDate; - - _seriesTimerProvider.Update(instance); - - UpdateTimersForSeriesTimer(instance, true, true); - } - - return Task.CompletedTask; - } - - public Task UpdateTimerAsync(TimerInfo updatedTimer, CancellationToken cancellationToken) - { - var existingTimer = _timerProvider.GetTimer(updatedTimer.Id); - - if (existingTimer is null) - { - throw new ResourceNotFoundException(); - } - - // Only update if not currently active - if (!_activeRecordings.TryGetValue(updatedTimer.Id, out _)) - { - existingTimer.PrePaddingSeconds = updatedTimer.PrePaddingSeconds; - existingTimer.PostPaddingSeconds = updatedTimer.PostPaddingSeconds; - existingTimer.IsPostPaddingRequired = updatedTimer.IsPostPaddingRequired; - existingTimer.IsPrePaddingRequired = updatedTimer.IsPrePaddingRequired; - - _timerProvider.Update(existingTimer); - } - - return Task.CompletedTask; - } - - private static void UpdateExistingTimerWithNewMetadata(TimerInfo existingTimer, TimerInfo updatedTimer) - { - // Update the program info but retain the status - existingTimer.ChannelId = updatedTimer.ChannelId; - existingTimer.CommunityRating = updatedTimer.CommunityRating; - existingTimer.EndDate = updatedTimer.EndDate; - existingTimer.EpisodeNumber = updatedTimer.EpisodeNumber; - existingTimer.EpisodeTitle = updatedTimer.EpisodeTitle; - existingTimer.Genres = updatedTimer.Genres; - existingTimer.IsMovie = updatedTimer.IsMovie; - existingTimer.IsSeries = updatedTimer.IsSeries; - existingTimer.Tags = updatedTimer.Tags; - existingTimer.IsProgramSeries = updatedTimer.IsProgramSeries; - existingTimer.IsRepeat = updatedTimer.IsRepeat; - existingTimer.Name = updatedTimer.Name; - existingTimer.OfficialRating = updatedTimer.OfficialRating; - existingTimer.OriginalAirDate = updatedTimer.OriginalAirDate; - existingTimer.Overview = updatedTimer.Overview; - existingTimer.ProductionYear = updatedTimer.ProductionYear; - existingTimer.ProgramId = updatedTimer.ProgramId; - existingTimer.SeasonNumber = updatedTimer.SeasonNumber; - existingTimer.StartDate = updatedTimer.StartDate; - existingTimer.ShowId = updatedTimer.ShowId; - existingTimer.ProviderIds = updatedTimer.ProviderIds; - existingTimer.SeriesProviderIds = updatedTimer.SeriesProviderIds; - } - - public string GetActiveRecordingPath(string id) - { - if (_activeRecordings.TryGetValue(id, out var info)) - { - return info.Path; - } - - return null; - } - - public ActiveRecordingInfo GetActiveRecordingInfo(string path) - { - if (string.IsNullOrWhiteSpace(path) || _activeRecordings.IsEmpty) - { - return null; - } - - foreach (var (_, recordingInfo) in _activeRecordings) - { - if (string.Equals(recordingInfo.Path, path, StringComparison.Ordinal) && !recordingInfo.CancellationTokenSource.IsCancellationRequested) - { - var timer = recordingInfo.Timer; - if (timer.Status != RecordingStatus.InProgress) - { - return null; - } - - return recordingInfo; - } - } - - return null; - } - - public Task> GetTimersAsync(CancellationToken cancellationToken) - { - var excludeStatues = new List - { - RecordingStatus.Completed - }; - - var timers = _timerProvider.GetAll() - .Where(i => !excludeStatues.Contains(i.Status)); - - return Task.FromResult(timers); - } - - public Task GetNewTimerDefaultsAsync(CancellationToken cancellationToken, ProgramInfo program = null) - { - var config = GetConfiguration(); - - var defaults = new SeriesTimerInfo() - { - PostPaddingSeconds = Math.Max(config.PostPaddingSeconds, 0), - PrePaddingSeconds = Math.Max(config.PrePaddingSeconds, 0), - RecordAnyChannel = false, - RecordAnyTime = true, - RecordNewOnly = true, - - Days = new List - { - DayOfWeek.Sunday, - DayOfWeek.Monday, - DayOfWeek.Tuesday, - DayOfWeek.Wednesday, - DayOfWeek.Thursday, - DayOfWeek.Friday, - DayOfWeek.Saturday - } - }; - - if (program is not null) - { - defaults.SeriesId = program.SeriesId; - defaults.ProgramId = program.Id; - defaults.RecordNewOnly = !program.IsRepeat; - defaults.Name = program.Name; - } - - defaults.SkipEpisodesInLibrary = defaults.RecordNewOnly; - defaults.KeepUntil = KeepUntil.UntilDeleted; - - return Task.FromResult(defaults); - } - - public Task> GetSeriesTimersAsync(CancellationToken cancellationToken) - { - return Task.FromResult((IEnumerable)_seriesTimerProvider.GetAll()); - } - - private bool IsListingProviderEnabledForTuner(ListingsProviderInfo info, string tunerHostId) - { - if (info.EnableAllTuners) - { - return true; - } - - if (string.IsNullOrWhiteSpace(tunerHostId)) - { - throw new ArgumentNullException(nameof(tunerHostId)); - } - - return info.EnabledTuners.Contains(tunerHostId, StringComparison.OrdinalIgnoreCase); - } - - public async Task> GetProgramsAsync(string channelId, DateTime startDateUtc, DateTime endDateUtc, CancellationToken cancellationToken) - { - var channels = await GetChannelsAsync(true, cancellationToken).ConfigureAwait(false); - var channel = channels.First(i => string.Equals(i.Id, channelId, StringComparison.OrdinalIgnoreCase)); - - foreach (var provider in GetListingProviders()) - { - if (!IsListingProviderEnabledForTuner(provider.Item2, channel.TunerHostId)) - { - _logger.LogDebug("Skipping getting programs for channel {0}-{1} from {2}-{3}, because it's not enabled for this tuner.", channel.Number, channel.Name, provider.Item1.Name, provider.Item2.ListingsId ?? string.Empty); - continue; - } - - _logger.LogDebug("Getting programs for channel {0}-{1} from {2}-{3}", channel.Number, channel.Name, provider.Item1.Name, provider.Item2.ListingsId ?? string.Empty); - - var epgChannel = await GetEpgChannelFromTunerChannel(provider.Item1, provider.Item2, channel, cancellationToken).ConfigureAwait(false); - - if (epgChannel is null) - { - _logger.LogDebug("EPG channel not found for tuner channel {0}-{1} from {2}-{3}", channel.Number, channel.Name, provider.Item1.Name, provider.Item2.ListingsId ?? string.Empty); - continue; - } - - List programs = (await provider.Item1.GetProgramsAsync(provider.Item2, epgChannel.Id, startDateUtc, endDateUtc, cancellationToken) - .ConfigureAwait(false)).ToList(); - - // Replace the value that came from the provider with a normalized value - foreach (var program in programs) - { - program.ChannelId = channelId; - - program.Id += "_" + channelId; - } - - if (programs.Count > 0) - { - return programs; - } - } - - return Enumerable.Empty(); - } - - private List> GetListingProviders() - { - return GetConfiguration().ListingProviders - .Select(i => - { - var provider = _liveTvManager.ListingProviders.FirstOrDefault(l => string.Equals(l.Type, i.Type, StringComparison.OrdinalIgnoreCase)); - - return provider is null ? null : new Tuple(provider, i); - }) - .Where(i => i is not null) - .ToList(); - } - - public Task GetChannelStream(string channelId, string streamId, CancellationToken cancellationToken) - { - throw new NotImplementedException(); - } - - public async Task GetChannelStreamWithDirectStreamProvider(string channelId, string streamId, List currentLiveStreams, CancellationToken cancellationToken) - { - _logger.LogInformation("Streaming Channel {Id}", channelId); - - var result = string.IsNullOrEmpty(streamId) ? - null : - currentLiveStreams.FirstOrDefault(i => string.Equals(i.OriginalStreamId, streamId, StringComparison.OrdinalIgnoreCase)); - - if (result is not null && result.EnableStreamSharing) - { - result.ConsumerCount++; - - _logger.LogInformation("Live stream {0} consumer count is now {1}", streamId, result.ConsumerCount); - - return result; - } - - foreach (var hostInstance in _liveTvManager.TunerHosts) - { - try - { - result = await hostInstance.GetChannelStream(channelId, streamId, currentLiveStreams, cancellationToken).ConfigureAwait(false); - - var openedMediaSource = result.MediaSource; - - result.OriginalStreamId = streamId; - - _logger.LogInformation("Returning mediasource streamId {0}, mediaSource.Id {1}, mediaSource.LiveStreamId {2}", streamId, openedMediaSource.Id, openedMediaSource.LiveStreamId); - - return result; - } - catch (FileNotFoundException) - { - } - catch (OperationCanceledException) - { - } - } - - throw new ResourceNotFoundException("Tuner not found."); - } - - public async Task> GetChannelStreamMediaSources(string channelId, CancellationToken cancellationToken) - { - if (string.IsNullOrWhiteSpace(channelId)) - { - throw new ArgumentNullException(nameof(channelId)); - } - - foreach (var hostInstance in _liveTvManager.TunerHosts) - { - try - { - var sources = await hostInstance.GetChannelStreamMediaSources(channelId, cancellationToken).ConfigureAwait(false); - - if (sources.Count > 0) - { - return sources; - } - } - catch (NotImplementedException) - { - } - } - - throw new NotImplementedException(); - } - - public Task CloseLiveStream(string id, CancellationToken cancellationToken) - { - return Task.CompletedTask; - } - - public Task RecordLiveStream(string id, CancellationToken cancellationToken) - { - return Task.CompletedTask; - } - - public Task ResetTuner(string id, CancellationToken cancellationToken) - { - return Task.CompletedTask; - } - - private async void OnTimerProviderTimerFired(object sender, GenericEventArgs e) - { - var timer = e.Argument; - - _logger.LogInformation("Recording timer fired for {0}.", timer.Name); - - try - { - var recordingEndDate = timer.EndDate.AddSeconds(timer.PostPaddingSeconds); - - if (recordingEndDate <= DateTime.UtcNow) - { - _logger.LogWarning("Recording timer fired for updatedTimer {0}, Id: {1}, but the program has already ended.", timer.Name, timer.Id); - OnTimerOutOfDate(timer); - return; - } - - var activeRecordingInfo = new ActiveRecordingInfo - { - CancellationTokenSource = new CancellationTokenSource(), - Timer = timer, - Id = timer.Id - }; - - if (!_activeRecordings.ContainsKey(timer.Id)) - { - await RecordStream(timer, recordingEndDate, activeRecordingInfo).ConfigureAwait(false); - } - else - { - _logger.LogInformation("Skipping RecordStream because it's already in progress."); - } - } - catch (OperationCanceledException) - { - } - catch (Exception ex) - { - _logger.LogError(ex, "Error recording stream"); - } - } - - private string GetRecordingPath(TimerInfo timer, RemoteSearchResult metadata, out string seriesPath) - { - var recordPath = RecordingPath; - var config = GetConfiguration(); - seriesPath = null; - - if (timer.IsProgramSeries) - { - var customRecordingPath = config.SeriesRecordingPath; - var allowSubfolder = true; - if (!string.IsNullOrWhiteSpace(customRecordingPath)) - { - allowSubfolder = string.Equals(customRecordingPath, recordPath, StringComparison.OrdinalIgnoreCase); - recordPath = customRecordingPath; - } - - if (allowSubfolder && config.EnableRecordingSubfolders) - { - recordPath = Path.Combine(recordPath, "Series"); - } - - // trim trailing period from the folder name - var folderName = _fileSystem.GetValidFilename(timer.Name).Trim().TrimEnd('.').Trim(); - - if (metadata is not null && metadata.ProductionYear.HasValue) - { - folderName += " (" + metadata.ProductionYear.Value.ToString(CultureInfo.InvariantCulture) + ")"; - } - - // Can't use the year here in the folder name because it is the year of the episode, not the series. - recordPath = Path.Combine(recordPath, folderName); - - seriesPath = recordPath; - - if (timer.SeasonNumber.HasValue) - { - folderName = string.Format( - CultureInfo.InvariantCulture, - "Season {0}", - timer.SeasonNumber.Value); - recordPath = Path.Combine(recordPath, folderName); - } - } - else if (timer.IsMovie) - { - var customRecordingPath = config.MovieRecordingPath; - var allowSubfolder = true; - if (!string.IsNullOrWhiteSpace(customRecordingPath)) - { - allowSubfolder = string.Equals(customRecordingPath, recordPath, StringComparison.OrdinalIgnoreCase); - recordPath = customRecordingPath; - } - - if (allowSubfolder && config.EnableRecordingSubfolders) - { - recordPath = Path.Combine(recordPath, "Movies"); - } - - var folderName = _fileSystem.GetValidFilename(timer.Name).Trim(); - if (timer.ProductionYear.HasValue) - { - folderName += " (" + timer.ProductionYear.Value.ToString(CultureInfo.InvariantCulture) + ")"; - } - - // trim trailing period from the folder name - folderName = folderName.TrimEnd('.').Trim(); - - recordPath = Path.Combine(recordPath, folderName); - } - else if (timer.IsKids) - { - if (config.EnableRecordingSubfolders) - { - recordPath = Path.Combine(recordPath, "Kids"); - } - - var folderName = _fileSystem.GetValidFilename(timer.Name).Trim(); - if (timer.ProductionYear.HasValue) - { - folderName += " (" + timer.ProductionYear.Value.ToString(CultureInfo.InvariantCulture) + ")"; - } - - // trim trailing period from the folder name - folderName = folderName.TrimEnd('.').Trim(); - - recordPath = Path.Combine(recordPath, folderName); - } - else if (timer.IsSports) - { - if (config.EnableRecordingSubfolders) - { - recordPath = Path.Combine(recordPath, "Sports"); - } - - recordPath = Path.Combine(recordPath, _fileSystem.GetValidFilename(timer.Name).Trim()); - } - else - { - if (config.EnableRecordingSubfolders) - { - recordPath = Path.Combine(recordPath, "Other"); - } - - recordPath = Path.Combine(recordPath, _fileSystem.GetValidFilename(timer.Name).Trim()); - } - - var recordingFileName = _fileSystem.GetValidFilename(RecordingHelper.GetRecordingName(timer)).Trim() + ".ts"; - - return Path.Combine(recordPath, recordingFileName); - } - - private async Task RecordStream(TimerInfo timer, DateTime recordingEndDate, ActiveRecordingInfo activeRecordingInfo) - { - ArgumentNullException.ThrowIfNull(timer); - - LiveTvProgram programInfo = null; - - if (!string.IsNullOrWhiteSpace(timer.ProgramId)) - { - programInfo = GetProgramInfoFromCache(timer); - } - - if (programInfo is null) - { - _logger.LogInformation("Unable to find program with Id {0}. Will search using start date", timer.ProgramId); - programInfo = GetProgramInfoFromCache(timer.ChannelId, timer.StartDate); - } - - if (programInfo is not null) - { - CopyProgramInfoToTimerInfo(programInfo, timer); - } - - var remoteMetadata = await FetchInternetMetadata(timer, CancellationToken.None).ConfigureAwait(false); - var recordPath = GetRecordingPath(timer, remoteMetadata, out string seriesPath); - - var channelItem = _liveTvManager.GetLiveTvChannel(timer, this); - - string liveStreamId = null; - RecordingStatus recordingStatus; - try - { - var allMediaSources = await _mediaSourceManager.GetPlaybackMediaSources(channelItem, null, true, false, CancellationToken.None).ConfigureAwait(false); - - var mediaStreamInfo = allMediaSources[0]; - IDirectStreamProvider directStreamProvider = null; - - if (mediaStreamInfo.RequiresOpening) - { - var liveStreamResponse = await _mediaSourceManager.OpenLiveStreamInternal( - new LiveStreamRequest - { - ItemId = channelItem.Id, - OpenToken = mediaStreamInfo.OpenToken - }, - CancellationToken.None).ConfigureAwait(false); - - mediaStreamInfo = liveStreamResponse.Item1.MediaSource; - liveStreamId = mediaStreamInfo.LiveStreamId; - directStreamProvider = liveStreamResponse.Item2; - } - - using var recorder = GetRecorder(mediaStreamInfo); - - recordPath = recorder.GetOutputPath(mediaStreamInfo, recordPath); - recordPath = EnsureFileUnique(recordPath, timer.Id); - - _libraryMonitor.ReportFileSystemChangeBeginning(recordPath); - - var duration = recordingEndDate - DateTime.UtcNow; - - _logger.LogInformation("Beginning recording. Will record for {0} minutes.", duration.TotalMinutes.ToString(CultureInfo.InvariantCulture)); - - _logger.LogInformation("Writing file to: {Path}", recordPath); - - Action onStarted = async () => - { - activeRecordingInfo.Path = recordPath; - - _activeRecordings.TryAdd(timer.Id, activeRecordingInfo); - - timer.Status = RecordingStatus.InProgress; - _timerProvider.AddOrUpdate(timer, false); - - await SaveRecordingMetadata(timer, recordPath, seriesPath).ConfigureAwait(false); - - await CreateRecordingFolders().ConfigureAwait(false); - - TriggerRefresh(recordPath); - await EnforceKeepUpTo(timer, seriesPath).ConfigureAwait(false); - }; - - await recorder.Record(directStreamProvider, mediaStreamInfo, recordPath, duration, onStarted, activeRecordingInfo.CancellationTokenSource.Token).ConfigureAwait(false); - - recordingStatus = RecordingStatus.Completed; - _logger.LogInformation("Recording completed: {RecordPath}", recordPath); - } - catch (OperationCanceledException) - { - _logger.LogInformation("Recording stopped: {RecordPath}", recordPath); - recordingStatus = RecordingStatus.Completed; - } - catch (Exception ex) - { - _logger.LogError(ex, "Error recording to {RecordPath}", recordPath); - recordingStatus = RecordingStatus.Error; - } - - if (!string.IsNullOrWhiteSpace(liveStreamId)) - { - try - { - await _mediaSourceManager.CloseLiveStream(liveStreamId).ConfigureAwait(false); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error closing live stream"); - } - } - - DeleteFileIfEmpty(recordPath); - - TriggerRefresh(recordPath); - _libraryMonitor.ReportFileSystemChangeComplete(recordPath, false); - - _activeRecordings.TryRemove(timer.Id, out _); - - if (recordingStatus != RecordingStatus.Completed && DateTime.UtcNow < timer.EndDate && timer.RetryCount < 10) - { - const int RetryIntervalSeconds = 60; - _logger.LogInformation("Retrying recording in {0} seconds.", RetryIntervalSeconds); - - timer.Status = RecordingStatus.New; - timer.PrePaddingSeconds = 0; - timer.StartDate = DateTime.UtcNow.AddSeconds(RetryIntervalSeconds); - timer.RetryCount++; - _timerProvider.AddOrUpdate(timer); - } - else if (File.Exists(recordPath)) - { - timer.RecordingPath = recordPath; - timer.Status = RecordingStatus.Completed; - _timerProvider.AddOrUpdate(timer, false); - OnSuccessfulRecording(timer, recordPath); - } - else - { - _timerProvider.Delete(timer); - } - } - - private async Task FetchInternetMetadata(TimerInfo timer, CancellationToken cancellationToken) - { - if (timer.IsSeries) - { - if (timer.SeriesProviderIds.Count == 0) - { - return null; - } - - var query = new RemoteSearchQuery() - { - SearchInfo = new SeriesInfo - { - ProviderIds = timer.SeriesProviderIds, - Name = timer.Name, - MetadataCountryCode = _config.Configuration.MetadataCountryCode, - MetadataLanguage = _config.Configuration.PreferredMetadataLanguage - } - }; - - var results = await _providerManager.GetRemoteSearchResults(query, cancellationToken).ConfigureAwait(false); - - return results.FirstOrDefault(); - } - - return null; - } - - private void DeleteFileIfEmpty(string path) - { - var file = _fileSystem.GetFileInfo(path); - - if (file.Exists && file.Length == 0) - { - try - { - _fileSystem.DeleteFile(path); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error deleting 0-byte failed recording file {Path}", path); - } - } - } - - private void TriggerRefresh(string path) - { - _logger.LogInformation("Triggering refresh on {Path}", path); - - var item = GetAffectedBaseItem(Path.GetDirectoryName(path)); - - if (item is not null) - { - _logger.LogInformation("Refreshing recording parent {Path}", item.Path); - - _providerManager.QueueRefresh( - item.Id, - new MetadataRefreshOptions(new DirectoryService(_fileSystem)) - { - RefreshPaths = new string[] - { - path, - Path.GetDirectoryName(path), - Path.GetDirectoryName(Path.GetDirectoryName(path)) - } - }, - RefreshPriority.High); - } - } - - private BaseItem GetAffectedBaseItem(string path) - { - BaseItem item = null; - - var parentPath = Path.GetDirectoryName(path); - - while (item is null && !string.IsNullOrEmpty(path)) - { - item = _libraryManager.FindByPath(path, null); - - path = Path.GetDirectoryName(path); - } - - if (item is not null) - { - if (item.GetType() == typeof(Folder) && string.Equals(item.Path, parentPath, StringComparison.OrdinalIgnoreCase)) - { - var parentItem = item.GetParent(); - if (parentItem is not null && parentItem is not AggregateFolder) - { - item = parentItem; - } - } - } - - return item; - } - - private async Task EnforceKeepUpTo(TimerInfo timer, string seriesPath) - { - if (string.IsNullOrWhiteSpace(timer.SeriesTimerId)) - { - return; - } - - if (string.IsNullOrWhiteSpace(seriesPath)) - { - return; - } - - var seriesTimerId = timer.SeriesTimerId; - var seriesTimer = _seriesTimerProvider.GetAll().FirstOrDefault(i => string.Equals(i.Id, seriesTimerId, StringComparison.OrdinalIgnoreCase)); - - if (seriesTimer is null || seriesTimer.KeepUpTo <= 0) - { - return; - } - - if (_disposed) - { - return; - } - - await _recordingDeleteSemaphore.WaitAsync().ConfigureAwait(false); - - try - { - if (_disposed) - { - return; - } - - var timersToDelete = _timerProvider.GetAll() - .Where(i => i.Status == RecordingStatus.Completed && !string.IsNullOrWhiteSpace(i.RecordingPath)) - .Where(i => string.Equals(i.SeriesTimerId, seriesTimerId, StringComparison.OrdinalIgnoreCase)) - .OrderByDescending(i => i.EndDate) - .Where(i => File.Exists(i.RecordingPath)) - .Skip(seriesTimer.KeepUpTo - 1) - .ToList(); - - DeleteLibraryItemsForTimers(timersToDelete); - - if (_libraryManager.FindByPath(seriesPath, true) is not Folder librarySeries) - { - return; - } - - var episodesToDelete = librarySeries.GetItemList( - new InternalItemsQuery - { - OrderBy = new[] { (ItemSortBy.DateCreated, SortOrder.Descending) }, - IsVirtualItem = false, - IsFolder = false, - Recursive = true, - DtoOptions = new DtoOptions(true) - }) - .Where(i => i.IsFileProtocol && File.Exists(i.Path)) - .Skip(seriesTimer.KeepUpTo - 1) - .ToList(); - - foreach (var item in episodesToDelete) - { - try - { - _libraryManager.DeleteItem( - item, - new DeleteOptions - { - DeleteFileLocation = true - }, - true); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error deleting item"); - } - } - } - finally - { - _recordingDeleteSemaphore.Release(); - } - } - - private void DeleteLibraryItemsForTimers(List timers) - { - foreach (var timer in timers) - { - if (_disposed) - { - return; - } - - try - { - DeleteLibraryItemForTimer(timer); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error deleting recording"); - } - } - } - - private void DeleteLibraryItemForTimer(TimerInfo timer) - { - var libraryItem = _libraryManager.FindByPath(timer.RecordingPath, false); - - if (libraryItem is not null) - { - _libraryManager.DeleteItem( - libraryItem, - new DeleteOptions - { - DeleteFileLocation = true - }, - true); - } - else if (File.Exists(timer.RecordingPath)) - { - _fileSystem.DeleteFile(timer.RecordingPath); - } - - _timerProvider.Delete(timer); - } - - private string EnsureFileUnique(string path, string timerId) - { - var originalPath = path; - var index = 1; - - while (FileExists(path, timerId)) - { - var parent = Path.GetDirectoryName(originalPath); - var name = Path.GetFileNameWithoutExtension(originalPath); - name += " - " + index.ToString(CultureInfo.InvariantCulture); - - path = Path.ChangeExtension(Path.Combine(parent, name), Path.GetExtension(originalPath)); - index++; - } - - return path; - } - - private bool FileExists(string path, string timerId) - { - if (File.Exists(path)) - { - return true; - } - - return _activeRecordings - .Any(i => string.Equals(i.Value.Path, path, StringComparison.OrdinalIgnoreCase) && !string.Equals(i.Value.Timer.Id, timerId, StringComparison.OrdinalIgnoreCase)); - } - - private IRecorder GetRecorder(MediaSourceInfo mediaSource) - { - if (mediaSource.RequiresLooping || !(mediaSource.Container ?? string.Empty).EndsWith("ts", StringComparison.OrdinalIgnoreCase) || (mediaSource.Protocol != MediaProtocol.File && mediaSource.Protocol != MediaProtocol.Http)) - { - return new EncodedRecorder(_logger, _mediaEncoder, _config.ApplicationPaths, _config); - } - - return new DirectRecorder(_logger, _httpClientFactory, _streamHelper); - } - - private void OnSuccessfulRecording(TimerInfo timer, string path) - { - PostProcessRecording(timer, path); - } - - private void PostProcessRecording(TimerInfo timer, string path) - { - var options = GetConfiguration(); - if (string.IsNullOrWhiteSpace(options.RecordingPostProcessor)) - { - return; - } - - try - { - var process = new Process - { - StartInfo = new ProcessStartInfo - { - Arguments = GetPostProcessArguments(path, options.RecordingPostProcessorArguments), - CreateNoWindow = true, - ErrorDialog = false, - FileName = options.RecordingPostProcessor, - WindowStyle = ProcessWindowStyle.Hidden, - UseShellExecute = false - }, - EnableRaisingEvents = true - }; - - _logger.LogInformation("Running recording post processor {0} {1}", process.StartInfo.FileName, process.StartInfo.Arguments); - - process.Exited += OnProcessExited; - process.Start(); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error running recording post processor"); - } - } - - private static string GetPostProcessArguments(string path, string arguments) - { - return arguments.Replace("{path}", path, StringComparison.OrdinalIgnoreCase); - } - - private void OnProcessExited(object sender, EventArgs e) - { - using (var process = (Process)sender) - { - _logger.LogInformation("Recording post-processing script completed with exit code {ExitCode}", process.ExitCode); - } - } - - private async Task SaveRecordingImage(string recordingPath, LiveTvProgram program, ItemImageInfo image) - { - if (!image.IsLocalFile) - { - image = await _libraryManager.ConvertImageToLocal(program, image, 0).ConfigureAwait(false); - } - - string imageSaveFilenameWithoutExtension = image.Type switch - { - ImageType.Primary => program.IsSeries ? Path.GetFileNameWithoutExtension(recordingPath) + "-thumb" : "poster", - ImageType.Logo => "logo", - ImageType.Thumb => program.IsSeries ? Path.GetFileNameWithoutExtension(recordingPath) + "-thumb" : "landscape", - ImageType.Backdrop => "fanart", - _ => null - }; - - if (imageSaveFilenameWithoutExtension is null) - { - return; - } - - var imageSavePath = Path.Combine(Path.GetDirectoryName(recordingPath), imageSaveFilenameWithoutExtension); - - // preserve original image extension - imageSavePath = Path.ChangeExtension(imageSavePath, Path.GetExtension(image.Path)); - - File.Copy(image.Path, imageSavePath, true); - } - - private async Task SaveRecordingImages(string recordingPath, LiveTvProgram program) - { - var image = program.IsSeries ? - (program.GetImageInfo(ImageType.Thumb, 0) ?? program.GetImageInfo(ImageType.Primary, 0)) : - (program.GetImageInfo(ImageType.Primary, 0) ?? program.GetImageInfo(ImageType.Thumb, 0)); - - if (image is not null) - { - try - { - await SaveRecordingImage(recordingPath, program, image).ConfigureAwait(false); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error saving recording image"); - } - } - - if (!program.IsSeries) - { - image = program.GetImageInfo(ImageType.Backdrop, 0); - if (image is not null) - { - try - { - await SaveRecordingImage(recordingPath, program, image).ConfigureAwait(false); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error saving recording image"); - } - } - - image = program.GetImageInfo(ImageType.Thumb, 0); - if (image is not null) - { - try - { - await SaveRecordingImage(recordingPath, program, image).ConfigureAwait(false); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error saving recording image"); - } - } - - image = program.GetImageInfo(ImageType.Logo, 0); - if (image is not null) - { - try - { - await SaveRecordingImage(recordingPath, program, image).ConfigureAwait(false); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error saving recording image"); - } - } - } - } - - private async Task SaveRecordingMetadata(TimerInfo timer, string recordingPath, string seriesPath) - { - try - { - var program = string.IsNullOrWhiteSpace(timer.ProgramId) ? null : _libraryManager.GetItemList(new InternalItemsQuery - { - IncludeItemTypes = new[] { BaseItemKind.LiveTvProgram }, - Limit = 1, - ExternalId = timer.ProgramId, - DtoOptions = new DtoOptions(true) - }).FirstOrDefault() as LiveTvProgram; - - // dummy this up - if (program is null) - { - program = new LiveTvProgram - { - Name = timer.Name, - Overview = timer.Overview, - Genres = timer.Genres, - CommunityRating = timer.CommunityRating, - OfficialRating = timer.OfficialRating, - ProductionYear = timer.ProductionYear, - PremiereDate = timer.OriginalAirDate, - IndexNumber = timer.EpisodeNumber, - ParentIndexNumber = timer.SeasonNumber - }; - } - - if (timer.IsSports) - { - program.AddGenre("Sports"); - } - - if (timer.IsKids) - { - program.AddGenre("Kids"); - program.AddGenre("Children"); - } - - if (timer.IsNews) - { - program.AddGenre("News"); - } - - var config = GetConfiguration(); - - if (config.SaveRecordingNFO) - { - if (timer.IsProgramSeries) - { - await SaveSeriesNfoAsync(timer, seriesPath).ConfigureAwait(false); - await SaveVideoNfoAsync(timer, recordingPath, program, false).ConfigureAwait(false); - } - else if (!timer.IsMovie || timer.IsSports || timer.IsNews) - { - await SaveVideoNfoAsync(timer, recordingPath, program, true).ConfigureAwait(false); - } - else - { - await SaveVideoNfoAsync(timer, recordingPath, program, false).ConfigureAwait(false); - } - } - - if (config.SaveRecordingImages) - { - await SaveRecordingImages(recordingPath, program).ConfigureAwait(false); - } - } - catch (Exception ex) - { - _logger.LogError(ex, "Error saving nfo"); - } - } - - private async Task SaveSeriesNfoAsync(TimerInfo timer, string seriesPath) - { - var nfoPath = Path.Combine(seriesPath, "tvshow.nfo"); - - if (File.Exists(nfoPath)) - { - return; - } - - var stream = new FileStream(nfoPath, FileMode.CreateNew, FileAccess.Write, FileShare.None); - await using (stream.ConfigureAwait(false)) - { - var settings = new XmlWriterSettings - { - Indent = true, - Encoding = Encoding.UTF8, - Async = true - }; - - var writer = XmlWriter.Create(stream, settings); - await using (writer.ConfigureAwait(false)) - { - await writer.WriteStartDocumentAsync(true).ConfigureAwait(false); - await writer.WriteStartElementAsync(null, "tvshow", null).ConfigureAwait(false); - if (timer.SeriesProviderIds.TryGetValue(MetadataProvider.Tvdb.ToString(), out var id)) - { - await writer.WriteElementStringAsync(null, "id", null, id).ConfigureAwait(false); - } - - if (timer.SeriesProviderIds.TryGetValue(MetadataProvider.Imdb.ToString(), out id)) - { - await writer.WriteElementStringAsync(null, "imdb_id", null, id).ConfigureAwait(false); - } - - if (timer.SeriesProviderIds.TryGetValue(MetadataProvider.Tmdb.ToString(), out id)) - { - await writer.WriteElementStringAsync(null, "tmdbid", null, id).ConfigureAwait(false); - } - - if (timer.SeriesProviderIds.TryGetValue(MetadataProvider.Zap2It.ToString(), out id)) - { - await writer.WriteElementStringAsync(null, "zap2itid", null, id).ConfigureAwait(false); - } - - if (!string.IsNullOrWhiteSpace(timer.Name)) - { - await writer.WriteElementStringAsync(null, "title", null, timer.Name).ConfigureAwait(false); - } - - if (!string.IsNullOrWhiteSpace(timer.OfficialRating)) - { - await writer.WriteElementStringAsync(null, "mpaa", null, timer.OfficialRating).ConfigureAwait(false); - } - - foreach (var genre in timer.Genres) - { - await writer.WriteElementStringAsync(null, "genre", null, genre).ConfigureAwait(false); - } - - await writer.WriteEndElementAsync().ConfigureAwait(false); - await writer.WriteEndDocumentAsync().ConfigureAwait(false); - } - } - } - - private async Task SaveVideoNfoAsync(TimerInfo timer, string recordingPath, BaseItem item, bool lockData) - { - var nfoPath = Path.ChangeExtension(recordingPath, ".nfo"); - - if (File.Exists(nfoPath)) - { - return; - } - - var stream = new FileStream(nfoPath, FileMode.CreateNew, FileAccess.Write, FileShare.None); - await using (stream.ConfigureAwait(false)) - { - var settings = new XmlWriterSettings - { - Indent = true, - Encoding = Encoding.UTF8, - Async = true - }; - - var options = _config.GetNfoConfiguration(); - - var isSeriesEpisode = timer.IsProgramSeries; - - var writer = XmlWriter.Create(stream, settings); - await using (writer.ConfigureAwait(false)) - { - await writer.WriteStartDocumentAsync(true).ConfigureAwait(false); - - if (isSeriesEpisode) - { - await writer.WriteStartElementAsync(null, "episodedetails", null).ConfigureAwait(false); - - if (!string.IsNullOrWhiteSpace(timer.EpisodeTitle)) - { - await writer.WriteElementStringAsync(null, "title", null, timer.EpisodeTitle).ConfigureAwait(false); - } - - var premiereDate = item.PremiereDate ?? (!timer.IsRepeat ? DateTime.UtcNow : null); - - if (premiereDate.HasValue) - { - var formatString = options.ReleaseDateFormat; - - await writer.WriteElementStringAsync( - null, - "aired", - null, - premiereDate.Value.ToLocalTime().ToString(formatString, CultureInfo.InvariantCulture)).ConfigureAwait(false); - } - - if (item.IndexNumber.HasValue) - { - await writer.WriteElementStringAsync(null, "episode", null, item.IndexNumber.Value.ToString(CultureInfo.InvariantCulture)).ConfigureAwait(false); - } - - if (item.ParentIndexNumber.HasValue) - { - await writer.WriteElementStringAsync(null, "season", null, item.ParentIndexNumber.Value.ToString(CultureInfo.InvariantCulture)).ConfigureAwait(false); - } - } - else - { - await writer.WriteStartElementAsync(null, "movie", null).ConfigureAwait(false); - - if (!string.IsNullOrWhiteSpace(item.Name)) - { - await writer.WriteElementStringAsync(null, "title", null, item.Name).ConfigureAwait(false); - } - - if (!string.IsNullOrWhiteSpace(item.OriginalTitle)) - { - await writer.WriteElementStringAsync(null, "originaltitle", null, item.OriginalTitle).ConfigureAwait(false); - } - - if (item.PremiereDate.HasValue) - { - var formatString = options.ReleaseDateFormat; - - await writer.WriteElementStringAsync( - null, - "premiered", - null, - item.PremiereDate.Value.ToLocalTime().ToString(formatString, CultureInfo.InvariantCulture)).ConfigureAwait(false); - await writer.WriteElementStringAsync( - null, - "releasedate", - null, - item.PremiereDate.Value.ToLocalTime().ToString(formatString, CultureInfo.InvariantCulture)).ConfigureAwait(false); - } - } - - await writer.WriteElementStringAsync( - null, - "dateadded", - null, - DateTime.Now.ToString(DateAddedFormat, CultureInfo.InvariantCulture)).ConfigureAwait(false); - - if (item.ProductionYear.HasValue) - { - await writer.WriteElementStringAsync(null, "year", null, item.ProductionYear.Value.ToString(CultureInfo.InvariantCulture)).ConfigureAwait(false); - } - - if (!string.IsNullOrEmpty(item.OfficialRating)) - { - await writer.WriteElementStringAsync(null, "mpaa", null, item.OfficialRating).ConfigureAwait(false); - } - - var overview = (item.Overview ?? string.Empty) - .StripHtml() - .Replace(""", "'", StringComparison.Ordinal); - - await writer.WriteElementStringAsync(null, "plot", null, overview).ConfigureAwait(false); - - if (item.CommunityRating.HasValue) - { - await writer.WriteElementStringAsync(null, "rating", null, item.CommunityRating.Value.ToString(CultureInfo.InvariantCulture)).ConfigureAwait(false); - } - - foreach (var genre in item.Genres) - { - await writer.WriteElementStringAsync(null, "genre", null, genre).ConfigureAwait(false); - } - - var people = item.Id.Equals(default) ? new List() : _libraryManager.GetPeople(item); - - var directors = people - .Where(i => i.IsType(PersonKind.Director)) - .Select(i => i.Name) - .ToList(); - - foreach (var person in directors) - { - await writer.WriteElementStringAsync(null, "director", null, person).ConfigureAwait(false); - } - - var writers = people - .Where(i => i.IsType(PersonKind.Writer)) - .Select(i => i.Name) - .Distinct(StringComparer.OrdinalIgnoreCase) - .ToList(); - - foreach (var person in writers) - { - await writer.WriteElementStringAsync(null, "writer", null, person).ConfigureAwait(false); - } - - foreach (var person in writers) - { - await writer.WriteElementStringAsync(null, "credits", null, person).ConfigureAwait(false); - } - - var tmdbCollection = item.GetProviderId(MetadataProvider.TmdbCollection); - - if (!string.IsNullOrEmpty(tmdbCollection)) - { - await writer.WriteElementStringAsync(null, "collectionnumber", null, tmdbCollection).ConfigureAwait(false); - } - - var imdb = item.GetProviderId(MetadataProvider.Imdb); - if (!string.IsNullOrEmpty(imdb)) - { - if (!isSeriesEpisode) - { - await writer.WriteElementStringAsync(null, "id", null, imdb).ConfigureAwait(false); - } - - await writer.WriteElementStringAsync(null, "imdbid", null, imdb).ConfigureAwait(false); - - // No need to lock if we have identified the content already - lockData = false; - } - - var tvdb = item.GetProviderId(MetadataProvider.Tvdb); - if (!string.IsNullOrEmpty(tvdb)) - { - await writer.WriteElementStringAsync(null, "tvdbid", null, tvdb).ConfigureAwait(false); - - // No need to lock if we have identified the content already - lockData = false; - } - - var tmdb = item.GetProviderId(MetadataProvider.Tmdb); - if (!string.IsNullOrEmpty(tmdb)) - { - await writer.WriteElementStringAsync(null, "tmdbid", null, tmdb).ConfigureAwait(false); - - // No need to lock if we have identified the content already - lockData = false; - } - - if (lockData) - { - await writer.WriteElementStringAsync(null, "lockdata", null, "true").ConfigureAwait(false); - } - - if (item.CriticRating.HasValue) - { - await writer.WriteElementStringAsync(null, "criticrating", null, item.CriticRating.Value.ToString(CultureInfo.InvariantCulture)).ConfigureAwait(false); - } - - if (!string.IsNullOrWhiteSpace(item.Tagline)) - { - await writer.WriteElementStringAsync(null, "tagline", null, item.Tagline).ConfigureAwait(false); - } - - foreach (var studio in item.Studios) - { - await writer.WriteElementStringAsync(null, "studio", null, studio).ConfigureAwait(false); - } - - await writer.WriteEndElementAsync().ConfigureAwait(false); - await writer.WriteEndDocumentAsync().ConfigureAwait(false); - } - } - } - - private LiveTvProgram GetProgramInfoFromCache(string programId) - { - var query = new InternalItemsQuery - { - ItemIds = new[] { _liveTvManager.GetInternalProgramId(programId) }, - Limit = 1, - DtoOptions = new DtoOptions() - }; - - return _libraryManager.GetItemList(query).Cast().FirstOrDefault(); - } - - private LiveTvProgram GetProgramInfoFromCache(TimerInfo timer) - { - return GetProgramInfoFromCache(timer.ProgramId); - } - - private LiveTvProgram GetProgramInfoFromCache(string channelId, DateTime startDateUtc) - { - var query = new InternalItemsQuery - { - IncludeItemTypes = new[] { BaseItemKind.LiveTvProgram }, - Limit = 1, - DtoOptions = new DtoOptions(true) - { - EnableImages = false - }, - MinStartDate = startDateUtc.AddMinutes(-3), - MaxStartDate = startDateUtc.AddMinutes(3), - OrderBy = new[] { (ItemSortBy.StartDate, SortOrder.Ascending) } - }; - - if (!string.IsNullOrWhiteSpace(channelId)) - { - query.ChannelIds = new[] { _liveTvManager.GetInternalChannelId(Name, channelId) }; - } - - return _libraryManager.GetItemList(query).Cast().FirstOrDefault(); - } - - private LiveTvOptions GetConfiguration() - { - return _config.GetConfiguration("livetv"); - } - - private bool ShouldCancelTimerForSeriesTimer(SeriesTimerInfo seriesTimer, TimerInfo timer) - { - if (timer.IsManual) - { - return false; - } - - if (!seriesTimer.RecordAnyTime - && Math.Abs(seriesTimer.StartDate.TimeOfDay.Ticks - timer.StartDate.TimeOfDay.Ticks) >= TimeSpan.FromMinutes(10).Ticks) - { - return true; - } - - if (seriesTimer.RecordNewOnly && timer.IsRepeat) - { - return true; - } - - if (!seriesTimer.RecordAnyChannel - && !string.Equals(timer.ChannelId, seriesTimer.ChannelId, StringComparison.OrdinalIgnoreCase)) - { - return true; - } - - return seriesTimer.SkipEpisodesInLibrary && IsProgramAlreadyInLibrary(timer); - } - - private void HandleDuplicateShowIds(List timers) - { - // sort showings by HD channels first, then by startDate, record earliest showing possible - foreach (var timer in timers.OrderByDescending(t => _liveTvManager.GetLiveTvChannel(t, this).IsHD).ThenBy(t => t.StartDate).Skip(1)) - { - timer.Status = RecordingStatus.Cancelled; - _timerProvider.Update(timer); - } - } - - private void SearchForDuplicateShowIds(IEnumerable timers) - { - var groups = timers.ToLookup(i => i.ShowId ?? string.Empty).ToList(); - - foreach (var group in groups) - { - if (string.IsNullOrWhiteSpace(group.Key)) - { - continue; - } - - var groupTimers = group.ToList(); - - if (groupTimers.Count < 2) - { - continue; - } - - // Skip ShowId without SubKey from duplicate removal actions - https://github.com/jellyfin/jellyfin/issues/5856 - if (group.Key.EndsWith("0000", StringComparison.Ordinal)) - { - continue; - } - - HandleDuplicateShowIds(groupTimers); - } - } - - private void UpdateTimersForSeriesTimer(SeriesTimerInfo seriesTimer, bool updateTimerSettings, bool deleteInvalidTimers) - { - var allTimers = GetTimersForSeries(seriesTimer).ToList(); - - var enabledTimersForSeries = new List(); - foreach (var timer in allTimers) - { - var existingTimer = _timerProvider.GetTimer(timer.Id) - ?? (string.IsNullOrWhiteSpace(timer.ProgramId) - ? null - : _timerProvider.GetTimerByProgramId(timer.ProgramId)); - - if (existingTimer is null) - { - if (ShouldCancelTimerForSeriesTimer(seriesTimer, timer)) - { - timer.Status = RecordingStatus.Cancelled; - } - else - { - enabledTimersForSeries.Add(timer); - } - - _timerProvider.Add(timer); - - TimerCreated?.Invoke(this, new GenericEventArgs(timer)); - } - - // Only update if not currently active - test both new timer and existing in case Id's are different - // Id's could be different if the timer was created manually prior to series timer creation - else if (!_activeRecordings.TryGetValue(timer.Id, out _) && !_activeRecordings.TryGetValue(existingTimer.Id, out _)) - { - UpdateExistingTimerWithNewMetadata(existingTimer, timer); - - // Needed by ShouldCancelTimerForSeriesTimer - timer.IsManual = existingTimer.IsManual; - - if (ShouldCancelTimerForSeriesTimer(seriesTimer, timer)) - { - existingTimer.Status = RecordingStatus.Cancelled; - } - else if (!existingTimer.IsManual) - { - existingTimer.Status = RecordingStatus.New; - } - - if (existingTimer.Status != RecordingStatus.Cancelled) - { - enabledTimersForSeries.Add(existingTimer); - } - - if (updateTimerSettings) - { - existingTimer.KeepUntil = seriesTimer.KeepUntil; - existingTimer.IsPostPaddingRequired = seriesTimer.IsPostPaddingRequired; - existingTimer.IsPrePaddingRequired = seriesTimer.IsPrePaddingRequired; - existingTimer.PostPaddingSeconds = seriesTimer.PostPaddingSeconds; - existingTimer.PrePaddingSeconds = seriesTimer.PrePaddingSeconds; - existingTimer.Priority = seriesTimer.Priority; - existingTimer.SeriesTimerId = seriesTimer.Id; - } - - existingTimer.SeriesTimerId = seriesTimer.Id; - _timerProvider.Update(existingTimer); - } - } - - SearchForDuplicateShowIds(enabledTimersForSeries); - - if (deleteInvalidTimers) - { - var allTimerIds = allTimers - .Select(i => i.Id) - .ToList(); - - var deleteStatuses = new[] - { - RecordingStatus.New - }; - - var deletes = _timerProvider.GetAll() - .Where(i => string.Equals(i.SeriesTimerId, seriesTimer.Id, StringComparison.OrdinalIgnoreCase)) - .Where(i => !allTimerIds.Contains(i.Id, StringComparison.OrdinalIgnoreCase) && i.StartDate > DateTime.UtcNow) - .Where(i => deleteStatuses.Contains(i.Status)) - .ToList(); - - foreach (var timer in deletes) - { - CancelTimerInternal(timer.Id, false, false); - } - } - } - - private IEnumerable GetTimersForSeries(SeriesTimerInfo seriesTimer) - { - ArgumentNullException.ThrowIfNull(seriesTimer); - - var query = new InternalItemsQuery - { - IncludeItemTypes = new[] { BaseItemKind.LiveTvProgram }, - ExternalSeriesId = seriesTimer.SeriesId, - DtoOptions = new DtoOptions(true) - { - EnableImages = false - }, - MinEndDate = DateTime.UtcNow - }; - - if (string.IsNullOrEmpty(seriesTimer.SeriesId)) - { - query.Name = seriesTimer.Name; - } - - if (!seriesTimer.RecordAnyChannel) - { - query.ChannelIds = new[] { _liveTvManager.GetInternalChannelId(Name, seriesTimer.ChannelId) }; - } - - var tempChannelCache = new Dictionary(); - - return _libraryManager.GetItemList(query).Cast().Select(i => CreateTimer(i, seriesTimer, tempChannelCache)); - } - - private TimerInfo CreateTimer(LiveTvProgram parent, SeriesTimerInfo seriesTimer, Dictionary tempChannelCache) - { - string channelId = seriesTimer.RecordAnyChannel ? null : seriesTimer.ChannelId; - - if (string.IsNullOrWhiteSpace(channelId) && !parent.ChannelId.Equals(default)) - { - if (!tempChannelCache.TryGetValue(parent.ChannelId, out LiveTvChannel channel)) - { - channel = _libraryManager.GetItemList( - new InternalItemsQuery - { - IncludeItemTypes = new[] { BaseItemKind.LiveTvChannel }, - ItemIds = new[] { parent.ChannelId }, - DtoOptions = new DtoOptions() - }).FirstOrDefault() as LiveTvChannel; - - if (channel is not null && !string.IsNullOrWhiteSpace(channel.ExternalId)) - { - tempChannelCache[parent.ChannelId] = channel; - } - } - - if (channel is not null || tempChannelCache.TryGetValue(parent.ChannelId, out channel)) - { - channelId = channel.ExternalId; - } - } - - var timer = new TimerInfo - { - ChannelId = channelId, - Id = (seriesTimer.Id + parent.ExternalId).GetMD5().ToString("N", CultureInfo.InvariantCulture), - StartDate = parent.StartDate, - EndDate = parent.EndDate.Value, - ProgramId = parent.ExternalId, - PrePaddingSeconds = seriesTimer.PrePaddingSeconds, - PostPaddingSeconds = seriesTimer.PostPaddingSeconds, - IsPostPaddingRequired = seriesTimer.IsPostPaddingRequired, - IsPrePaddingRequired = seriesTimer.IsPrePaddingRequired, - KeepUntil = seriesTimer.KeepUntil, - Priority = seriesTimer.Priority, - Name = parent.Name, - Overview = parent.Overview, - SeriesId = parent.ExternalSeriesId, - SeriesTimerId = seriesTimer.Id, - ShowId = parent.ShowId - }; - - CopyProgramInfoToTimerInfo(parent, timer, tempChannelCache); - - return timer; - } - - private void CopyProgramInfoToTimerInfo(LiveTvProgram programInfo, TimerInfo timerInfo) - { - var tempChannelCache = new Dictionary(); - CopyProgramInfoToTimerInfo(programInfo, timerInfo, tempChannelCache); - } - - private void CopyProgramInfoToTimerInfo(LiveTvProgram programInfo, TimerInfo timerInfo, Dictionary tempChannelCache) - { - string channelId = null; - - if (!programInfo.ChannelId.Equals(default)) - { - if (!tempChannelCache.TryGetValue(programInfo.ChannelId, out LiveTvChannel channel)) - { - channel = _libraryManager.GetItemList( - new InternalItemsQuery - { - IncludeItemTypes = new[] { BaseItemKind.LiveTvChannel }, - ItemIds = new[] { programInfo.ChannelId }, - DtoOptions = new DtoOptions() - }).FirstOrDefault() as LiveTvChannel; - - if (channel is not null && !string.IsNullOrWhiteSpace(channel.ExternalId)) - { - tempChannelCache[programInfo.ChannelId] = channel; - } - } - - if (channel is not null || tempChannelCache.TryGetValue(programInfo.ChannelId, out channel)) - { - channelId = channel.ExternalId; - } - } - - timerInfo.Name = programInfo.Name; - timerInfo.StartDate = programInfo.StartDate; - timerInfo.EndDate = programInfo.EndDate.Value; - - if (!string.IsNullOrWhiteSpace(channelId)) - { - timerInfo.ChannelId = channelId; - } - - timerInfo.SeasonNumber = programInfo.ParentIndexNumber; - timerInfo.EpisodeNumber = programInfo.IndexNumber; - timerInfo.IsMovie = programInfo.IsMovie; - timerInfo.ProductionYear = programInfo.ProductionYear; - timerInfo.EpisodeTitle = programInfo.EpisodeTitle; - timerInfo.OriginalAirDate = programInfo.PremiereDate; - timerInfo.IsProgramSeries = programInfo.IsSeries; - - timerInfo.IsSeries = programInfo.IsSeries; - - timerInfo.CommunityRating = programInfo.CommunityRating; - timerInfo.Overview = programInfo.Overview; - timerInfo.OfficialRating = programInfo.OfficialRating; - timerInfo.IsRepeat = programInfo.IsRepeat; - timerInfo.SeriesId = programInfo.ExternalSeriesId; - timerInfo.ProviderIds = programInfo.ProviderIds; - timerInfo.Tags = programInfo.Tags; - - var seriesProviderIds = new Dictionary(StringComparer.OrdinalIgnoreCase); - - foreach (var providerId in timerInfo.ProviderIds) - { - const string Search = "Series"; - if (providerId.Key.StartsWith(Search, StringComparison.OrdinalIgnoreCase)) - { - seriesProviderIds[providerId.Key.Substring(Search.Length)] = providerId.Value; - } - } - - timerInfo.SeriesProviderIds = seriesProviderIds; - } - - private bool IsProgramAlreadyInLibrary(TimerInfo program) - { - if ((program.EpisodeNumber.HasValue && program.SeasonNumber.HasValue) || !string.IsNullOrWhiteSpace(program.EpisodeTitle)) - { - var seriesIds = _libraryManager.GetItemIds( - new InternalItemsQuery - { - IncludeItemTypes = new[] { BaseItemKind.Series }, - Name = program.Name - }).ToArray(); - - if (seriesIds.Length == 0) - { - return false; - } - - if (program.EpisodeNumber.HasValue && program.SeasonNumber.HasValue) - { - var result = _libraryManager.GetItemIds(new InternalItemsQuery - { - IncludeItemTypes = new[] { BaseItemKind.Episode }, - ParentIndexNumber = program.SeasonNumber.Value, - IndexNumber = program.EpisodeNumber.Value, - AncestorIds = seriesIds, - IsVirtualItem = false, - Limit = 1 - }); - - if (result.Count > 0) - { - return true; - } - } - } - - return false; - } - - /// - public void Dispose() - { - if (_disposed) - { - return; - } - - _recordingDeleteSemaphore.Dispose(); - - foreach (var pair in _activeRecordings.ToList()) - { - pair.Value.CancellationTokenSource.Cancel(); - } - - _disposed = true; - } - - public IEnumerable GetRecordingFolders() - { - var defaultFolder = RecordingPath; - var defaultName = "Recordings"; - - if (Directory.Exists(defaultFolder)) - { - yield return new VirtualFolderInfo - { - Locations = new string[] { defaultFolder }, - Name = defaultName - }; - } - - var customPath = GetConfiguration().MovieRecordingPath; - if (!string.IsNullOrWhiteSpace(customPath) && !string.Equals(customPath, defaultFolder, StringComparison.OrdinalIgnoreCase) && Directory.Exists(customPath)) - { - yield return new VirtualFolderInfo - { - Locations = new string[] { customPath }, - Name = "Recorded Movies", - CollectionType = CollectionTypeOptions.Movies - }; - } - - customPath = GetConfiguration().SeriesRecordingPath; - if (!string.IsNullOrWhiteSpace(customPath) && !string.Equals(customPath, defaultFolder, StringComparison.OrdinalIgnoreCase) && Directory.Exists(customPath)) - { - yield return new VirtualFolderInfo - { - Locations = new string[] { customPath }, - Name = "Recorded Shows", - CollectionType = CollectionTypeOptions.TvShows - }; - } - } - - public async Task> DiscoverTuners(bool newDevicesOnly, CancellationToken cancellationToken) - { - var list = new List(); - - var configuredDeviceIds = GetConfiguration().TunerHosts - .Where(i => !string.IsNullOrWhiteSpace(i.DeviceId)) - .Select(i => i.DeviceId) - .ToList(); - - foreach (var host in _liveTvManager.TunerHosts) - { - var discoveredDevices = await DiscoverDevices(host, TunerDiscoveryDurationMs, cancellationToken).ConfigureAwait(false); - - if (newDevicesOnly) - { - discoveredDevices = discoveredDevices.Where(d => !configuredDeviceIds.Contains(d.DeviceId, StringComparison.OrdinalIgnoreCase)) - .ToList(); - } - - list.AddRange(discoveredDevices); - } - - return list; - } - - public async Task ScanForTunerDeviceChanges(CancellationToken cancellationToken) - { - foreach (var host in _liveTvManager.TunerHosts) - { - await ScanForTunerDeviceChanges(host, cancellationToken).ConfigureAwait(false); - } - } - - private async Task ScanForTunerDeviceChanges(ITunerHost host, CancellationToken cancellationToken) - { - var discoveredDevices = await DiscoverDevices(host, TunerDiscoveryDurationMs, cancellationToken).ConfigureAwait(false); - - var configuredDevices = GetConfiguration().TunerHosts - .Where(i => string.Equals(i.Type, host.Type, StringComparison.OrdinalIgnoreCase)) - .ToList(); - - foreach (var device in discoveredDevices) - { - var configuredDevice = configuredDevices.FirstOrDefault(i => string.Equals(i.DeviceId, device.DeviceId, StringComparison.OrdinalIgnoreCase)); - - if (configuredDevice is not null && !string.Equals(device.Url, configuredDevice.Url, StringComparison.OrdinalIgnoreCase)) - { - _logger.LogInformation("Tuner url has changed from {PreviousUrl} to {NewUrl}", configuredDevice.Url, device.Url); - - configuredDevice.Url = device.Url; - await _liveTvManager.SaveTunerHost(configuredDevice).ConfigureAwait(false); - } - } - } - - private async Task> DiscoverDevices(ITunerHost host, int discoveryDurationMs, CancellationToken cancellationToken) - { - try - { - var discoveredDevices = await host.DiscoverDevices(discoveryDurationMs, cancellationToken).ConfigureAwait(false); - - foreach (var device in discoveredDevices) - { - _logger.LogInformation("Discovered tuner device {0} at {1}", host.Name, device.Url); - } - - return discoveredDevices; - } - catch (Exception ex) - { - _logger.LogError(ex, "Error discovering tuner devices"); - - return new List(); - } - } - } -} diff --git a/Emby.Server.Implementations/LiveTv/EmbyTV/EncodedRecorder.cs b/Emby.Server.Implementations/LiveTv/EmbyTV/EncodedRecorder.cs deleted file mode 100644 index 9a9fd0273..000000000 --- a/Emby.Server.Implementations/LiveTv/EmbyTV/EncodedRecorder.cs +++ /dev/null @@ -1,362 +0,0 @@ -#nullable disable - -#pragma warning disable CS1591 - -using System; -using System.Collections.Generic; -using System.Diagnostics; -using System.Globalization; -using System.IO; -using System.Text; -using System.Text.Json; -using System.Threading; -using System.Threading.Tasks; -using Jellyfin.Extensions; -using Jellyfin.Extensions.Json; -using MediaBrowser.Common; -using MediaBrowser.Common.Configuration; -using MediaBrowser.Controller; -using MediaBrowser.Controller.Configuration; -using MediaBrowser.Controller.Library; -using MediaBrowser.Controller.MediaEncoding; -using MediaBrowser.Model.Dto; -using MediaBrowser.Model.IO; -using Microsoft.Extensions.Logging; - -namespace Emby.Server.Implementations.LiveTv.EmbyTV -{ - public class EncodedRecorder : IRecorder - { - private readonly ILogger _logger; - private readonly IMediaEncoder _mediaEncoder; - private readonly IServerApplicationPaths _appPaths; - private readonly TaskCompletionSource _taskCompletionSource = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - private readonly IServerConfigurationManager _serverConfigurationManager; - private readonly JsonSerializerOptions _jsonOptions = JsonDefaults.Options; - private bool _hasExited; - private FileStream _logFileStream; - private string _targetPath; - private Process _process; - private bool _disposed; - - public EncodedRecorder( - ILogger logger, - IMediaEncoder mediaEncoder, - IServerApplicationPaths appPaths, - IServerConfigurationManager serverConfigurationManager) - { - _logger = logger; - _mediaEncoder = mediaEncoder; - _appPaths = appPaths; - _serverConfigurationManager = serverConfigurationManager; - } - - private static bool CopySubtitles => false; - - public string GetOutputPath(MediaSourceInfo mediaSource, string targetFile) - { - return Path.ChangeExtension(targetFile, ".ts"); - } - - public async Task Record(IDirectStreamProvider directStreamProvider, MediaSourceInfo mediaSource, string targetFile, TimeSpan duration, Action onStarted, CancellationToken cancellationToken) - { - // The media source is infinite so we need to handle stopping ourselves - using var durationToken = new CancellationTokenSource(duration); - using var cancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, durationToken.Token); - - await RecordFromFile(mediaSource, mediaSource.Path, targetFile, onStarted, cancellationTokenSource.Token).ConfigureAwait(false); - - _logger.LogInformation("Recording completed to file {Path}", targetFile); - } - - private async Task RecordFromFile(MediaSourceInfo mediaSource, string inputFile, string targetFile, Action onStarted, CancellationToken cancellationToken) - { - _targetPath = targetFile; - Directory.CreateDirectory(Path.GetDirectoryName(targetFile)); - - var processStartInfo = new ProcessStartInfo - { - CreateNoWindow = true, - UseShellExecute = false, - - RedirectStandardError = true, - RedirectStandardInput = true, - - FileName = _mediaEncoder.EncoderPath, - Arguments = GetCommandLineArgs(mediaSource, inputFile, targetFile), - - WindowStyle = ProcessWindowStyle.Hidden, - ErrorDialog = false - }; - - _logger.LogInformation("{Filename} {Arguments}", processStartInfo.FileName, processStartInfo.Arguments); - - var logFilePath = Path.Combine(_appPaths.LogDirectoryPath, "record-transcode-" + Guid.NewGuid() + ".txt"); - Directory.CreateDirectory(Path.GetDirectoryName(logFilePath)); - - // FFMpeg writes debug/error info to stderr. This is useful when debugging so let's put it in the log directory. - _logFileStream = new FileStream(logFilePath, FileMode.CreateNew, FileAccess.Write, FileShare.Read, IODefaults.FileStreamBufferSize, FileOptions.Asynchronous); - - await JsonSerializer.SerializeAsync(_logFileStream, mediaSource, _jsonOptions, cancellationToken).ConfigureAwait(false); - await _logFileStream.WriteAsync(Encoding.UTF8.GetBytes(Environment.NewLine + Environment.NewLine + processStartInfo.FileName + " " + processStartInfo.Arguments + Environment.NewLine + Environment.NewLine), cancellationToken).ConfigureAwait(false); - - _process = new Process - { - StartInfo = processStartInfo, - EnableRaisingEvents = true - }; - _process.Exited += (_, _) => OnFfMpegProcessExited(_process); - - _process.Start(); - - cancellationToken.Register(Stop); - - onStarted(); - - // Important - don't await the log task or we won't be able to kill ffmpeg when the user stops playback - _ = StartStreamingLog(_process.StandardError.BaseStream, _logFileStream); - - _logger.LogInformation("ffmpeg recording process started for {Path}", _targetPath); - - // Block until ffmpeg exits - await _taskCompletionSource.Task.ConfigureAwait(false); - } - - private string GetCommandLineArgs(MediaSourceInfo mediaSource, string inputTempFile, string targetFile) - { - string videoArgs; - if (EncodeVideo(mediaSource)) - { - const int MaxBitrate = 25000000; - videoArgs = string.Format( - CultureInfo.InvariantCulture, - "-codec:v:0 libx264 -force_key_frames \"expr:gte(t,n_forced*5)\" {0} -pix_fmt yuv420p -preset superfast -crf 23 -b:v {1} -maxrate {1} -bufsize ({1}*2) -vsync -1 -profile:v high -level 41", - GetOutputSizeParam(), - MaxBitrate); - } - else - { - videoArgs = "-codec:v:0 copy"; - } - - videoArgs += " -fflags +genpts"; - - var flags = new List(); - if (mediaSource.IgnoreDts) - { - flags.Add("+igndts"); - } - - if (mediaSource.IgnoreIndex) - { - flags.Add("+ignidx"); - } - - if (mediaSource.GenPtsInput) - { - flags.Add("+genpts"); - } - - var inputModifier = "-async 1 -vsync -1"; - - if (flags.Count > 0) - { - inputModifier += " -fflags " + string.Join(string.Empty, flags); - } - - if (mediaSource.ReadAtNativeFramerate) - { - inputModifier += " -re"; - } - - if (mediaSource.RequiresLooping) - { - inputModifier += " -stream_loop -1 -reconnect_at_eof 1 -reconnect_streamed 1 -reconnect_delay_max 2"; - } - - var analyzeDurationSeconds = 5; - var analyzeDuration = " -analyzeduration " + - (analyzeDurationSeconds * 1000000).ToString(CultureInfo.InvariantCulture); - inputModifier += analyzeDuration; - - var subtitleArgs = CopySubtitles ? " -codec:s copy" : " -sn"; - - // var outputParam = string.Equals(Path.GetExtension(targetFile), ".mp4", StringComparison.OrdinalIgnoreCase) ? - // " -f mp4 -movflags frag_keyframe+empty_moov" : - // string.Empty; - - var outputParam = string.Empty; - - var threads = EncodingHelper.GetNumberOfThreads(null, _serverConfigurationManager.GetEncodingOptions(), null); - var commandLineArgs = string.Format( - CultureInfo.InvariantCulture, - "-i \"{0}\" {2} -map_metadata -1 -threads {6} {3}{4}{5} -y \"{1}\"", - inputTempFile, - targetFile.Replace("\"", "\\\"", StringComparison.Ordinal), // Escape quotes in filename - videoArgs, - GetAudioArgs(mediaSource), - subtitleArgs, - outputParam, - threads); - - return inputModifier + " " + commandLineArgs; - } - - private static string GetAudioArgs(MediaSourceInfo mediaSource) - { - return "-codec:a:0 copy"; - - // var audioChannels = 2; - // var audioStream = mediaStreams.FirstOrDefault(i => i.Type == MediaStreamType.Audio); - // if (audioStream is not null) - // { - // audioChannels = audioStream.Channels ?? audioChannels; - // } - // return "-codec:a:0 aac -strict experimental -ab 320000"; - } - - private static bool EncodeVideo(MediaSourceInfo mediaSource) - { - return false; - } - - protected string GetOutputSizeParam() - => "-vf \"yadif=0:-1:0\""; - - private void Stop() - { - if (!_hasExited) - { - try - { - _logger.LogInformation("Stopping ffmpeg recording process for {Path}", _targetPath); - - _process.StandardInput.WriteLine("q"); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error stopping recording transcoding job for {Path}", _targetPath); - } - - if (_hasExited) - { - return; - } - - try - { - _logger.LogInformation("Calling recording process.WaitForExit for {Path}", _targetPath); - - if (_process.WaitForExit(10000)) - { - return; - } - } - catch (Exception ex) - { - _logger.LogError(ex, "Error waiting for recording process to exit for {Path}", _targetPath); - } - - if (_hasExited) - { - return; - } - - try - { - _logger.LogInformation("Killing ffmpeg recording process for {Path}", _targetPath); - - _process.Kill(); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error killing recording transcoding job for {Path}", _targetPath); - } - } - } - - /// - /// Processes the exited. - /// - private void OnFfMpegProcessExited(Process process) - { - using (process) - { - _hasExited = true; - - _logFileStream?.Dispose(); - _logFileStream = null; - - var exitCode = process.ExitCode; - - _logger.LogInformation("FFMpeg recording exited with code {ExitCode} for {Path}", exitCode, _targetPath); - - if (exitCode == 0) - { - _taskCompletionSource.TrySetResult(true); - } - else - { - _taskCompletionSource.TrySetException( - new FfmpegException( - string.Format( - CultureInfo.InvariantCulture, - "Recording for {0} failed. Exit code {1}", - _targetPath, - exitCode))); - } - } - } - - private async Task StartStreamingLog(Stream source, FileStream target) - { - try - { - using (var reader = new StreamReader(source)) - { - await foreach (var line in reader.ReadAllLinesAsync().ConfigureAwait(false)) - { - var bytes = Encoding.UTF8.GetBytes(Environment.NewLine + line); - - await target.WriteAsync(bytes.AsMemory()).ConfigureAwait(false); - await target.FlushAsync().ConfigureAwait(false); - } - } - } - catch (Exception ex) - { - _logger.LogError(ex, "Error reading ffmpeg recording log"); - } - } - - /// - public void Dispose() - { - Dispose(true); - GC.SuppressFinalize(this); - } - - /// - /// Releases unmanaged and optionally managed resources. - /// - /// true to release both managed and unmanaged resources; false to release only unmanaged resources. - protected virtual void Dispose(bool disposing) - { - if (_disposed) - { - return; - } - - if (disposing) - { - _logFileStream?.Dispose(); - _process?.Dispose(); - } - - _logFileStream = null; - _process = null; - - _disposed = true; - } - } -} diff --git a/Emby.Server.Implementations/LiveTv/EmbyTV/EntryPoint.cs b/Emby.Server.Implementations/LiveTv/EmbyTV/EntryPoint.cs deleted file mode 100644 index a2ec2df37..000000000 --- a/Emby.Server.Implementations/LiveTv/EmbyTV/EntryPoint.cs +++ /dev/null @@ -1,21 +0,0 @@ -#pragma warning disable CS1591 - -using System.Threading.Tasks; -using MediaBrowser.Controller.Plugins; - -namespace Emby.Server.Implementations.LiveTv.EmbyTV -{ - public sealed class EntryPoint : IServerEntryPoint - { - /// - public Task RunAsync() - { - return EmbyTV.Current.Start(); - } - - /// - public void Dispose() - { - } - } -} diff --git a/Emby.Server.Implementations/LiveTv/EmbyTV/EpgChannelData.cs b/Emby.Server.Implementations/LiveTv/EmbyTV/EpgChannelData.cs deleted file mode 100644 index 20a8213a7..000000000 --- a/Emby.Server.Implementations/LiveTv/EmbyTV/EpgChannelData.cs +++ /dev/null @@ -1,54 +0,0 @@ -#pragma warning disable CS1591 - -using System; -using System.Collections.Generic; -using MediaBrowser.Controller.LiveTv; - -namespace Emby.Server.Implementations.LiveTv.EmbyTV -{ - internal class EpgChannelData - { - private readonly Dictionary _channelsById; - - private readonly Dictionary _channelsByNumber; - - private readonly Dictionary _channelsByName; - - public EpgChannelData(IEnumerable channels) - { - _channelsById = new Dictionary(StringComparer.OrdinalIgnoreCase); - _channelsByNumber = new Dictionary(StringComparer.OrdinalIgnoreCase); - _channelsByName = new Dictionary(StringComparer.OrdinalIgnoreCase); - - foreach (var channel in channels) - { - _channelsById[channel.Id] = channel; - - if (!string.IsNullOrEmpty(channel.Number)) - { - _channelsByNumber[channel.Number] = channel; - } - - var normalizedName = NormalizeName(channel.Name ?? string.Empty); - if (!string.IsNullOrWhiteSpace(normalizedName)) - { - _channelsByName[normalizedName] = channel; - } - } - } - - public ChannelInfo? GetChannelById(string id) - => _channelsById.GetValueOrDefault(id); - - public ChannelInfo? GetChannelByNumber(string number) - => _channelsByNumber.GetValueOrDefault(number); - - public ChannelInfo? GetChannelByName(string name) - => _channelsByName.GetValueOrDefault(name); - - public static string NormalizeName(string value) - { - return value.Replace(" ", string.Empty, StringComparison.Ordinal).Replace("-", string.Empty, StringComparison.Ordinal); - } - } -} diff --git a/Emby.Server.Implementations/LiveTv/EmbyTV/IRecorder.cs b/Emby.Server.Implementations/LiveTv/EmbyTV/IRecorder.cs deleted file mode 100644 index de14d6d08..000000000 --- a/Emby.Server.Implementations/LiveTv/EmbyTV/IRecorder.cs +++ /dev/null @@ -1,27 +0,0 @@ -#pragma warning disable CS1591 - -using System; -using System.Threading; -using System.Threading.Tasks; -using MediaBrowser.Controller.Library; -using MediaBrowser.Model.Dto; - -namespace Emby.Server.Implementations.LiveTv.EmbyTV -{ - public interface IRecorder : IDisposable - { - /// - /// Records the specified media source. - /// - /// The direct stream provider, or null. - /// The media source. - /// The target file. - /// The duration to record. - /// An action to perform when recording starts. - /// The cancellation token. - /// A that represents the recording operation. - Task Record(IDirectStreamProvider? directStreamProvider, MediaSourceInfo mediaSource, string targetFile, TimeSpan duration, Action onStarted, CancellationToken cancellationToken); - - string GetOutputPath(MediaSourceInfo mediaSource, string targetFile); - } -} diff --git a/Emby.Server.Implementations/LiveTv/EmbyTV/ItemDataProvider.cs b/Emby.Server.Implementations/LiveTv/EmbyTV/ItemDataProvider.cs deleted file mode 100644 index d5a6feb47..000000000 --- a/Emby.Server.Implementations/LiveTv/EmbyTV/ItemDataProvider.cs +++ /dev/null @@ -1,163 +0,0 @@ -#pragma warning disable CS1591 - -using System; -using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; -using System.IO; -using System.Linq; -using System.Text.Json; -using Jellyfin.Extensions.Json; -using Microsoft.Extensions.Logging; - -namespace Emby.Server.Implementations.LiveTv.EmbyTV -{ - public class ItemDataProvider - where T : class - { - private readonly string _dataPath; - private readonly object _fileDataLock = new object(); - private readonly JsonSerializerOptions _jsonOptions = JsonDefaults.Options; - private T[]? _items; - - public ItemDataProvider( - ILogger logger, - string dataPath, - Func equalityComparer) - { - Logger = logger; - _dataPath = dataPath; - EqualityComparer = equalityComparer; - } - - protected ILogger Logger { get; } - - protected Func EqualityComparer { get; } - - [MemberNotNull(nameof(_items))] - private void EnsureLoaded() - { - if (_items is not null) - { - return; - } - - if (File.Exists(_dataPath)) - { - Logger.LogInformation("Loading live tv data from {Path}", _dataPath); - - try - { - var bytes = File.ReadAllBytes(_dataPath); - _items = JsonSerializer.Deserialize(bytes, _jsonOptions); - if (_items is null) - { - Logger.LogError("Error deserializing {Path}, data was null", _dataPath); - _items = Array.Empty(); - } - - return; - } - catch (JsonException ex) - { - Logger.LogError(ex, "Error deserializing {Path}", _dataPath); - } - } - - _items = Array.Empty(); - } - - private void SaveList() - { - Directory.CreateDirectory(Path.GetDirectoryName(_dataPath) ?? throw new ArgumentException("Path can't be a root directory.", nameof(_dataPath))); - var jsonString = JsonSerializer.Serialize(_items, _jsonOptions); - File.WriteAllText(_dataPath, jsonString); - } - - public IReadOnlyList GetAll() - { - lock (_fileDataLock) - { - EnsureLoaded(); - return (T[])_items.Clone(); - } - } - - public virtual void Update(T item) - { - ArgumentNullException.ThrowIfNull(item); - - lock (_fileDataLock) - { - EnsureLoaded(); - - var index = Array.FindIndex(_items, i => EqualityComparer(i, item)); - if (index == -1) - { - throw new ArgumentException("item not found"); - } - - _items[index] = item; - - SaveList(); - } - } - - public virtual void Add(T item) - { - ArgumentNullException.ThrowIfNull(item); - - lock (_fileDataLock) - { - EnsureLoaded(); - - if (_items.Any(i => EqualityComparer(i, item))) - { - throw new ArgumentException("item already exists", nameof(item)); - } - - int oldLen = _items.Length; - var newList = new T[oldLen + 1]; - _items.CopyTo(newList, 0); - newList[oldLen] = item; - _items = newList; - - SaveList(); - } - } - - public virtual void AddOrUpdate(T item) - { - lock (_fileDataLock) - { - EnsureLoaded(); - - int index = Array.FindIndex(_items, i => EqualityComparer(i, item)); - if (index == -1) - { - int oldLen = _items.Length; - var newList = new T[oldLen + 1]; - _items.CopyTo(newList, 0); - newList[oldLen] = item; - _items = newList; - } - else - { - _items[index] = item; - } - - SaveList(); - } - } - - public virtual void Delete(T item) - { - lock (_fileDataLock) - { - EnsureLoaded(); - _items = _items.Where(i => !EqualityComparer(i, item)).ToArray(); - - SaveList(); - } - } - } -} diff --git a/Emby.Server.Implementations/LiveTv/EmbyTV/NfoConfigurationExtensions.cs b/Emby.Server.Implementations/LiveTv/EmbyTV/NfoConfigurationExtensions.cs deleted file mode 100644 index 83f5e8413..000000000 --- a/Emby.Server.Implementations/LiveTv/EmbyTV/NfoConfigurationExtensions.cs +++ /dev/null @@ -1,19 +0,0 @@ -using MediaBrowser.Common.Configuration; -using MediaBrowser.Model.Configuration; - -namespace Emby.Server.Implementations.LiveTv.EmbyTV -{ - /// - /// Class containing extension methods for working with the nfo configuration. - /// - public static class NfoConfigurationExtensions - { - /// - /// Gets the nfo configuration. - /// - /// The configuration manager. - /// The nfo configuration. - public static XbmcMetadataOptions GetNfoConfiguration(this IConfigurationManager configurationManager) - => configurationManager.GetConfiguration("xbmcmetadata"); - } -} diff --git a/Emby.Server.Implementations/LiveTv/EmbyTV/RecordingHelper.cs b/Emby.Server.Implementations/LiveTv/EmbyTV/RecordingHelper.cs deleted file mode 100644 index 7bbeae866..000000000 --- a/Emby.Server.Implementations/LiveTv/EmbyTV/RecordingHelper.cs +++ /dev/null @@ -1,83 +0,0 @@ -#pragma warning disable CS1591 - -using System; -using System.Globalization; -using System.Text; -using MediaBrowser.Controller.LiveTv; - -namespace Emby.Server.Implementations.LiveTv.EmbyTV -{ - internal static class RecordingHelper - { - public static DateTime GetStartTime(TimerInfo timer) - { - return timer.StartDate.AddSeconds(-timer.PrePaddingSeconds); - } - - public static string GetRecordingName(TimerInfo info) - { - var name = info.Name; - - if (info.IsProgramSeries) - { - var addHyphen = true; - - if (info.SeasonNumber.HasValue && info.EpisodeNumber.HasValue) - { - name += string.Format( - CultureInfo.InvariantCulture, - " S{0}E{1}", - info.SeasonNumber.Value.ToString("00", CultureInfo.InvariantCulture), - info.EpisodeNumber.Value.ToString("00", CultureInfo.InvariantCulture)); - addHyphen = false; - } - else if (info.OriginalAirDate.HasValue) - { - if (info.OriginalAirDate.Value.Date.Equals(info.StartDate.Date)) - { - name += " " + GetDateString(info.StartDate); - } - else - { - name += " " + info.OriginalAirDate.Value.ToLocalTime().ToString("yyyy-MM-dd", CultureInfo.InvariantCulture); - } - } - else - { - name += " " + GetDateString(info.StartDate); - } - - if (!string.IsNullOrWhiteSpace(info.EpisodeTitle)) - { - var tmpName = name; - if (addHyphen) - { - tmpName += " -"; - } - - tmpName += " " + info.EpisodeTitle; - // Since the filename will be used with file ext. (.mp4, .ts, etc) - if (Encoding.UTF8.GetByteCount(tmpName) < 250) - { - name = tmpName; - } - } - } - else if (info.IsMovie && info.ProductionYear is not null) - { - name += " (" + info.ProductionYear + ")"; - } - else - { - name += " " + GetDateString(info.StartDate); - } - - return name; - } - - private static string GetDateString(DateTime date) - { - return date.ToLocalTime().ToString("yyyy_MM_dd_HH_mm_ss", CultureInfo.InvariantCulture); - } - } -} diff --git a/Emby.Server.Implementations/LiveTv/EmbyTV/SeriesTimerManager.cs b/Emby.Server.Implementations/LiveTv/EmbyTV/SeriesTimerManager.cs deleted file mode 100644 index bf28f3b67..000000000 --- a/Emby.Server.Implementations/LiveTv/EmbyTV/SeriesTimerManager.cs +++ /dev/null @@ -1,24 +0,0 @@ -#pragma warning disable CS1591 - -using System; -using MediaBrowser.Controller.LiveTv; -using Microsoft.Extensions.Logging; - -namespace Emby.Server.Implementations.LiveTv.EmbyTV -{ - public class SeriesTimerManager : ItemDataProvider - { - public SeriesTimerManager(ILogger logger, string dataPath) - : base(logger, dataPath, (r1, r2) => string.Equals(r1.Id, r2.Id, StringComparison.OrdinalIgnoreCase)) - { - } - - /// - public override void Add(SeriesTimerInfo item) - { - ArgumentException.ThrowIfNullOrEmpty(item.Id); - - base.Add(item); - } - } -} diff --git a/Emby.Server.Implementations/LiveTv/EmbyTV/TimerManager.cs b/Emby.Server.Implementations/LiveTv/EmbyTV/TimerManager.cs deleted file mode 100644 index 9f8441fa4..000000000 --- a/Emby.Server.Implementations/LiveTv/EmbyTV/TimerManager.cs +++ /dev/null @@ -1,181 +0,0 @@ -#pragma warning disable CS1591 - -using System; -using System.Collections.Concurrent; -using System.Globalization; -using System.Linq; -using System.Threading; -using Jellyfin.Data.Events; -using MediaBrowser.Controller.LiveTv; -using MediaBrowser.Model.LiveTv; -using Microsoft.Extensions.Logging; - -namespace Emby.Server.Implementations.LiveTv.EmbyTV -{ - public class TimerManager : ItemDataProvider - { - private readonly ConcurrentDictionary _timers = new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase); - - public TimerManager(ILogger logger, string dataPath) - : base(logger, dataPath, (r1, r2) => string.Equals(r1.Id, r2.Id, StringComparison.OrdinalIgnoreCase)) - { - } - - public event EventHandler>? TimerFired; - - public void RestartTimers() - { - StopTimers(); - - foreach (var item in GetAll()) - { - AddOrUpdateSystemTimer(item); - } - } - - public void StopTimers() - { - foreach (var pair in _timers.ToList()) - { - pair.Value.Dispose(); - } - - _timers.Clear(); - } - - public override void Delete(TimerInfo item) - { - base.Delete(item); - StopTimer(item); - } - - public override void Update(TimerInfo item) - { - base.Update(item); - AddOrUpdateSystemTimer(item); - } - - public void AddOrUpdate(TimerInfo item, bool resetTimer) - { - if (resetTimer) - { - AddOrUpdate(item); - return; - } - - base.AddOrUpdate(item); - } - - public override void AddOrUpdate(TimerInfo item) - { - base.AddOrUpdate(item); - AddOrUpdateSystemTimer(item); - } - - public override void Add(TimerInfo item) - { - ArgumentException.ThrowIfNullOrEmpty(item.Id); - - base.Add(item); - AddOrUpdateSystemTimer(item); - } - - private static bool ShouldStartTimer(TimerInfo item) - { - if (item.Status == RecordingStatus.Completed - || item.Status == RecordingStatus.Cancelled) - { - return false; - } - - return true; - } - - private void AddOrUpdateSystemTimer(TimerInfo item) - { - StopTimer(item); - - if (!ShouldStartTimer(item)) - { - return; - } - - var startDate = RecordingHelper.GetStartTime(item); - var now = DateTime.UtcNow; - - if (startDate < now) - { - TimerFired?.Invoke(this, new GenericEventArgs(item)); - return; - } - - var dueTime = startDate - now; - StartTimer(item, dueTime); - } - - private void StartTimer(TimerInfo item, TimeSpan dueTime) - { - var timer = new Timer(TimerCallback, item.Id, dueTime, TimeSpan.Zero); - - if (_timers.TryAdd(item.Id, timer)) - { - if (item.IsSeries) - { - Logger.LogInformation( - "Creating recording timer for {Id}, {Name} {SeasonNumber}x{EpisodeNumber:D2} on channel {ChannelId}. Timer will fire in {Minutes} minutes at {StartDate}", - item.Id, - item.Name, - item.SeasonNumber, - item.EpisodeNumber, - item.ChannelId, - dueTime.TotalMinutes.ToString(CultureInfo.InvariantCulture), - item.StartDate); - } - else - { - Logger.LogInformation( - "Creating recording timer for {Id}, {Name} on channel {ChannelId}. Timer will fire in {Minutes} minutes at {StartDate}", - item.Id, - item.Name, - item.ChannelId, - dueTime.TotalMinutes.ToString(CultureInfo.InvariantCulture), - item.StartDate); - } - } - else - { - timer.Dispose(); - Logger.LogWarning("Timer already exists for item {Id}", item.Id); - } - } - - private void StopTimer(TimerInfo item) - { - if (_timers.TryRemove(item.Id, out var timer)) - { - timer.Dispose(); - } - } - - private void TimerCallback(object? state) - { - var timerId = (string?)state ?? throw new ArgumentNullException(nameof(state)); - - var timer = GetAll().FirstOrDefault(i => string.Equals(i.Id, timerId, StringComparison.OrdinalIgnoreCase)); - if (timer is not null) - { - TimerFired?.Invoke(this, new GenericEventArgs(timer)); - } - } - - public TimerInfo? GetTimer(string id) - { - return GetAll().FirstOrDefault(r => string.Equals(r.Id, id, StringComparison.OrdinalIgnoreCase)); - } - - public TimerInfo? GetTimerByProgramId(string programId) - { - return GetAll().FirstOrDefault(r => string.Equals(r.ProgramId, programId, StringComparison.OrdinalIgnoreCase)); - } - } -} diff --git a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirect.cs b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirect.cs deleted file mode 100644 index 5be3a7488..000000000 --- a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirect.cs +++ /dev/null @@ -1,808 +0,0 @@ -#nullable disable - -#pragma warning disable CS1591 - -using System; -using System.Collections.Concurrent; -using System.Collections.Generic; -using System.Globalization; -using System.Linq; -using System.Net; -using System.Net.Http; -using System.Net.Http.Json; -using System.Net.Mime; -using System.Security.Cryptography; -using System.Text; -using System.Text.Json; -using System.Threading; -using System.Threading.Tasks; -using Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos; -using Jellyfin.Extensions; -using Jellyfin.Extensions.Json; -using MediaBrowser.Common.Net; -using MediaBrowser.Controller.Authentication; -using MediaBrowser.Controller.LiveTv; -using MediaBrowser.Model.Dto; -using MediaBrowser.Model.Entities; -using MediaBrowser.Model.LiveTv; -using Microsoft.Extensions.Logging; - -namespace Emby.Server.Implementations.LiveTv.Listings -{ - public class SchedulesDirect : IListingsProvider, IDisposable - { - private const string ApiUrl = "https://json.schedulesdirect.org/20141201"; - - private readonly ILogger _logger; - private readonly IHttpClientFactory _httpClientFactory; - private readonly SemaphoreSlim _tokenSemaphore = new SemaphoreSlim(1, 1); - - private readonly ConcurrentDictionary _tokens = new ConcurrentDictionary(); - private readonly JsonSerializerOptions _jsonOptions = JsonDefaults.Options; - private DateTime _lastErrorResponse; - private bool _disposed = false; - - public SchedulesDirect( - ILogger logger, - IHttpClientFactory httpClientFactory) - { - _logger = logger; - _httpClientFactory = httpClientFactory; - } - - /// - public string Name => "Schedules Direct"; - - /// - public string Type => nameof(SchedulesDirect); - - private static List GetScheduleRequestDates(DateTime startDateUtc, DateTime endDateUtc) - { - var dates = new List(); - - var start = new[] { startDateUtc, startDateUtc.ToLocalTime() }.Min().Date; - var end = new[] { endDateUtc, endDateUtc.ToLocalTime() }.Max().Date; - - while (start <= end) - { - dates.Add(start.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)); - start = start.AddDays(1); - } - - return dates; - } - - public async Task> GetProgramsAsync(ListingsProviderInfo info, string channelId, DateTime startDateUtc, DateTime endDateUtc, CancellationToken cancellationToken) - { - ArgumentException.ThrowIfNullOrEmpty(channelId); - - // Normalize incoming input - channelId = channelId.Replace(".json.schedulesdirect.org", string.Empty, StringComparison.OrdinalIgnoreCase).TrimStart('I'); - - var token = await GetToken(info, cancellationToken).ConfigureAwait(false); - - if (string.IsNullOrEmpty(token)) - { - _logger.LogWarning("SchedulesDirect token is empty, returning empty program list"); - - return Enumerable.Empty(); - } - - var dates = GetScheduleRequestDates(startDateUtc, endDateUtc); - - _logger.LogInformation("Channel Station ID is: {ChannelID}", channelId); - var requestList = new List() - { - new RequestScheduleForChannelDto() - { - StationId = channelId, - Date = dates - } - }; - - _logger.LogDebug("Request string for schedules is: {@RequestString}", requestList); - - using var options = new HttpRequestMessage(HttpMethod.Post, ApiUrl + "/schedules"); - options.Content = JsonContent.Create(requestList, options: _jsonOptions); - options.Headers.TryAddWithoutValidation("token", token); - using var response = await Send(options, true, info, cancellationToken).ConfigureAwait(false); - var dailySchedules = await response.Content.ReadFromJsonAsync>(_jsonOptions, cancellationToken).ConfigureAwait(false); - if (dailySchedules is null) - { - return Array.Empty(); - } - - _logger.LogDebug("Found {ScheduleCount} programs on {ChannelID} ScheduleDirect", dailySchedules.Count, channelId); - - using var programRequestOptions = new HttpRequestMessage(HttpMethod.Post, ApiUrl + "/programs"); - programRequestOptions.Headers.TryAddWithoutValidation("token", token); - - var programIds = dailySchedules.SelectMany(d => d.Programs.Select(s => s.ProgramId)).Distinct(); - programRequestOptions.Content = JsonContent.Create(programIds, options: _jsonOptions); - - using var innerResponse = await Send(programRequestOptions, true, info, cancellationToken).ConfigureAwait(false); - var programDetails = await innerResponse.Content.ReadFromJsonAsync>(_jsonOptions, cancellationToken).ConfigureAwait(false); - if (programDetails is null) - { - return Array.Empty(); - } - - var programDict = programDetails.ToDictionary(p => p.ProgramId, y => y); - - var programIdsWithImages = programDetails - .Where(p => p.HasImageArtwork).Select(p => p.ProgramId) - .ToList(); - - var images = await GetImageForPrograms(info, programIdsWithImages, cancellationToken).ConfigureAwait(false); - - var programsInfo = new List(); - foreach (ProgramDto schedule in dailySchedules.SelectMany(d => d.Programs)) - { - // _logger.LogDebug("Proccesing Schedule for statio ID " + stationID + - // " which corresponds to channel " + channelNumber + " and program id " + - // schedule.ProgramId + " which says it has images? " + - // programDict[schedule.ProgramId].hasImageArtwork); - - if (string.IsNullOrEmpty(schedule.ProgramId)) - { - continue; - } - - if (images is not null) - { - var imageIndex = images.FindIndex(i => i.ProgramId == schedule.ProgramId[..10]); - if (imageIndex > -1) - { - var programEntry = programDict[schedule.ProgramId]; - - var allImages = images[imageIndex].Data; - var imagesWithText = allImages.Where(i => string.Equals(i.Text, "yes", StringComparison.OrdinalIgnoreCase)).ToList(); - var imagesWithoutText = allImages.Where(i => string.Equals(i.Text, "no", StringComparison.OrdinalIgnoreCase)).ToList(); - - const double DesiredAspect = 2.0 / 3; - - programEntry.PrimaryImage = GetProgramImage(ApiUrl, imagesWithText, DesiredAspect, token) ?? - GetProgramImage(ApiUrl, allImages, DesiredAspect, token); - - const double WideAspect = 16.0 / 9; - - programEntry.ThumbImage = GetProgramImage(ApiUrl, imagesWithText, WideAspect, token); - - // Don't supply the same image twice - if (string.Equals(programEntry.PrimaryImage, programEntry.ThumbImage, StringComparison.Ordinal)) - { - programEntry.ThumbImage = null; - } - - programEntry.BackdropImage = GetProgramImage(ApiUrl, imagesWithoutText, WideAspect, token); - - // programEntry.bannerImage = GetProgramImage(ApiUrl, data, "Banner", false) ?? - // GetProgramImage(ApiUrl, data, "Banner-L1", false) ?? - // GetProgramImage(ApiUrl, data, "Banner-LO", false) ?? - // GetProgramImage(ApiUrl, data, "Banner-LOT", false); - } - } - - programsInfo.Add(GetProgram(channelId, schedule, programDict[schedule.ProgramId])); - } - - return programsInfo; - } - - private static int GetSizeOrder(ImageDataDto image) - { - if (int.TryParse(image.Height, out int value)) - { - return value; - } - - return 0; - } - - private static string GetChannelNumber(MapDto map) - { - var channelNumber = map.LogicalChannelNumber; - - if (string.IsNullOrWhiteSpace(channelNumber)) - { - channelNumber = map.Channel; - } - - if (string.IsNullOrWhiteSpace(channelNumber)) - { - channelNumber = map.AtscMajor + "." + map.AtscMinor; - } - - return channelNumber.TrimStart('0'); - } - - private static bool IsMovie(ProgramDetailsDto programInfo) - { - return string.Equals(programInfo.EntityType, "movie", StringComparison.OrdinalIgnoreCase); - } - - private ProgramInfo GetProgram(string channelId, ProgramDto programInfo, ProgramDetailsDto details) - { - if (programInfo.AirDateTime is null) - { - return null; - } - - var startAt = programInfo.AirDateTime.Value; - var endAt = startAt.AddSeconds(programInfo.Duration); - var audioType = ProgramAudio.Stereo; - - var programId = programInfo.ProgramId ?? string.Empty; - - string newID = programId + "T" + startAt.Ticks + "C" + channelId; - - if (programInfo.AudioProperties.Count != 0) - { - if (programInfo.AudioProperties.Contains("atmos", StringComparison.OrdinalIgnoreCase)) - { - audioType = ProgramAudio.Atmos; - } - else if (programInfo.AudioProperties.Contains("dd 5.1", StringComparison.OrdinalIgnoreCase)) - { - audioType = ProgramAudio.DolbyDigital; - } - else if (programInfo.AudioProperties.Contains("dd", StringComparison.OrdinalIgnoreCase)) - { - audioType = ProgramAudio.DolbyDigital; - } - else if (programInfo.AudioProperties.Contains("stereo", StringComparison.OrdinalIgnoreCase)) - { - audioType = ProgramAudio.Stereo; - } - else - { - audioType = ProgramAudio.Mono; - } - } - - string episodeTitle = null; - if (details.EpisodeTitle150 is not null) - { - episodeTitle = details.EpisodeTitle150; - } - - var info = new ProgramInfo - { - ChannelId = channelId, - Id = newID, - StartDate = startAt, - EndDate = endAt, - Name = details.Titles[0].Title120 ?? "Unknown", - OfficialRating = null, - CommunityRating = null, - EpisodeTitle = episodeTitle, - Audio = audioType, - // IsNew = programInfo.@new ?? false, - IsRepeat = programInfo.New is null, - IsSeries = string.Equals(details.EntityType, "episode", StringComparison.OrdinalIgnoreCase), - ImageUrl = details.PrimaryImage, - ThumbImageUrl = details.ThumbImage, - IsKids = string.Equals(details.Audience, "children", StringComparison.OrdinalIgnoreCase), - IsSports = string.Equals(details.EntityType, "sports", StringComparison.OrdinalIgnoreCase), - IsMovie = IsMovie(details), - Etag = programInfo.Md5, - IsLive = string.Equals(programInfo.LiveTapeDelay, "live", StringComparison.OrdinalIgnoreCase), - IsPremiere = programInfo.Premiere || (programInfo.IsPremiereOrFinale ?? string.Empty).Contains("premiere", StringComparison.OrdinalIgnoreCase) - }; - - var showId = programId; - - if (!info.IsSeries) - { - // It's also a series if it starts with SH - info.IsSeries = showId.StartsWith("SH", StringComparison.OrdinalIgnoreCase) && showId.Length >= 14; - } - - // According to SchedulesDirect, these are generic, unidentified episodes - // SH005316560000 - var hasUniqueShowId = !showId.StartsWith("SH", StringComparison.OrdinalIgnoreCase) || - !showId.EndsWith("0000", StringComparison.OrdinalIgnoreCase); - - if (!hasUniqueShowId) - { - showId = newID; - } - - info.ShowId = showId; - - if (programInfo.VideoProperties is not null) - { - info.IsHD = programInfo.VideoProperties.Contains("hdtv", StringComparison.OrdinalIgnoreCase); - info.Is3D = programInfo.VideoProperties.Contains("3d", StringComparison.OrdinalIgnoreCase); - } - - if (details.ContentRating is not null && details.ContentRating.Count > 0) - { - info.OfficialRating = details.ContentRating[0].Code.Replace("TV", "TV-", StringComparison.Ordinal) - .Replace("--", "-", StringComparison.Ordinal); - - var invalid = new[] { "N/A", "Approved", "Not Rated", "Passed" }; - if (invalid.Contains(info.OfficialRating, StringComparison.OrdinalIgnoreCase)) - { - info.OfficialRating = null; - } - } - - if (details.Descriptions is not null) - { - if (details.Descriptions.Description1000 is not null && details.Descriptions.Description1000.Count > 0) - { - info.Overview = details.Descriptions.Description1000[0].Description; - } - else if (details.Descriptions.Description100 is not null && details.Descriptions.Description100.Count > 0) - { - info.Overview = details.Descriptions.Description100[0].Description; - } - } - - if (info.IsSeries) - { - info.SeriesId = programId.Substring(0, 10); - - info.SeriesProviderIds[MetadataProvider.Zap2It.ToString()] = info.SeriesId; - - if (details.Metadata is not null) - { - foreach (var metadataProgram in details.Metadata) - { - var gracenote = metadataProgram.Gracenote; - if (gracenote is not null) - { - info.SeasonNumber = gracenote.Season; - - if (gracenote.Episode > 0) - { - info.EpisodeNumber = gracenote.Episode; - } - - break; - } - } - } - } - - if (details.OriginalAirDate is not null) - { - info.OriginalAirDate = details.OriginalAirDate; - info.ProductionYear = info.OriginalAirDate.Value.Year; - } - - if (details.Movie is not null) - { - if (!string.IsNullOrEmpty(details.Movie.Year) - && int.TryParse(details.Movie.Year, out int year)) - { - info.ProductionYear = year; - } - } - - if (details.Genres is not null) - { - info.Genres = details.Genres.Where(g => !string.IsNullOrWhiteSpace(g)).ToList(); - info.IsNews = details.Genres.Contains("news", StringComparison.OrdinalIgnoreCase); - - if (info.Genres.Contains("children", StringComparison.OrdinalIgnoreCase)) - { - info.IsKids = true; - } - } - - return info; - } - - private static string GetProgramImage(string apiUrl, IEnumerable images, double desiredAspect, string token) - { - var match = images - .OrderBy(i => Math.Abs(desiredAspect - GetAspectRatio(i))) - .ThenByDescending(i => GetSizeOrder(i)) - .FirstOrDefault(); - - if (match is null) - { - return null; - } - - var uri = match.Uri; - - if (string.IsNullOrWhiteSpace(uri)) - { - return null; - } - - if (uri.Contains("http", StringComparison.OrdinalIgnoreCase)) - { - return uri; - } - - return apiUrl + "/image/" + uri + "?token=" + token; - } - - private static double GetAspectRatio(ImageDataDto i) - { - int width = 0; - int height = 0; - - if (!string.IsNullOrWhiteSpace(i.Width)) - { - _ = int.TryParse(i.Width, out width); - } - - if (!string.IsNullOrWhiteSpace(i.Height)) - { - _ = int.TryParse(i.Height, out height); - } - - if (height == 0 || width == 0) - { - return 0; - } - - double result = width; - result /= height; - return result; - } - - private async Task> GetImageForPrograms( - ListingsProviderInfo info, - IReadOnlyList programIds, - CancellationToken cancellationToken) - { - var token = await GetToken(info, cancellationToken).ConfigureAwait(false); - - if (programIds.Count == 0) - { - return Array.Empty(); - } - - StringBuilder str = new StringBuilder("[", 1 + (programIds.Count * 13)); - foreach (var i in programIds) - { - str.Append('"') - .Append(i[..10]) - .Append("\","); - } - - // Remove last , - str.Length--; - str.Append(']'); - - using var message = new HttpRequestMessage(HttpMethod.Post, ApiUrl + "/metadata/programs") - { - Content = new StringContent(str.ToString(), Encoding.UTF8, MediaTypeNames.Application.Json) - }; - message.Headers.TryAddWithoutValidation("token", token); - - try - { - using var innerResponse2 = await Send(message, true, info, cancellationToken).ConfigureAwait(false); - return await innerResponse2.Content.ReadFromJsonAsync>(_jsonOptions, cancellationToken).ConfigureAwait(false); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error getting image info from schedules direct"); - - return Array.Empty(); - } - } - - public async Task> GetHeadends(ListingsProviderInfo info, string country, string location, CancellationToken cancellationToken) - { - var token = await GetToken(info, cancellationToken).ConfigureAwait(false); - - var lineups = new List(); - - if (string.IsNullOrWhiteSpace(token)) - { - return lineups; - } - - using var options = new HttpRequestMessage(HttpMethod.Get, ApiUrl + "/headends?country=" + country + "&postalcode=" + location); - options.Headers.TryAddWithoutValidation("token", token); - - try - { - using var httpResponse = await Send(options, false, info, cancellationToken).ConfigureAwait(false); - var root = await httpResponse.Content.ReadFromJsonAsync>(_jsonOptions, cancellationToken).ConfigureAwait(false); - if (root is not null) - { - foreach (HeadendsDto headend in root) - { - foreach (LineupDto lineup in headend.Lineups) - { - lineups.Add(new NameIdPair - { - Name = string.IsNullOrWhiteSpace(lineup.Name) ? lineup.Lineup : lineup.Name, - Id = lineup.Uri?[18..] - }); - } - } - } - else - { - _logger.LogInformation("No lineups available"); - } - } - catch (Exception ex) - { - _logger.LogError(ex, "Error getting headends"); - } - - return lineups; - } - - private async Task GetToken(ListingsProviderInfo info, CancellationToken cancellationToken) - { - var username = info.Username; - - // Reset the token if there's no username - if (string.IsNullOrWhiteSpace(username)) - { - return null; - } - - var password = info.Password; - if (string.IsNullOrEmpty(password)) - { - return null; - } - - // Avoid hammering SD - if ((DateTime.UtcNow - _lastErrorResponse).TotalMinutes < 1) - { - return null; - } - - if (!_tokens.TryGetValue(username, out NameValuePair savedToken)) - { - savedToken = new NameValuePair(); - _tokens.TryAdd(username, savedToken); - } - - if (!string.IsNullOrEmpty(savedToken.Name) - && long.TryParse(savedToken.Value, CultureInfo.InvariantCulture, out long ticks)) - { - // If it's under 24 hours old we can still use it - if (DateTime.UtcNow.Ticks - ticks < TimeSpan.FromHours(20).Ticks) - { - return savedToken.Name; - } - } - - await _tokenSemaphore.WaitAsync(cancellationToken).ConfigureAwait(false); - try - { - var result = await GetTokenInternal(username, password, cancellationToken).ConfigureAwait(false); - savedToken.Name = result; - savedToken.Value = DateTime.UtcNow.Ticks.ToString(CultureInfo.InvariantCulture); - return result; - } - catch (HttpRequestException ex) - { - if (ex.StatusCode.HasValue && ex.StatusCode.Value == HttpStatusCode.BadRequest) - { - _tokens.Clear(); - _lastErrorResponse = DateTime.UtcNow; - } - - throw; - } - finally - { - _tokenSemaphore.Release(); - } - } - - private async Task Send( - HttpRequestMessage options, - bool enableRetry, - ListingsProviderInfo providerInfo, - CancellationToken cancellationToken, - HttpCompletionOption completionOption = HttpCompletionOption.ResponseContentRead) - { - var response = await _httpClientFactory.CreateClient(NamedClient.Default) - .SendAsync(options, completionOption, cancellationToken).ConfigureAwait(false); - if (response.IsSuccessStatusCode) - { - return response; - } - - // Response is automatically disposed in the calling function, - // so dispose manually if not returning. - response.Dispose(); - if (!enableRetry || (int)response.StatusCode >= 500) - { - throw new HttpRequestException( - string.Format(CultureInfo.InvariantCulture, "Request failed: {0}", response.ReasonPhrase), - null, - response.StatusCode); - } - - _tokens.Clear(); - options.Headers.TryAddWithoutValidation("token", await GetToken(providerInfo, cancellationToken).ConfigureAwait(false)); - return await Send(options, false, providerInfo, cancellationToken).ConfigureAwait(false); - } - - private async Task GetTokenInternal( - string username, - string password, - CancellationToken cancellationToken) - { - using var options = new HttpRequestMessage(HttpMethod.Post, ApiUrl + "/token"); -#pragma warning disable CA5350 // SchedulesDirect is always SHA1. - var hashedPasswordBytes = SHA1.HashData(Encoding.ASCII.GetBytes(password)); -#pragma warning restore CA5350 - // TODO: remove ToLower when Convert.ToHexString supports lowercase - // Schedules Direct requires the hex to be lowercase - string hashedPassword = Convert.ToHexString(hashedPasswordBytes).ToLowerInvariant(); - options.Content = new StringContent("{\"username\":\"" + username + "\",\"password\":\"" + hashedPassword + "\"}", Encoding.UTF8, MediaTypeNames.Application.Json); - - using var response = await Send(options, false, null, cancellationToken).ConfigureAwait(false); - response.EnsureSuccessStatusCode(); - var root = await response.Content.ReadFromJsonAsync(_jsonOptions, cancellationToken).ConfigureAwait(false); - if (string.Equals(root?.Message, "OK", StringComparison.Ordinal)) - { - _logger.LogInformation("Authenticated with Schedules Direct token: {Token}", root.Token); - return root.Token; - } - - throw new AuthenticationException("Could not authenticate with Schedules Direct Error: " + root.Message); - } - - private async Task AddLineupToAccount(ListingsProviderInfo info, CancellationToken cancellationToken) - { - var token = await GetToken(info, cancellationToken).ConfigureAwait(false); - - ArgumentException.ThrowIfNullOrEmpty(token); - ArgumentException.ThrowIfNullOrEmpty(info.ListingsId); - - _logger.LogInformation("Adding new LineUp "); - - using var options = new HttpRequestMessage(HttpMethod.Put, ApiUrl + "/lineups/" + info.ListingsId); - options.Headers.TryAddWithoutValidation("token", token); - using var response = await _httpClientFactory.CreateClient(NamedClient.Default).SendAsync(options, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); - } - - private async Task HasLineup(ListingsProviderInfo info, CancellationToken cancellationToken) - { - ArgumentException.ThrowIfNullOrEmpty(info.ListingsId); - - var token = await GetToken(info, cancellationToken).ConfigureAwait(false); - - ArgumentException.ThrowIfNullOrEmpty(token); - - _logger.LogInformation("Headends on account "); - - using var options = new HttpRequestMessage(HttpMethod.Get, ApiUrl + "/lineups"); - options.Headers.TryAddWithoutValidation("token", token); - - try - { - using var httpResponse = await Send(options, false, null, cancellationToken).ConfigureAwait(false); - httpResponse.EnsureSuccessStatusCode(); - var root = await httpResponse.Content.ReadFromJsonAsync(_jsonOptions, cancellationToken).ConfigureAwait(false); - return root?.Lineups.Any(i => string.Equals(info.ListingsId, i.Lineup, StringComparison.OrdinalIgnoreCase)) ?? false; - } - catch (HttpRequestException ex) - { - // SchedulesDirect returns 400 if no lineups are configured. - if (ex.StatusCode is HttpStatusCode.BadRequest) - { - return false; - } - - throw; - } - } - - public async Task Validate(ListingsProviderInfo info, bool validateLogin, bool validateListings) - { - if (validateLogin) - { - ArgumentException.ThrowIfNullOrEmpty(info.Username); - ArgumentException.ThrowIfNullOrEmpty(info.Password); - } - - if (validateListings) - { - ArgumentException.ThrowIfNullOrEmpty(info.ListingsId); - - var hasLineup = await HasLineup(info, CancellationToken.None).ConfigureAwait(false); - - if (!hasLineup) - { - await AddLineupToAccount(info, CancellationToken.None).ConfigureAwait(false); - } - } - } - - public Task> GetLineups(ListingsProviderInfo info, string country, string location) - { - return GetHeadends(info, country, location, CancellationToken.None); - } - - public async Task> GetChannels(ListingsProviderInfo info, CancellationToken cancellationToken) - { - var listingsId = info.ListingsId; - ArgumentException.ThrowIfNullOrEmpty(listingsId); - - var token = await GetToken(info, cancellationToken).ConfigureAwait(false); - - ArgumentException.ThrowIfNullOrEmpty(token); - - using var options = new HttpRequestMessage(HttpMethod.Get, ApiUrl + "/lineups/" + listingsId); - options.Headers.TryAddWithoutValidation("token", token); - - using var httpResponse = await Send(options, true, info, cancellationToken).ConfigureAwait(false); - var root = await httpResponse.Content.ReadFromJsonAsync(_jsonOptions, cancellationToken).ConfigureAwait(false); - if (root is null) - { - return new List(); - } - - _logger.LogInformation("Found {ChannelCount} channels on the lineup on ScheduleDirect", root.Map.Count); - _logger.LogInformation("Mapping Stations to Channel"); - - var allStations = root.Stations; - - var map = root.Map; - var list = new List(map.Count); - foreach (var channel in map) - { - var channelNumber = GetChannelNumber(channel); - - var stationIndex = allStations.FindIndex(item => string.Equals(item.StationId, channel.StationId, StringComparison.OrdinalIgnoreCase)); - var station = stationIndex == -1 - ? new StationDto { StationId = channel.StationId } - : allStations[stationIndex]; - - var channelInfo = new ChannelInfo - { - Id = station.StationId, - CallSign = station.Callsign, - Number = channelNumber, - Name = string.IsNullOrWhiteSpace(station.Name) ? channelNumber : station.Name - }; - - if (station.Logo is not null) - { - channelInfo.ImageUrl = station.Logo.Url; - } - - list.Add(channelInfo); - } - - return list; - } - - /// - public void Dispose() - { - Dispose(true); - GC.SuppressFinalize(this); - } - - /// - /// Releases unmanaged and optionally managed resources. - /// - /// true to release both managed and unmanaged resources; false to release only unmanaged resources. - protected virtual void Dispose(bool disposing) - { - if (_disposed) - { - return; - } - - if (disposing) - { - _tokenSemaphore?.Dispose(); - } - - _disposed = true; - } - } -} diff --git a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/BroadcasterDto.cs b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/BroadcasterDto.cs deleted file mode 100644 index 95ac996e0..000000000 --- a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/BroadcasterDto.cs +++ /dev/null @@ -1,34 +0,0 @@ -using System.Text.Json.Serialization; - -namespace Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos -{ - /// - /// Broadcaster dto. - /// - public class BroadcasterDto - { - /// - /// Gets or sets the city. - /// - [JsonPropertyName("city")] - public string? City { get; set; } - - /// - /// Gets or sets the state. - /// - [JsonPropertyName("state")] - public string? State { get; set; } - - /// - /// Gets or sets the postal code. - /// - [JsonPropertyName("postalCode")] - public string? Postalcode { get; set; } - - /// - /// Gets or sets the country. - /// - [JsonPropertyName("country")] - public string? Country { get; set; } - } -} diff --git a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/CaptionDto.cs b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/CaptionDto.cs deleted file mode 100644 index f6251b9ad..000000000 --- a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/CaptionDto.cs +++ /dev/null @@ -1,22 +0,0 @@ -using System.Text.Json.Serialization; - -namespace Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos -{ - /// - /// Caption dto. - /// - public class CaptionDto - { - /// - /// Gets or sets the content. - /// - [JsonPropertyName("content")] - public string? Content { get; set; } - - /// - /// Gets or sets the lang. - /// - [JsonPropertyName("lang")] - public string? Lang { get; set; } - } -} diff --git a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/CastDto.cs b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/CastDto.cs deleted file mode 100644 index 0b7a2c63a..000000000 --- a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/CastDto.cs +++ /dev/null @@ -1,46 +0,0 @@ -using System.Text.Json.Serialization; - -namespace Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos -{ - /// - /// Cast dto. - /// - public class CastDto - { - /// - /// Gets or sets the billing order. - /// - [JsonPropertyName("billingOrder")] - public string? BillingOrder { get; set; } - - /// - /// Gets or sets the role. - /// - [JsonPropertyName("role")] - public string? Role { get; set; } - - /// - /// Gets or sets the name id. - /// - [JsonPropertyName("nameId")] - public string? NameId { get; set; } - - /// - /// Gets or sets the person id. - /// - [JsonPropertyName("personId")] - public string? PersonId { get; set; } - - /// - /// Gets or sets the name. - /// - [JsonPropertyName("name")] - public string? Name { get; set; } - - /// - /// Gets or sets the character name. - /// - [JsonPropertyName("characterName")] - public string? CharacterName { get; set; } - } -} diff --git a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/ChannelDto.cs b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/ChannelDto.cs deleted file mode 100644 index 87c327ed8..000000000 --- a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/ChannelDto.cs +++ /dev/null @@ -1,30 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Text.Json.Serialization; - -namespace Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos -{ - /// - /// Channel dto. - /// - public class ChannelDto - { - /// - /// Gets or sets the list of maps. - /// - [JsonPropertyName("map")] - public IReadOnlyList Map { get; set; } = Array.Empty(); - - /// - /// Gets or sets the list of stations. - /// - [JsonPropertyName("stations")] - public IReadOnlyList Stations { get; set; } = Array.Empty(); - - /// - /// Gets or sets the metadata. - /// - [JsonPropertyName("metadata")] - public MetadataDto? Metadata { get; set; } - } -} diff --git a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/ContentRatingDto.cs b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/ContentRatingDto.cs deleted file mode 100644 index c19cd2e48..000000000 --- a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/ContentRatingDto.cs +++ /dev/null @@ -1,22 +0,0 @@ -using System.Text.Json.Serialization; - -namespace Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos -{ - /// - /// Content rating dto. - /// - public class ContentRatingDto - { - /// - /// Gets or sets the body. - /// - [JsonPropertyName("body")] - public string? Body { get; set; } - - /// - /// Gets or sets the code. - /// - [JsonPropertyName("code")] - public string? Code { get; set; } - } -} diff --git a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/CrewDto.cs b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/CrewDto.cs deleted file mode 100644 index f00c9accd..000000000 --- a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/CrewDto.cs +++ /dev/null @@ -1,40 +0,0 @@ -using System.Text.Json.Serialization; - -namespace Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos -{ - /// - /// Crew dto. - /// - public class CrewDto - { - /// - /// Gets or sets the billing order. - /// - [JsonPropertyName("billingOrder")] - public string? BillingOrder { get; set; } - - /// - /// Gets or sets the role. - /// - [JsonPropertyName("role")] - public string? Role { get; set; } - - /// - /// Gets or sets the name id. - /// - [JsonPropertyName("nameId")] - public string? NameId { get; set; } - - /// - /// Gets or sets the person id. - /// - [JsonPropertyName("personId")] - public string? PersonId { get; set; } - - /// - /// Gets or sets the name. - /// - [JsonPropertyName("name")] - public string? Name { get; set; } - } -} diff --git a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/DayDto.cs b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/DayDto.cs deleted file mode 100644 index 1a371965c..000000000 --- a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/DayDto.cs +++ /dev/null @@ -1,30 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Text.Json.Serialization; - -namespace Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos -{ - /// - /// Day dto. - /// - public class DayDto - { - /// - /// Gets or sets the station id. - /// - [JsonPropertyName("stationID")] - public string? StationId { get; set; } - - /// - /// Gets or sets the list of programs. - /// - [JsonPropertyName("programs")] - public IReadOnlyList Programs { get; set; } = Array.Empty(); - - /// - /// Gets or sets the metadata schedule. - /// - [JsonPropertyName("metadata")] - public MetadataScheduleDto? Metadata { get; set; } - } -} diff --git a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/Description1000Dto.cs b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/Description1000Dto.cs deleted file mode 100644 index ca6ae7fb1..000000000 --- a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/Description1000Dto.cs +++ /dev/null @@ -1,22 +0,0 @@ -using System.Text.Json.Serialization; - -namespace Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos -{ - /// - /// Description 1_000 dto. - /// - public class Description1000Dto - { - /// - /// Gets or sets the description language. - /// - [JsonPropertyName("descriptionLanguage")] - public string? DescriptionLanguage { get; set; } - - /// - /// Gets or sets the description. - /// - [JsonPropertyName("description")] - public string? Description { get; set; } - } -} diff --git a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/Description100Dto.cs b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/Description100Dto.cs deleted file mode 100644 index 1577219ed..000000000 --- a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/Description100Dto.cs +++ /dev/null @@ -1,22 +0,0 @@ -using System.Text.Json.Serialization; - -namespace Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos -{ - /// - /// Description 100 dto. - /// - public class Description100Dto - { - /// - /// Gets or sets the description language. - /// - [JsonPropertyName("descriptionLanguage")] - public string? DescriptionLanguage { get; set; } - - /// - /// Gets or sets the description. - /// - [JsonPropertyName("description")] - public string? Description { get; set; } - } -} diff --git a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/DescriptionsProgramDto.cs b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/DescriptionsProgramDto.cs deleted file mode 100644 index eaf4a340b..000000000 --- a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/DescriptionsProgramDto.cs +++ /dev/null @@ -1,24 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Text.Json.Serialization; - -namespace Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos -{ - /// - /// Descriptions program dto. - /// - public class DescriptionsProgramDto - { - /// - /// Gets or sets the list of description 100. - /// - [JsonPropertyName("description100")] - public IReadOnlyList Description100 { get; set; } = Array.Empty(); - - /// - /// Gets or sets the list of description1000. - /// - [JsonPropertyName("description1000")] - public IReadOnlyList Description1000 { get; set; } = Array.Empty(); - } -} diff --git a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/EventDetailsDto.cs b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/EventDetailsDto.cs deleted file mode 100644 index fbdfb1f71..000000000 --- a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/EventDetailsDto.cs +++ /dev/null @@ -1,16 +0,0 @@ -using System.Text.Json.Serialization; - -namespace Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos -{ - /// - /// Event details dto. - /// - public class EventDetailsDto - { - /// - /// Gets or sets the sub type. - /// - [JsonPropertyName("subType")] - public string? SubType { get; set; } - } -} diff --git a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/GracenoteDto.cs b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/GracenoteDto.cs deleted file mode 100644 index 6852d89d7..000000000 --- a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/GracenoteDto.cs +++ /dev/null @@ -1,22 +0,0 @@ -using System.Text.Json.Serialization; - -namespace Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos -{ - /// - /// Gracenote dto. - /// - public class GracenoteDto - { - /// - /// Gets or sets the season. - /// - [JsonPropertyName("season")] - public int Season { get; set; } - - /// - /// Gets or sets the episode. - /// - [JsonPropertyName("episode")] - public int Episode { get; set; } - } -} diff --git a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/HeadendsDto.cs b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/HeadendsDto.cs deleted file mode 100644 index b9844562f..000000000 --- a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/HeadendsDto.cs +++ /dev/null @@ -1,36 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Text.Json.Serialization; - -namespace Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos -{ - /// - /// Headends dto. - /// - public class HeadendsDto - { - /// - /// Gets or sets the headend. - /// - [JsonPropertyName("headend")] - public string? Headend { get; set; } - - /// - /// Gets or sets the transport. - /// - [JsonPropertyName("transport")] - public string? Transport { get; set; } - - /// - /// Gets or sets the location. - /// - [JsonPropertyName("location")] - public string? Location { get; set; } - - /// - /// Gets or sets the list of lineups. - /// - [JsonPropertyName("lineups")] - public IReadOnlyList Lineups { get; set; } = Array.Empty(); - } -} diff --git a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/ImageDataDto.cs b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/ImageDataDto.cs deleted file mode 100644 index a1ae3ca6d..000000000 --- a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/ImageDataDto.cs +++ /dev/null @@ -1,70 +0,0 @@ -using System.Text.Json.Serialization; - -namespace Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos -{ - /// - /// Image data dto. - /// - public class ImageDataDto - { - /// - /// Gets or sets the width. - /// - [JsonPropertyName("width")] - public string? Width { get; set; } - - /// - /// Gets or sets the height. - /// - [JsonPropertyName("height")] - public string? Height { get; set; } - - /// - /// Gets or sets the uri. - /// - [JsonPropertyName("uri")] - public string? Uri { get; set; } - - /// - /// Gets or sets the size. - /// - [JsonPropertyName("size")] - public string? Size { get; set; } - - /// - /// Gets or sets the aspect. - /// - [JsonPropertyName("aspect")] - public string? Aspect { get; set; } - - /// - /// Gets or sets the category. - /// - [JsonPropertyName("category")] - public string? Category { get; set; } - - /// - /// Gets or sets the text. - /// - [JsonPropertyName("text")] - public string? Text { get; set; } - - /// - /// Gets or sets the primary. - /// - [JsonPropertyName("primary")] - public string? Primary { get; set; } - - /// - /// Gets or sets the tier. - /// - [JsonPropertyName("tier")] - public string? Tier { get; set; } - - /// - /// Gets or sets the caption. - /// - [JsonPropertyName("caption")] - public CaptionDto? Caption { get; set; } - } -} diff --git a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/LineupDto.cs b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/LineupDto.cs deleted file mode 100644 index 3dc64e5d8..000000000 --- a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/LineupDto.cs +++ /dev/null @@ -1,46 +0,0 @@ -using System.Text.Json.Serialization; - -namespace Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos -{ - /// - /// The lineup dto. - /// - public class LineupDto - { - /// - /// Gets or sets the linup. - /// - [JsonPropertyName("lineup")] - public string? Lineup { get; set; } - - /// - /// Gets or sets the lineup name. - /// - [JsonPropertyName("name")] - public string? Name { get; set; } - - /// - /// Gets or sets the transport. - /// - [JsonPropertyName("transport")] - public string? Transport { get; set; } - - /// - /// Gets or sets the location. - /// - [JsonPropertyName("location")] - public string? Location { get; set; } - - /// - /// Gets or sets the uri. - /// - [JsonPropertyName("uri")] - public string? Uri { get; set; } - - /// - /// Gets or sets a value indicating whether this lineup was deleted. - /// - [JsonPropertyName("isDeleted")] - public bool? IsDeleted { get; set; } - } -} diff --git a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/LineupsDto.cs b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/LineupsDto.cs deleted file mode 100644 index f19081781..000000000 --- a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/LineupsDto.cs +++ /dev/null @@ -1,36 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Text.Json.Serialization; - -namespace Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos -{ - /// - /// Lineups dto. - /// - public class LineupsDto - { - /// - /// Gets or sets the response code. - /// - [JsonPropertyName("code")] - public int Code { get; set; } - - /// - /// Gets or sets the server id. - /// - [JsonPropertyName("serverID")] - public string? ServerId { get; set; } - - /// - /// Gets or sets the datetime. - /// - [JsonPropertyName("datetime")] - public DateTime? LineupTimestamp { get; set; } - - /// - /// Gets or sets the list of lineups. - /// - [JsonPropertyName("lineups")] - public IReadOnlyList Lineups { get; set; } = Array.Empty(); - } -} diff --git a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/LogoDto.cs b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/LogoDto.cs deleted file mode 100644 index fecc55e03..000000000 --- a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/LogoDto.cs +++ /dev/null @@ -1,34 +0,0 @@ -using System.Text.Json.Serialization; - -namespace Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos -{ - /// - /// Logo dto. - /// - public class LogoDto - { - /// - /// Gets or sets the url. - /// - [JsonPropertyName("URL")] - public string? Url { get; set; } - - /// - /// Gets or sets the height. - /// - [JsonPropertyName("height")] - public int Height { get; set; } - - /// - /// Gets or sets the width. - /// - [JsonPropertyName("width")] - public int Width { get; set; } - - /// - /// Gets or sets the md5. - /// - [JsonPropertyName("md5")] - public string? Md5 { get; set; } - } -} diff --git a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/MapDto.cs b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/MapDto.cs deleted file mode 100644 index ffd02d474..000000000 --- a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/MapDto.cs +++ /dev/null @@ -1,58 +0,0 @@ -using System.Text.Json.Serialization; - -namespace Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos -{ - /// - /// Map dto. - /// - public class MapDto - { - /// - /// Gets or sets the station id. - /// - [JsonPropertyName("stationID")] - public string? StationId { get; set; } - - /// - /// Gets or sets the channel. - /// - [JsonPropertyName("channel")] - public string? Channel { get; set; } - - /// - /// Gets or sets the provider callsign. - /// - [JsonPropertyName("providerCallsign")] - public string? ProvderCallsign { get; set; } - - /// - /// Gets or sets the logical channel number. - /// - [JsonPropertyName("logicalChannelNumber")] - public string? LogicalChannelNumber { get; set; } - - /// - /// Gets or sets the uhfvhf. - /// - [JsonPropertyName("uhfVhf")] - public int UhfVhf { get; set; } - - /// - /// Gets or sets the atsc major. - /// - [JsonPropertyName("atscMajor")] - public int AtscMajor { get; set; } - - /// - /// Gets or sets the atsc minor. - /// - [JsonPropertyName("atscMinor")] - public int AtscMinor { get; set; } - - /// - /// Gets or sets the match type. - /// - [JsonPropertyName("matchType")] - public string? MatchType { get; set; } - } -} diff --git a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/MetadataDto.cs b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/MetadataDto.cs deleted file mode 100644 index 40faa493c..000000000 --- a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/MetadataDto.cs +++ /dev/null @@ -1,28 +0,0 @@ -using System.Text.Json.Serialization; - -namespace Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos -{ - /// - /// Metadata dto. - /// - public class MetadataDto - { - /// - /// Gets or sets the linup. - /// - [JsonPropertyName("lineup")] - public string? Lineup { get; set; } - - /// - /// Gets or sets the modified timestamp. - /// - [JsonPropertyName("modified")] - public string? Modified { get; set; } - - /// - /// Gets or sets the transport. - /// - [JsonPropertyName("transport")] - public string? Transport { get; set; } - } -} diff --git a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/MetadataProgramsDto.cs b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/MetadataProgramsDto.cs deleted file mode 100644 index 43f290156..000000000 --- a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/MetadataProgramsDto.cs +++ /dev/null @@ -1,16 +0,0 @@ -using System.Text.Json.Serialization; - -namespace Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos -{ - /// - /// Metadata programs dto. - /// - public class MetadataProgramsDto - { - /// - /// Gets or sets the gracenote object. - /// - [JsonPropertyName("Gracenote")] - public GracenoteDto? Gracenote { get; set; } - } -} diff --git a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/MetadataScheduleDto.cs b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/MetadataScheduleDto.cs deleted file mode 100644 index 04560ab55..000000000 --- a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/MetadataScheduleDto.cs +++ /dev/null @@ -1,41 +0,0 @@ -using System; -using System.Text.Json.Serialization; - -namespace Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos -{ - /// - /// Metadata schedule dto. - /// - public class MetadataScheduleDto - { - /// - /// Gets or sets the modified timestamp. - /// - [JsonPropertyName("modified")] - public string? Modified { get; set; } - - /// - /// Gets or sets the md5. - /// - [JsonPropertyName("md5")] - public string? Md5 { get; set; } - - /// - /// Gets or sets the start date. - /// - [JsonPropertyName("startDate")] - public DateTime? StartDate { get; set; } - - /// - /// Gets or sets the end date. - /// - [JsonPropertyName("endDate")] - public DateTime? EndDate { get; set; } - - /// - /// Gets or sets the days count. - /// - [JsonPropertyName("days")] - public int Days { get; set; } - } -} diff --git a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/MovieDto.cs b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/MovieDto.cs deleted file mode 100644 index 31bef423b..000000000 --- a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/MovieDto.cs +++ /dev/null @@ -1,30 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Text.Json.Serialization; - -namespace Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos -{ - /// - /// Movie dto. - /// - public class MovieDto - { - /// - /// Gets or sets the year. - /// - [JsonPropertyName("year")] - public string? Year { get; set; } - - /// - /// Gets or sets the duration. - /// - [JsonPropertyName("duration")] - public int Duration { get; set; } - - /// - /// Gets or sets the list of quality rating. - /// - [JsonPropertyName("qualityRating")] - public IReadOnlyList QualityRating { get; set; } = Array.Empty(); - } -} diff --git a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/MultipartDto.cs b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/MultipartDto.cs deleted file mode 100644 index e8b15dc07..000000000 --- a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/MultipartDto.cs +++ /dev/null @@ -1,22 +0,0 @@ -using System.Text.Json.Serialization; - -namespace Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos -{ - /// - /// Multipart dto. - /// - public class MultipartDto - { - /// - /// Gets or sets the part number. - /// - [JsonPropertyName("partNumber")] - public int PartNumber { get; set; } - - /// - /// Gets or sets the total parts. - /// - [JsonPropertyName("totalParts")] - public int TotalParts { get; set; } - } -} diff --git a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/ProgramDetailsDto.cs b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/ProgramDetailsDto.cs deleted file mode 100644 index 84c48f67f..000000000 --- a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/ProgramDetailsDto.cs +++ /dev/null @@ -1,156 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Text.Json.Serialization; - -namespace Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos -{ - /// - /// Program details dto. - /// - public class ProgramDetailsDto - { - /// - /// Gets or sets the audience. - /// - [JsonPropertyName("audience")] - public string? Audience { get; set; } - - /// - /// Gets or sets the program id. - /// - [JsonPropertyName("programID")] - public string? ProgramId { get; set; } - - /// - /// Gets or sets the list of titles. - /// - [JsonPropertyName("titles")] - public IReadOnlyList Titles { get; set; } = Array.Empty(); - - /// - /// Gets or sets the event details object. - /// - [JsonPropertyName("eventDetails")] - public EventDetailsDto? EventDetails { get; set; } - - /// - /// Gets or sets the descriptions. - /// - [JsonPropertyName("descriptions")] - public DescriptionsProgramDto? Descriptions { get; set; } - - /// - /// Gets or sets the original air date. - /// - [JsonPropertyName("originalAirDate")] - public DateTime? OriginalAirDate { get; set; } - - /// - /// Gets or sets the list of genres. - /// - [JsonPropertyName("genres")] - public IReadOnlyList Genres { get; set; } = Array.Empty(); - - /// - /// Gets or sets the episode title. - /// - [JsonPropertyName("episodeTitle150")] - public string? EpisodeTitle150 { get; set; } - - /// - /// Gets or sets the list of metadata. - /// - [JsonPropertyName("metadata")] - public IReadOnlyList Metadata { get; set; } = Array.Empty(); - - /// - /// Gets or sets the list of content raitings. - /// - [JsonPropertyName("contentRating")] - public IReadOnlyList ContentRating { get; set; } = Array.Empty(); - - /// - /// Gets or sets the list of cast. - /// - [JsonPropertyName("cast")] - public IReadOnlyList Cast { get; set; } = Array.Empty(); - - /// - /// Gets or sets the list of crew. - /// - [JsonPropertyName("crew")] - public IReadOnlyList Crew { get; set; } = Array.Empty(); - - /// - /// Gets or sets the entity type. - /// - [JsonPropertyName("entityType")] - public string? EntityType { get; set; } - - /// - /// Gets or sets the show type. - /// - [JsonPropertyName("showType")] - public string? ShowType { get; set; } - - /// - /// Gets or sets a value indicating whether there is image artwork. - /// - [JsonPropertyName("hasImageArtwork")] - public bool HasImageArtwork { get; set; } - - /// - /// Gets or sets the primary image. - /// - [JsonPropertyName("primaryImage")] - public string? PrimaryImage { get; set; } - - /// - /// Gets or sets the thumb image. - /// - [JsonPropertyName("thumbImage")] - public string? ThumbImage { get; set; } - - /// - /// Gets or sets the backdrop image. - /// - [JsonPropertyName("backdropImage")] - public string? BackdropImage { get; set; } - - /// - /// Gets or sets the banner image. - /// - [JsonPropertyName("bannerImage")] - public string? BannerImage { get; set; } - - /// - /// Gets or sets the image id. - /// - [JsonPropertyName("imageID")] - public string? ImageId { get; set; } - - /// - /// Gets or sets the md5. - /// - [JsonPropertyName("md5")] - public string? Md5 { get; set; } - - /// - /// Gets or sets the list of content advisory. - /// - [JsonPropertyName("contentAdvisory")] - public IReadOnlyList ContentAdvisory { get; set; } = Array.Empty(); - - /// - /// Gets or sets the movie object. - /// - [JsonPropertyName("movie")] - public MovieDto? Movie { get; set; } - - /// - /// Gets or sets the list of recommendations. - /// - [JsonPropertyName("recommendations")] - public IReadOnlyList Recommendations { get; set; } = Array.Empty(); - } -} diff --git a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/ProgramDto.cs b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/ProgramDto.cs deleted file mode 100644 index 60389b45b..000000000 --- a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/ProgramDto.cs +++ /dev/null @@ -1,90 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Text.Json.Serialization; - -namespace Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos -{ - /// - /// Program dto. - /// - public class ProgramDto - { - /// - /// Gets or sets the program id. - /// - [JsonPropertyName("programID")] - public string? ProgramId { get; set; } - - /// - /// Gets or sets the air date time. - /// - [JsonPropertyName("airDateTime")] - public DateTime? AirDateTime { get; set; } - - /// - /// Gets or sets the duration. - /// - [JsonPropertyName("duration")] - public int Duration { get; set; } - - /// - /// Gets or sets the md5. - /// - [JsonPropertyName("md5")] - public string? Md5 { get; set; } - - /// - /// Gets or sets the list of audio properties. - /// - [JsonPropertyName("audioProperties")] - public IReadOnlyList AudioProperties { get; set; } = Array.Empty(); - - /// - /// Gets or sets the list of video properties. - /// - [JsonPropertyName("videoProperties")] - public IReadOnlyList VideoProperties { get; set; } = Array.Empty(); - - /// - /// Gets or sets the list of ratings. - /// - [JsonPropertyName("ratings")] - public IReadOnlyList Ratings { get; set; } = Array.Empty(); - - /// - /// Gets or sets a value indicating whether this program is new. - /// - [JsonPropertyName("new")] - public bool? New { get; set; } - - /// - /// Gets or sets the multipart object. - /// - [JsonPropertyName("multipart")] - public MultipartDto? Multipart { get; set; } - - /// - /// Gets or sets the live tape delay. - /// - [JsonPropertyName("liveTapeDelay")] - public string? LiveTapeDelay { get; set; } - - /// - /// Gets or sets a value indicating whether this is the premiere. - /// - [JsonPropertyName("premiere")] - public bool Premiere { get; set; } - - /// - /// Gets or sets a value indicating whether this is a repeat. - /// - [JsonPropertyName("repeat")] - public bool Repeat { get; set; } - - /// - /// Gets or sets the premiere or finale. - /// - [JsonPropertyName("isPremiereOrFinale")] - public string? IsPremiereOrFinale { get; set; } - } -} diff --git a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/QualityRatingDto.cs b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/QualityRatingDto.cs deleted file mode 100644 index c5ddcf7c5..000000000 --- a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/QualityRatingDto.cs +++ /dev/null @@ -1,40 +0,0 @@ -using System.Text.Json.Serialization; - -namespace Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos -{ - /// - /// Quality rating dto. - /// - public class QualityRatingDto - { - /// - /// Gets or sets the ratings body. - /// - [JsonPropertyName("ratingsBody")] - public string? RatingsBody { get; set; } - - /// - /// Gets or sets the rating. - /// - [JsonPropertyName("rating")] - public string? Rating { get; set; } - - /// - /// Gets or sets the min rating. - /// - [JsonPropertyName("minRating")] - public string? MinRating { get; set; } - - /// - /// Gets or sets the max rating. - /// - [JsonPropertyName("maxRating")] - public string? MaxRating { get; set; } - - /// - /// Gets or sets the increment. - /// - [JsonPropertyName("increment")] - public string? Increment { get; set; } - } -} diff --git a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/RatingDto.cs b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/RatingDto.cs deleted file mode 100644 index e04b619a4..000000000 --- a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/RatingDto.cs +++ /dev/null @@ -1,22 +0,0 @@ -using System.Text.Json.Serialization; - -namespace Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos -{ - /// - /// Rating dto. - /// - public class RatingDto - { - /// - /// Gets or sets the body. - /// - [JsonPropertyName("body")] - public string? Body { get; set; } - - /// - /// Gets or sets the code. - /// - [JsonPropertyName("code")] - public string? Code { get; set; } - } -} diff --git a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/RecommendationDto.cs b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/RecommendationDto.cs deleted file mode 100644 index c8f79fd1c..000000000 --- a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/RecommendationDto.cs +++ /dev/null @@ -1,22 +0,0 @@ -using System.Text.Json.Serialization; - -namespace Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos -{ - /// - /// Recommendation dto. - /// - public class RecommendationDto - { - /// - /// Gets or sets the program id. - /// - [JsonPropertyName("programID")] - public string? ProgramId { get; set; } - - /// - /// Gets or sets the title. - /// - [JsonPropertyName("title120")] - public string? Title120 { get; set; } - } -} diff --git a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/RequestScheduleForChannelDto.cs b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/RequestScheduleForChannelDto.cs deleted file mode 100644 index 0cd05709b..000000000 --- a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/RequestScheduleForChannelDto.cs +++ /dev/null @@ -1,24 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Text.Json.Serialization; - -namespace Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos -{ - /// - /// Request schedule for channel dto. - /// - public class RequestScheduleForChannelDto - { - /// - /// Gets or sets the station id. - /// - [JsonPropertyName("stationID")] - public string? StationId { get; set; } - - /// - /// Gets or sets the list of dates. - /// - [JsonPropertyName("date")] - public IReadOnlyList Date { get; set; } = Array.Empty(); - } -} diff --git a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/ShowImagesDto.cs b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/ShowImagesDto.cs deleted file mode 100644 index 84e224b71..000000000 --- a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/ShowImagesDto.cs +++ /dev/null @@ -1,24 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Text.Json.Serialization; - -namespace Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos -{ - /// - /// Show image dto. - /// - public class ShowImagesDto - { - /// - /// Gets or sets the program id. - /// - [JsonPropertyName("programID")] - public string? ProgramId { get; set; } - - /// - /// Gets or sets the list of data. - /// - [JsonPropertyName("data")] - public IReadOnlyList Data { get; set; } = Array.Empty(); - } -} diff --git a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/StationDto.cs b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/StationDto.cs deleted file mode 100644 index d797fd49b..000000000 --- a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/StationDto.cs +++ /dev/null @@ -1,66 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Text.Json.Serialization; - -namespace Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos -{ - /// - /// Station dto. - /// - public class StationDto - { - /// - /// Gets or sets the station id. - /// - [JsonPropertyName("stationID")] - public string? StationId { get; set; } - - /// - /// Gets or sets the name. - /// - [JsonPropertyName("name")] - public string? Name { get; set; } - - /// - /// Gets or sets the callsign. - /// - [JsonPropertyName("callsign")] - public string? Callsign { get; set; } - - /// - /// Gets or sets the broadcast language. - /// - [JsonPropertyName("broadcastLanguage")] - public IReadOnlyList BroadcastLanguage { get; set; } = Array.Empty(); - - /// - /// Gets or sets the description language. - /// - [JsonPropertyName("descriptionLanguage")] - public IReadOnlyList DescriptionLanguage { get; set; } = Array.Empty(); - - /// - /// Gets or sets the broadcaster. - /// - [JsonPropertyName("broadcaster")] - public BroadcasterDto? Broadcaster { get; set; } - - /// - /// Gets or sets the affiliate. - /// - [JsonPropertyName("affiliate")] - public string? Affiliate { get; set; } - - /// - /// Gets or sets the logo. - /// - [JsonPropertyName("logo")] - public LogoDto? Logo { get; set; } - - /// - /// Gets or sets a value indicating whether it is commercial free. - /// - [JsonPropertyName("isCommercialFree")] - public bool? IsCommercialFree { get; set; } - } -} diff --git a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/TitleDto.cs b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/TitleDto.cs deleted file mode 100644 index 61cd4a9b0..000000000 --- a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/TitleDto.cs +++ /dev/null @@ -1,16 +0,0 @@ -using System.Text.Json.Serialization; - -namespace Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos -{ - /// - /// Title dto. - /// - public class TitleDto - { - /// - /// Gets or sets the title. - /// - [JsonPropertyName("title120")] - public string? Title120 { get; set; } - } -} diff --git a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/TokenDto.cs b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/TokenDto.cs deleted file mode 100644 index afb999486..000000000 --- a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirectDtos/TokenDto.cs +++ /dev/null @@ -1,47 +0,0 @@ -using System; -using System.Text.Json.Serialization; - -namespace Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos -{ - /// - /// The token dto. - /// - public class TokenDto - { - /// - /// Gets or sets the response code. - /// - [JsonPropertyName("code")] - public int Code { get; set; } - - /// - /// Gets or sets the response message. - /// - [JsonPropertyName("message")] - public string? Message { get; set; } - - /// - /// Gets or sets the server id. - /// - [JsonPropertyName("serverID")] - public string? ServerId { get; set; } - - /// - /// Gets or sets the token. - /// - [JsonPropertyName("token")] - public string? Token { get; set; } - - /// - /// Gets or sets the current datetime. - /// - [JsonPropertyName("datetime")] - public DateTime? TokenTimestamp { get; set; } - - /// - /// Gets or sets the response message. - /// - [JsonPropertyName("response")] - public string? Response { get; set; } - } -} diff --git a/Emby.Server.Implementations/LiveTv/Listings/XmlTvListingsProvider.cs b/Emby.Server.Implementations/LiveTv/Listings/XmlTvListingsProvider.cs deleted file mode 100644 index e60e9dcc1..000000000 --- a/Emby.Server.Implementations/LiveTv/Listings/XmlTvListingsProvider.cs +++ /dev/null @@ -1,267 +0,0 @@ -#nullable disable - -#pragma warning disable CS1591 - -using System; -using System.Collections.Generic; -using System.Globalization; -using System.IO; -using System.IO.Compression; -using System.Linq; -using System.Net.Http; -using System.Threading; -using System.Threading.Tasks; -using Jellyfin.Extensions; -using Jellyfin.XmlTv; -using Jellyfin.XmlTv.Entities; -using MediaBrowser.Common.Extensions; -using MediaBrowser.Common.Net; -using MediaBrowser.Controller.Configuration; -using MediaBrowser.Controller.LiveTv; -using MediaBrowser.Model.Dto; -using MediaBrowser.Model.IO; -using MediaBrowser.Model.LiveTv; -using Microsoft.Extensions.Logging; - -namespace Emby.Server.Implementations.LiveTv.Listings -{ - public class XmlTvListingsProvider : IListingsProvider - { - private static readonly TimeSpan _maxCacheAge = TimeSpan.FromHours(1); - - private readonly IServerConfigurationManager _config; - private readonly IHttpClientFactory _httpClientFactory; - private readonly ILogger _logger; - - public XmlTvListingsProvider( - IServerConfigurationManager config, - IHttpClientFactory httpClientFactory, - ILogger logger) - { - _config = config; - _httpClientFactory = httpClientFactory; - _logger = logger; - } - - public string Name => "XmlTV"; - - public string Type => "xmltv"; - - private string GetLanguage(ListingsProviderInfo info) - { - if (!string.IsNullOrWhiteSpace(info.PreferredLanguage)) - { - return info.PreferredLanguage; - } - - return _config.Configuration.PreferredMetadataLanguage; - } - - private async Task GetXml(ListingsProviderInfo info, CancellationToken cancellationToken) - { - _logger.LogInformation("xmltv path: {Path}", info.Path); - - string cacheFilename = info.Id + ".xml"; - string cacheFile = Path.Combine(_config.ApplicationPaths.CachePath, "xmltv", cacheFilename); - - if (File.Exists(cacheFile) && File.GetLastWriteTimeUtc(cacheFile) >= DateTime.UtcNow.Subtract(_maxCacheAge)) - { - return cacheFile; - } - - // Must check if file exists as parent directory may not exist. - if (File.Exists(cacheFile)) - { - File.Delete(cacheFile); - } - else - { - Directory.CreateDirectory(Path.GetDirectoryName(cacheFile)); - } - - if (info.Path.StartsWith("http", StringComparison.OrdinalIgnoreCase)) - { - _logger.LogInformation("Downloading xmltv listings from {Path}", info.Path); - - using var response = await _httpClientFactory.CreateClient(NamedClient.Default).GetAsync(info.Path, cancellationToken).ConfigureAwait(false); - var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); - await using (stream.ConfigureAwait(false)) - { - return await UnzipIfNeededAndCopy(info.Path, stream, cacheFile, cancellationToken).ConfigureAwait(false); - } - } - else - { - var stream = AsyncFile.OpenRead(info.Path); - await using (stream.ConfigureAwait(false)) - { - return await UnzipIfNeededAndCopy(info.Path, stream, cacheFile, cancellationToken).ConfigureAwait(false); - } - } - } - - private async Task UnzipIfNeededAndCopy(string originalUrl, Stream stream, string file, CancellationToken cancellationToken) - { - var fileStream = new FileStream( - file, - FileMode.CreateNew, - FileAccess.Write, - FileShare.None, - IODefaults.FileStreamBufferSize, - FileOptions.Asynchronous); - - await using (fileStream.ConfigureAwait(false)) - { - if (Path.GetExtension(originalUrl.AsSpan().LeftPart('?')).Equals(".gz", StringComparison.OrdinalIgnoreCase)) - { - try - { - using var reader = new GZipStream(stream, CompressionMode.Decompress); - await reader.CopyToAsync(fileStream, cancellationToken).ConfigureAwait(false); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error extracting from gz file {File}", originalUrl); - } - } - else - { - await stream.CopyToAsync(fileStream, cancellationToken).ConfigureAwait(false); - } - - return file; - } - } - - public async Task> GetProgramsAsync(ListingsProviderInfo info, string channelId, DateTime startDateUtc, DateTime endDateUtc, CancellationToken cancellationToken) - { - if (string.IsNullOrWhiteSpace(channelId)) - { - throw new ArgumentNullException(nameof(channelId)); - } - - _logger.LogDebug("Getting xmltv programs for channel {Id}", channelId); - - string path = await GetXml(info, cancellationToken).ConfigureAwait(false); - _logger.LogDebug("Opening XmlTvReader for {Path}", path); - var reader = new XmlTvReader(path, GetLanguage(info)); - - return reader.GetProgrammes(channelId, startDateUtc, endDateUtc, cancellationToken) - .Select(p => GetProgramInfo(p, info)); - } - - private static ProgramInfo GetProgramInfo(XmlTvProgram program, ListingsProviderInfo info) - { - string episodeTitle = program.Episode.Title; - var programCategories = program.Categories.Where(c => !string.IsNullOrWhiteSpace(c)).ToList(); - - var programInfo = new ProgramInfo - { - ChannelId = program.ChannelId, - EndDate = program.EndDate.UtcDateTime, - EpisodeNumber = program.Episode.Episode, - EpisodeTitle = episodeTitle, - Genres = programCategories, - StartDate = program.StartDate.UtcDateTime, - Name = program.Title, - Overview = program.Description, - ProductionYear = program.CopyrightDate?.Year, - SeasonNumber = program.Episode.Series, - IsSeries = program.Episode.Series is not null, - IsRepeat = program.IsPreviouslyShown && !program.IsNew, - IsPremiere = program.Premiere is not null, - IsKids = programCategories.Any(c => info.KidsCategories.Contains(c, StringComparison.OrdinalIgnoreCase)), - IsMovie = programCategories.Any(c => info.MovieCategories.Contains(c, StringComparison.OrdinalIgnoreCase)), - IsNews = programCategories.Any(c => info.NewsCategories.Contains(c, StringComparison.OrdinalIgnoreCase)), - IsSports = programCategories.Any(c => info.SportsCategories.Contains(c, StringComparison.OrdinalIgnoreCase)), - ImageUrl = string.IsNullOrEmpty(program.Icon?.Source) ? null : program.Icon.Source, - HasImage = !string.IsNullOrEmpty(program.Icon?.Source), - OfficialRating = string.IsNullOrEmpty(program.Rating?.Value) ? null : program.Rating.Value, - CommunityRating = program.StarRating, - SeriesId = program.Episode.Episode is null ? null : program.Title?.GetMD5().ToString("N", CultureInfo.InvariantCulture) - }; - - if (string.IsNullOrWhiteSpace(program.ProgramId)) - { - string uniqueString = (program.Title ?? string.Empty) + (episodeTitle ?? string.Empty); - - if (programInfo.SeasonNumber.HasValue) - { - uniqueString = "-" + programInfo.SeasonNumber.Value.ToString(CultureInfo.InvariantCulture); - } - - if (programInfo.EpisodeNumber.HasValue) - { - uniqueString = "-" + programInfo.EpisodeNumber.Value.ToString(CultureInfo.InvariantCulture); - } - - programInfo.ShowId = uniqueString.GetMD5().ToString("N", CultureInfo.InvariantCulture); - - // If we don't have valid episode info, assume it's a unique program, otherwise recordings might be skipped - if (programInfo.IsSeries - && !programInfo.IsRepeat - && (programInfo.EpisodeNumber ?? 0) == 0) - { - programInfo.ShowId += programInfo.StartDate.Ticks.ToString(CultureInfo.InvariantCulture); - } - } - else - { - programInfo.ShowId = program.ProgramId; - } - - // Construct an id from the channel and start date - programInfo.Id = string.Format(CultureInfo.InvariantCulture, "{0}_{1:O}", program.ChannelId, program.StartDate); - - if (programInfo.IsMovie) - { - programInfo.IsSeries = false; - programInfo.EpisodeNumber = null; - programInfo.EpisodeTitle = null; - } - - return programInfo; - } - - public Task Validate(ListingsProviderInfo info, bool validateLogin, bool validateListings) - { - // Assume all urls are valid. check files for existence - if (!info.Path.StartsWith("http", StringComparison.OrdinalIgnoreCase) && !File.Exists(info.Path)) - { - throw new FileNotFoundException("Could not find the XmlTv file specified:", info.Path); - } - - return Task.CompletedTask; - } - - public async Task> GetLineups(ListingsProviderInfo info, string country, string location) - { - // In theory this should never be called because there is always only one lineup - string path = await GetXml(info, CancellationToken.None).ConfigureAwait(false); - _logger.LogDebug("Opening XmlTvReader for {Path}", path); - var reader = new XmlTvReader(path, GetLanguage(info)); - IEnumerable results = reader.GetChannels(); - - // Should this method be async? - return results.Select(c => new NameIdPair() { Id = c.Id, Name = c.DisplayName }).ToList(); - } - - public async Task> GetChannels(ListingsProviderInfo info, CancellationToken cancellationToken) - { - // In theory this should never be called because there is always only one lineup - string path = await GetXml(info, cancellationToken).ConfigureAwait(false); - _logger.LogDebug("Opening XmlTvReader for {Path}", path); - var reader = new XmlTvReader(path, GetLanguage(info)); - var results = reader.GetChannels(); - - // Should this method be async? - return results.Select(c => new ChannelInfo - { - Id = c.Id, - Name = c.DisplayName, - ImageUrl = string.IsNullOrEmpty(c.Icon?.Source) ? null : c.Icon.Source, - Number = string.IsNullOrWhiteSpace(c.Number) ? c.Id : c.Number - }).ToList(); - } - } -} diff --git a/Emby.Server.Implementations/LiveTv/LiveTvConfigurationFactory.cs b/Emby.Server.Implementations/LiveTv/LiveTvConfigurationFactory.cs deleted file mode 100644 index 098f193fb..000000000 --- a/Emby.Server.Implementations/LiveTv/LiveTvConfigurationFactory.cs +++ /dev/null @@ -1,25 +0,0 @@ -using System.Collections.Generic; -using MediaBrowser.Common.Configuration; -using MediaBrowser.Model.LiveTv; - -namespace Emby.Server.Implementations.LiveTv -{ - /// - /// implementation for . - /// - public class LiveTvConfigurationFactory : IConfigurationFactory - { - /// - public IEnumerable GetConfigurations() - { - return new ConfigurationStore[] - { - new ConfigurationStore - { - ConfigurationType = typeof(LiveTvOptions), - Key = "livetv" - } - }; - } - } -} diff --git a/Emby.Server.Implementations/LiveTv/LiveTvDtoService.cs b/Emby.Server.Implementations/LiveTv/LiveTvDtoService.cs deleted file mode 100644 index 9326fbd5c..000000000 --- a/Emby.Server.Implementations/LiveTv/LiveTvDtoService.cs +++ /dev/null @@ -1,548 +0,0 @@ -#nullable disable - -#pragma warning disable CS1591 - -using System; -using System.Globalization; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; -using Jellyfin.Data.Enums; -using MediaBrowser.Common; -using MediaBrowser.Common.Extensions; -using MediaBrowser.Controller.Drawing; -using MediaBrowser.Controller.Dto; -using MediaBrowser.Controller.Entities; -using MediaBrowser.Controller.Library; -using MediaBrowser.Controller.LiveTv; -using MediaBrowser.Model.Dto; -using MediaBrowser.Model.Entities; -using MediaBrowser.Model.LiveTv; -using Microsoft.Extensions.Logging; - -namespace Emby.Server.Implementations.LiveTv -{ - public class LiveTvDtoService - { - private const string InternalVersionNumber = "4"; - - private const string ServiceName = "Emby"; - - private readonly ILogger _logger; - private readonly IImageProcessor _imageProcessor; - private readonly IDtoService _dtoService; - private readonly IApplicationHost _appHost; - private readonly ILibraryManager _libraryManager; - - public LiveTvDtoService( - IDtoService dtoService, - IImageProcessor imageProcessor, - ILogger logger, - IApplicationHost appHost, - ILibraryManager libraryManager) - { - _dtoService = dtoService; - _imageProcessor = imageProcessor; - _logger = logger; - _appHost = appHost; - _libraryManager = libraryManager; - } - - public TimerInfoDto GetTimerInfoDto(TimerInfo info, ILiveTvService service, LiveTvProgram program, BaseItem channel) - { - var dto = new TimerInfoDto - { - Id = GetInternalTimerId(info.Id), - Overview = info.Overview, - EndDate = info.EndDate, - Name = info.Name, - StartDate = info.StartDate, - ExternalId = info.Id, - ChannelId = GetInternalChannelId(service.Name, info.ChannelId), - Status = info.Status, - SeriesTimerId = string.IsNullOrEmpty(info.SeriesTimerId) ? null : GetInternalSeriesTimerId(info.SeriesTimerId).ToString("N", CultureInfo.InvariantCulture), - PrePaddingSeconds = info.PrePaddingSeconds, - PostPaddingSeconds = info.PostPaddingSeconds, - IsPostPaddingRequired = info.IsPostPaddingRequired, - IsPrePaddingRequired = info.IsPrePaddingRequired, - KeepUntil = info.KeepUntil, - ExternalChannelId = info.ChannelId, - ExternalSeriesTimerId = info.SeriesTimerId, - ServiceName = service.Name, - ExternalProgramId = info.ProgramId, - Priority = info.Priority, - RunTimeTicks = (info.EndDate - info.StartDate).Ticks, - ServerId = _appHost.SystemId - }; - - if (!string.IsNullOrEmpty(info.ProgramId)) - { - dto.ProgramId = GetInternalProgramId(info.ProgramId).ToString("N", CultureInfo.InvariantCulture); - } - - if (program is not null) - { - dto.ProgramInfo = _dtoService.GetBaseItemDto(program, new DtoOptions()); - - if (info.Status != RecordingStatus.Cancelled && info.Status != RecordingStatus.Error) - { - dto.ProgramInfo.TimerId = dto.Id; - dto.ProgramInfo.Status = info.Status.ToString(); - } - - dto.ProgramInfo.SeriesTimerId = dto.SeriesTimerId; - - if (!string.IsNullOrEmpty(info.SeriesTimerId)) - { - FillImages(dto.ProgramInfo, info.Name, info.SeriesId); - } - } - - if (channel is not null) - { - dto.ChannelName = channel.Name; - - if (channel.HasImage(ImageType.Primary)) - { - dto.ChannelPrimaryImageTag = GetImageTag(channel); - } - } - - return dto; - } - - public SeriesTimerInfoDto GetSeriesTimerInfoDto(SeriesTimerInfo info, ILiveTvService service, string channelName) - { - var dto = new SeriesTimerInfoDto - { - Id = GetInternalSeriesTimerId(info.Id).ToString("N", CultureInfo.InvariantCulture), - Overview = info.Overview, - EndDate = info.EndDate, - Name = info.Name, - StartDate = info.StartDate, - ExternalId = info.Id, - PrePaddingSeconds = info.PrePaddingSeconds, - PostPaddingSeconds = info.PostPaddingSeconds, - IsPostPaddingRequired = info.IsPostPaddingRequired, - IsPrePaddingRequired = info.IsPrePaddingRequired, - Days = info.Days.ToArray(), - Priority = info.Priority, - RecordAnyChannel = info.RecordAnyChannel, - RecordAnyTime = info.RecordAnyTime, - SkipEpisodesInLibrary = info.SkipEpisodesInLibrary, - KeepUpTo = info.KeepUpTo, - KeepUntil = info.KeepUntil, - RecordNewOnly = info.RecordNewOnly, - ExternalChannelId = info.ChannelId, - ExternalProgramId = info.ProgramId, - ServiceName = service.Name, - ChannelName = channelName, - ServerId = _appHost.SystemId - }; - - if (!string.IsNullOrEmpty(info.ChannelId)) - { - dto.ChannelId = GetInternalChannelId(service.Name, info.ChannelId); - } - - if (!string.IsNullOrEmpty(info.ProgramId)) - { - dto.ProgramId = GetInternalProgramId(info.ProgramId).ToString("N", CultureInfo.InvariantCulture); - } - - dto.DayPattern = info.Days is null ? null : GetDayPattern(info.Days.ToArray()); - - FillImages(dto, info.Name, info.SeriesId); - - return dto; - } - - private void FillImages(BaseItemDto dto, string seriesName, string programSeriesId) - { - var librarySeries = _libraryManager.GetItemList(new InternalItemsQuery - { - IncludeItemTypes = new[] { BaseItemKind.Series }, - Name = seriesName, - Limit = 1, - ImageTypes = new ImageType[] { ImageType.Thumb }, - DtoOptions = new DtoOptions(false) - }).FirstOrDefault(); - - if (librarySeries is not null) - { - var image = librarySeries.GetImageInfo(ImageType.Thumb, 0); - if (image is not null) - { - try - { - dto.ParentThumbImageTag = _imageProcessor.GetImageCacheTag(librarySeries, image); - dto.ParentThumbItemId = librarySeries.Id; - } - catch (Exception ex) - { - _logger.LogError(ex, "Error"); - } - } - - image = librarySeries.GetImageInfo(ImageType.Backdrop, 0); - if (image is not null) - { - try - { - dto.ParentBackdropImageTags = new string[] - { - _imageProcessor.GetImageCacheTag(librarySeries, image) - }; - dto.ParentBackdropItemId = librarySeries.Id; - } - catch (Exception ex) - { - _logger.LogError(ex, "Error"); - } - } - } - - var program = _libraryManager.GetItemList(new InternalItemsQuery - { - IncludeItemTypes = new[] { BaseItemKind.LiveTvProgram }, - ExternalSeriesId = programSeriesId, - Limit = 1, - ImageTypes = new ImageType[] { ImageType.Primary }, - DtoOptions = new DtoOptions(false), - Name = string.IsNullOrEmpty(programSeriesId) ? seriesName : null - }).FirstOrDefault(); - - if (program is not null) - { - var image = program.GetImageInfo(ImageType.Primary, 0); - if (image is not null) - { - try - { - dto.ParentPrimaryImageTag = _imageProcessor.GetImageCacheTag(program, image); - dto.ParentPrimaryImageItemId = program.Id.ToString("N", CultureInfo.InvariantCulture); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error"); - } - } - - if (dto.ParentBackdropImageTags is null || dto.ParentBackdropImageTags.Length == 0) - { - image = program.GetImageInfo(ImageType.Backdrop, 0); - if (image is not null) - { - try - { - dto.ParentBackdropImageTags = new string[] - { - _imageProcessor.GetImageCacheTag(program, image) - }; - - dto.ParentBackdropItemId = program.Id; - } - catch (Exception ex) - { - _logger.LogError(ex, "Error"); - } - } - } - } - } - - private void FillImages(SeriesTimerInfoDto dto, string seriesName, string programSeriesId) - { - var librarySeries = _libraryManager.GetItemList(new InternalItemsQuery - { - IncludeItemTypes = new[] { BaseItemKind.Series }, - Name = seriesName, - Limit = 1, - ImageTypes = new ImageType[] { ImageType.Thumb }, - DtoOptions = new DtoOptions(false) - }).FirstOrDefault(); - - if (librarySeries is not null) - { - var image = librarySeries.GetImageInfo(ImageType.Thumb, 0); - if (image is not null) - { - try - { - dto.ParentThumbImageTag = _imageProcessor.GetImageCacheTag(librarySeries, image); - dto.ParentThumbItemId = librarySeries.Id.ToString("N", CultureInfo.InvariantCulture); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error"); - } - } - - image = librarySeries.GetImageInfo(ImageType.Backdrop, 0); - if (image is not null) - { - try - { - dto.ParentBackdropImageTags = new string[] - { - _imageProcessor.GetImageCacheTag(librarySeries, image) - }; - dto.ParentBackdropItemId = librarySeries.Id.ToString("N", CultureInfo.InvariantCulture); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error"); - } - } - } - - var program = _libraryManager.GetItemList(new InternalItemsQuery - { - IncludeItemTypes = new[] { BaseItemKind.Series }, - Name = seriesName, - Limit = 1, - ImageTypes = new ImageType[] { ImageType.Primary }, - DtoOptions = new DtoOptions(false) - }).FirstOrDefault(); - - if (program is null) - { - program = _libraryManager.GetItemList(new InternalItemsQuery - { - IncludeItemTypes = new[] { BaseItemKind.LiveTvProgram }, - ExternalSeriesId = programSeriesId, - Limit = 1, - ImageTypes = new ImageType[] { ImageType.Primary }, - DtoOptions = new DtoOptions(false), - Name = string.IsNullOrEmpty(programSeriesId) ? seriesName : null - }).FirstOrDefault(); - } - - if (program is not null) - { - var image = program.GetImageInfo(ImageType.Primary, 0); - if (image is not null) - { - try - { - dto.ParentPrimaryImageTag = _imageProcessor.GetImageCacheTag(program, image); - dto.ParentPrimaryImageItemId = program.Id.ToString("N", CultureInfo.InvariantCulture); - } - catch (Exception ex) - { - _logger.LogDebug(ex, "GetImageCacheTag raised an exception in LiveTvDtoService.FillImages."); - } - } - - if (dto.ParentBackdropImageTags is null || dto.ParentBackdropImageTags.Length == 0) - { - image = program.GetImageInfo(ImageType.Backdrop, 0); - if (image is not null) - { - try - { - dto.ParentBackdropImageTags = new[] - { - _imageProcessor.GetImageCacheTag(program, image) - }; - dto.ParentBackdropItemId = program.Id.ToString("N", CultureInfo.InvariantCulture); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error"); - } - } - } - } - } - - public DayPattern? GetDayPattern(DayOfWeek[] days) - { - DayPattern? pattern = null; - - if (days.Length > 0) - { - if (days.Length == 7) - { - pattern = DayPattern.Daily; - } - else if (days.Length == 2) - { - if (days.Contains(DayOfWeek.Saturday) && days.Contains(DayOfWeek.Sunday)) - { - pattern = DayPattern.Weekends; - } - } - else if (days.Length == 5) - { - if (days.Contains(DayOfWeek.Monday) && days.Contains(DayOfWeek.Tuesday) && days.Contains(DayOfWeek.Wednesday) && days.Contains(DayOfWeek.Thursday) && days.Contains(DayOfWeek.Friday)) - { - pattern = DayPattern.Weekdays; - } - } - } - - return pattern; - } - - internal string GetImageTag(BaseItem info) - { - try - { - return _imageProcessor.GetImageCacheTag(info, ImageType.Primary); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error getting image info for {Name}", info.Name); - } - - return null; - } - - public Guid GetInternalChannelId(string serviceName, string externalId) - { - var name = serviceName + externalId + InternalVersionNumber; - - return _libraryManager.GetNewItemId(name.ToLowerInvariant(), typeof(LiveTvChannel)); - } - - public string GetInternalTimerId(string externalId) - { - var name = ServiceName + externalId + InternalVersionNumber; - - return name.ToLowerInvariant().GetMD5().ToString("N", CultureInfo.InvariantCulture); - } - - public Guid GetInternalSeriesTimerId(string externalId) - { - var name = ServiceName + externalId + InternalVersionNumber; - - return name.ToLowerInvariant().GetMD5(); - } - - public Guid GetInternalProgramId(string externalId) - { - var name = ServiceName + externalId + InternalVersionNumber; - - return _libraryManager.GetNewItemId(name.ToLowerInvariant(), typeof(LiveTvProgram)); - } - - public async Task GetTimerInfo(TimerInfoDto dto, bool isNew, LiveTvManager liveTv, CancellationToken cancellationToken) - { - var info = new TimerInfo - { - Overview = dto.Overview, - EndDate = dto.EndDate, - Name = dto.Name, - StartDate = dto.StartDate, - Status = dto.Status, - PrePaddingSeconds = dto.PrePaddingSeconds, - PostPaddingSeconds = dto.PostPaddingSeconds, - IsPostPaddingRequired = dto.IsPostPaddingRequired, - IsPrePaddingRequired = dto.IsPrePaddingRequired, - KeepUntil = dto.KeepUntil, - Priority = dto.Priority, - SeriesTimerId = dto.ExternalSeriesTimerId, - ProgramId = dto.ExternalProgramId, - ChannelId = dto.ExternalChannelId, - Id = dto.ExternalId - }; - - // Convert internal server id's to external tv provider id's - if (!isNew && !string.IsNullOrEmpty(dto.Id) && string.IsNullOrEmpty(info.Id)) - { - var timer = await liveTv.GetSeriesTimer(dto.Id, cancellationToken).ConfigureAwait(false); - - info.Id = timer.ExternalId; - } - - if (!dto.ChannelId.Equals(default) && string.IsNullOrEmpty(info.ChannelId)) - { - var channel = _libraryManager.GetItemById(dto.ChannelId); - - if (channel is not null) - { - info.ChannelId = channel.ExternalId; - } - } - - if (!string.IsNullOrEmpty(dto.ProgramId) && string.IsNullOrEmpty(info.ProgramId)) - { - var program = _libraryManager.GetItemById(dto.ProgramId); - - if (program is not null) - { - info.ProgramId = program.ExternalId; - } - } - - if (!string.IsNullOrEmpty(dto.SeriesTimerId) && string.IsNullOrEmpty(info.SeriesTimerId)) - { - var timer = await liveTv.GetSeriesTimer(dto.SeriesTimerId, cancellationToken).ConfigureAwait(false); - - if (timer is not null) - { - info.SeriesTimerId = timer.ExternalId; - } - } - - return info; - } - - public async Task GetSeriesTimerInfo(SeriesTimerInfoDto dto, bool isNew, LiveTvManager liveTv, CancellationToken cancellationToken) - { - var info = new SeriesTimerInfo - { - Overview = dto.Overview, - EndDate = dto.EndDate, - Name = dto.Name, - StartDate = dto.StartDate, - PrePaddingSeconds = dto.PrePaddingSeconds, - PostPaddingSeconds = dto.PostPaddingSeconds, - IsPostPaddingRequired = dto.IsPostPaddingRequired, - IsPrePaddingRequired = dto.IsPrePaddingRequired, - Days = dto.Days.ToList(), - Priority = dto.Priority, - RecordAnyChannel = dto.RecordAnyChannel, - RecordAnyTime = dto.RecordAnyTime, - SkipEpisodesInLibrary = dto.SkipEpisodesInLibrary, - KeepUpTo = dto.KeepUpTo, - KeepUntil = dto.KeepUntil, - RecordNewOnly = dto.RecordNewOnly, - ProgramId = dto.ExternalProgramId, - ChannelId = dto.ExternalChannelId, - Id = dto.ExternalId - }; - - // Convert internal server id's to external tv provider id's - if (!isNew && !string.IsNullOrEmpty(dto.Id) && string.IsNullOrEmpty(info.Id)) - { - var timer = await liveTv.GetSeriesTimer(dto.Id, cancellationToken).ConfigureAwait(false); - - info.Id = timer.ExternalId; - } - - if (!dto.ChannelId.Equals(default) && string.IsNullOrEmpty(info.ChannelId)) - { - var channel = _libraryManager.GetItemById(dto.ChannelId); - - if (channel is not null) - { - info.ChannelId = channel.ExternalId; - } - } - - if (!string.IsNullOrEmpty(dto.ProgramId) && string.IsNullOrEmpty(info.ProgramId)) - { - var program = _libraryManager.GetItemById(dto.ProgramId); - - if (program is not null) - { - info.ProgramId = program.ExternalId; - } - } - - return info; - } - } -} diff --git a/Emby.Server.Implementations/LiveTv/LiveTvManager.cs b/Emby.Server.Implementations/LiveTv/LiveTvManager.cs deleted file mode 100644 index 426165de6..000000000 --- a/Emby.Server.Implementations/LiveTv/LiveTvManager.cs +++ /dev/null @@ -1,2408 +0,0 @@ -#nullable disable - -#pragma warning disable CS1591 - -using System; -using System.Collections.Generic; -using System.Globalization; -using System.Linq; -using System.Text.Json; -using System.Threading; -using System.Threading.Tasks; -using Emby.Server.Implementations.Library; -using Jellyfin.Data.Entities; -using Jellyfin.Data.Enums; -using Jellyfin.Data.Events; -using MediaBrowser.Common.Configuration; -using MediaBrowser.Common.Extensions; -using MediaBrowser.Common.Progress; -using MediaBrowser.Controller.Channels; -using MediaBrowser.Controller.Configuration; -using MediaBrowser.Controller.Dto; -using MediaBrowser.Controller.Entities; -using MediaBrowser.Controller.Library; -using MediaBrowser.Controller.LiveTv; -using MediaBrowser.Controller.Persistence; -using MediaBrowser.Controller.Providers; -using MediaBrowser.Controller.Sorting; -using MediaBrowser.Model.Dto; -using MediaBrowser.Model.Entities; -using MediaBrowser.Model.Globalization; -using MediaBrowser.Model.IO; -using MediaBrowser.Model.LiveTv; -using MediaBrowser.Model.Querying; -using MediaBrowser.Model.Tasks; -using Microsoft.Extensions.Logging; - -namespace Emby.Server.Implementations.LiveTv -{ - /// - /// Class LiveTvManager. - /// - public class LiveTvManager : ILiveTvManager - { - private const int MaxGuideDays = 14; - private const string ExternalServiceTag = "ExternalServiceId"; - - private const string EtagKey = "ProgramEtag"; - - private readonly IServerConfigurationManager _config; - private readonly ILogger _logger; - private readonly IItemRepository _itemRepo; - private readonly IUserManager _userManager; - private readonly IDtoService _dtoService; - private readonly IUserDataManager _userDataManager; - private readonly ILibraryManager _libraryManager; - private readonly ITaskManager _taskManager; - private readonly ILocalizationManager _localization; - private readonly IFileSystem _fileSystem; - private readonly IChannelManager _channelManager; - private readonly LiveTvDtoService _tvDtoService; - - private ILiveTvService[] _services = Array.Empty(); - private ITunerHost[] _tunerHosts = Array.Empty(); - private IListingsProvider[] _listingProviders = Array.Empty(); - - public LiveTvManager( - IServerConfigurationManager config, - ILogger logger, - IItemRepository itemRepo, - IUserDataManager userDataManager, - IDtoService dtoService, - IUserManager userManager, - ILibraryManager libraryManager, - ITaskManager taskManager, - ILocalizationManager localization, - IFileSystem fileSystem, - IChannelManager channelManager, - LiveTvDtoService liveTvDtoService) - { - _config = config; - _logger = logger; - _itemRepo = itemRepo; - _userManager = userManager; - _libraryManager = libraryManager; - _taskManager = taskManager; - _localization = localization; - _fileSystem = fileSystem; - _dtoService = dtoService; - _userDataManager = userDataManager; - _channelManager = channelManager; - _tvDtoService = liveTvDtoService; - } - - public event EventHandler> SeriesTimerCancelled; - - public event EventHandler> TimerCancelled; - - public event EventHandler> TimerCreated; - - public event EventHandler> SeriesTimerCreated; - - /// - /// Gets the services. - /// - /// The services. - public IReadOnlyList Services => _services; - - public IReadOnlyList TunerHosts => _tunerHosts; - - public IReadOnlyList ListingProviders => _listingProviders; - - private LiveTvOptions GetConfiguration() - { - return _config.GetConfiguration("livetv"); - } - - public string GetEmbyTvActiveRecordingPath(string id) - { - return EmbyTV.EmbyTV.Current.GetActiveRecordingPath(id); - } - - /// - /// Adds the parts. - /// - /// The services. - /// The tuner hosts. - /// The listing providers. - public void AddParts(IEnumerable services, IEnumerable tunerHosts, IEnumerable listingProviders) - { - _services = services.ToArray(); - _tunerHosts = tunerHosts.Where(i => i.IsSupported).ToArray(); - - _listingProviders = listingProviders.ToArray(); - - foreach (var service in _services) - { - if (service is EmbyTV.EmbyTV embyTv) - { - embyTv.TimerCreated += OnEmbyTvTimerCreated; - embyTv.TimerCancelled += OnEmbyTvTimerCancelled; - } - } - } - - private void OnEmbyTvTimerCancelled(object sender, GenericEventArgs e) - { - var timerId = e.Argument; - - TimerCancelled?.Invoke(this, new GenericEventArgs(new TimerEventInfo(timerId))); - } - - private void OnEmbyTvTimerCreated(object sender, GenericEventArgs e) - { - var timer = e.Argument; - - TimerCreated?.Invoke(this, new GenericEventArgs( - new TimerEventInfo(timer.Id) - { - ProgramId = _tvDtoService.GetInternalProgramId(timer.ProgramId) - })); - } - - public List GetTunerHostTypes() - { - return _tunerHosts.OrderBy(i => i.Name).Select(i => new NameIdPair - { - Name = i.Name, - Id = i.Type - }).ToList(); - } - - public Task> DiscoverTuners(bool newDevicesOnly, CancellationToken cancellationToken) - { - return EmbyTV.EmbyTV.Current.DiscoverTuners(newDevicesOnly, cancellationToken); - } - - public QueryResult GetInternalChannels(LiveTvChannelQuery query, DtoOptions dtoOptions, CancellationToken cancellationToken) - { - var user = query.UserId.Equals(default) - ? null - : _userManager.GetUserById(query.UserId); - - var topFolder = GetInternalLiveTvFolder(cancellationToken); - - var internalQuery = new InternalItemsQuery(user) - { - IsMovie = query.IsMovie, - IsNews = query.IsNews, - IsKids = query.IsKids, - IsSports = query.IsSports, - IsSeries = query.IsSeries, - IncludeItemTypes = new[] { BaseItemKind.LiveTvChannel }, - TopParentIds = new[] { topFolder.Id }, - IsFavorite = query.IsFavorite, - IsLiked = query.IsLiked, - StartIndex = query.StartIndex, - Limit = query.Limit, - DtoOptions = dtoOptions - }; - - var orderBy = internalQuery.OrderBy.ToList(); - - orderBy.AddRange(query.SortBy.Select(i => (i, query.SortOrder ?? SortOrder.Ascending))); - - if (query.EnableFavoriteSorting) - { - orderBy.Insert(0, (ItemSortBy.IsFavoriteOrLiked, SortOrder.Descending)); - } - - if (internalQuery.OrderBy.All(i => i.OrderBy != ItemSortBy.SortName)) - { - orderBy.Add((ItemSortBy.SortName, SortOrder.Ascending)); - } - - internalQuery.OrderBy = orderBy.ToArray(); - - return _libraryManager.GetItemsResult(internalQuery); - } - - public async Task> GetChannelStream(string id, string mediaSourceId, List currentLiveStreams, CancellationToken cancellationToken) - { - if (string.Equals(id, mediaSourceId, StringComparison.OrdinalIgnoreCase)) - { - mediaSourceId = null; - } - - var channel = (LiveTvChannel)_libraryManager.GetItemById(id); - - bool isVideo = channel.ChannelType == ChannelType.TV; - var service = GetService(channel); - _logger.LogInformation("Opening channel stream from {0}, external channel Id: {1}", service.Name, channel.ExternalId); - - MediaSourceInfo info; - ILiveStream liveStream; - if (service is ISupportsDirectStreamProvider supportsManagedStream) - { - liveStream = await supportsManagedStream.GetChannelStreamWithDirectStreamProvider(channel.ExternalId, mediaSourceId, currentLiveStreams, cancellationToken).ConfigureAwait(false); - info = liveStream.MediaSource; - } - else - { - info = await service.GetChannelStream(channel.ExternalId, mediaSourceId, cancellationToken).ConfigureAwait(false); - var openedId = info.Id; - Func closeFn = () => service.CloseLiveStream(openedId, CancellationToken.None); - - liveStream = new ExclusiveLiveStream(info, closeFn); - - var startTime = DateTime.UtcNow; - await liveStream.Open(cancellationToken).ConfigureAwait(false); - var endTime = DateTime.UtcNow; - _logger.LogInformation("Live stream opened after {0}ms", (endTime - startTime).TotalMilliseconds); - } - - info.RequiresClosing = true; - - var idPrefix = service.GetType().FullName.GetMD5().ToString("N", CultureInfo.InvariantCulture) + "_"; - - info.LiveStreamId = idPrefix + info.Id; - - Normalize(info, service, isVideo); - - return new Tuple(info, liveStream); - } - - public async Task> GetChannelMediaSources(BaseItem item, CancellationToken cancellationToken) - { - var baseItem = (LiveTvChannel)item; - var service = GetService(baseItem); - - var sources = await service.GetChannelStreamMediaSources(baseItem.ExternalId, cancellationToken).ConfigureAwait(false); - - if (sources.Count == 0) - { - throw new NotImplementedException(); - } - - foreach (var source in sources) - { - Normalize(source, service, baseItem.ChannelType == ChannelType.TV); - } - - return sources; - } - - private ILiveTvService GetService(LiveTvChannel item) - { - var name = item.ServiceName; - return GetService(name); - } - - private ILiveTvService GetService(LiveTvProgram item) - { - var channel = _libraryManager.GetItemById(item.ChannelId) as LiveTvChannel; - - return GetService(channel); - } - - private ILiveTvService GetService(string name) - => Array.Find(_services, x => string.Equals(x.Name, name, StringComparison.OrdinalIgnoreCase)) - ?? throw new KeyNotFoundException( - string.Format( - CultureInfo.InvariantCulture, - "No service with the name '{0}' can be found.", - name)); - - private static void Normalize(MediaSourceInfo mediaSource, ILiveTvService service, bool isVideo) - { - // Not all of the plugins are setting this - mediaSource.IsInfiniteStream = true; - - if (mediaSource.MediaStreams.Count == 0) - { - if (isVideo) - { - mediaSource.MediaStreams = new MediaStream[] - { - new MediaStream - { - Type = MediaStreamType.Video, - // Set the index to -1 because we don't know the exact index of the video stream within the container - Index = -1, - - // Set to true if unknown to enable deinterlacing - IsInterlaced = true - }, - new MediaStream - { - Type = MediaStreamType.Audio, - // Set the index to -1 because we don't know the exact index of the audio stream within the container - Index = -1 - } - }; - } - else - { - mediaSource.MediaStreams = new MediaStream[] - { - new MediaStream - { - Type = MediaStreamType.Audio, - // Set the index to -1 because we don't know the exact index of the audio stream within the container - Index = -1 - } - }; - } - } - - // Clean some bad data coming from providers - foreach (var stream in mediaSource.MediaStreams) - { - if (stream.BitRate.HasValue && stream.BitRate <= 0) - { - stream.BitRate = null; - } - - if (stream.Channels.HasValue && stream.Channels <= 0) - { - stream.Channels = null; - } - - if (stream.AverageFrameRate.HasValue && stream.AverageFrameRate <= 0) - { - stream.AverageFrameRate = null; - } - - if (stream.RealFrameRate.HasValue && stream.RealFrameRate <= 0) - { - stream.RealFrameRate = null; - } - - if (stream.Width.HasValue && stream.Width <= 0) - { - stream.Width = null; - } - - if (stream.Height.HasValue && stream.Height <= 0) - { - stream.Height = null; - } - - if (stream.SampleRate.HasValue && stream.SampleRate <= 0) - { - stream.SampleRate = null; - } - - if (stream.Level.HasValue && stream.Level <= 0) - { - stream.Level = null; - } - } - - var indexes = mediaSource.MediaStreams.Select(i => i.Index).Distinct().ToList(); - - // If there are duplicate stream indexes, set them all to unknown - if (indexes.Count != mediaSource.MediaStreams.Count) - { - foreach (var stream in mediaSource.MediaStreams) - { - stream.Index = -1; - } - } - - // Set the total bitrate if not already supplied - mediaSource.InferTotalBitrate(); - - if (service is not EmbyTV.EmbyTV) - { - // We can't trust that we'll be able to direct stream it through emby server, no matter what the provider says - // mediaSource.SupportsDirectPlay = false; - // mediaSource.SupportsDirectStream = false; - mediaSource.SupportsTranscoding = true; - foreach (var stream in mediaSource.MediaStreams) - { - if (stream.Type == MediaStreamType.Video && string.IsNullOrWhiteSpace(stream.NalLengthSize)) - { - stream.NalLengthSize = "0"; - } - - if (stream.Type == MediaStreamType.Video) - { - stream.IsInterlaced = true; - } - } - } - } - - private async Task GetChannelAsync(ChannelInfo channelInfo, string serviceName, BaseItem parentFolder, CancellationToken cancellationToken) - { - var parentFolderId = parentFolder.Id; - var isNew = false; - var forceUpdate = false; - - var id = _tvDtoService.GetInternalChannelId(serviceName, channelInfo.Id); - - var item = _libraryManager.GetItemById(id) as LiveTvChannel; - - if (item is null) - { - item = new LiveTvChannel - { - Name = channelInfo.Name, - Id = id, - DateCreated = DateTime.UtcNow - }; - - isNew = true; - } - - if (channelInfo.Tags is not null) - { - if (!channelInfo.Tags.SequenceEqual(item.Tags, StringComparer.OrdinalIgnoreCase)) - { - isNew = true; - } - - item.Tags = channelInfo.Tags; - } - - if (!item.ParentId.Equals(parentFolderId)) - { - isNew = true; - } - - item.ParentId = parentFolderId; - - item.ChannelType = channelInfo.ChannelType; - item.ServiceName = serviceName; - - if (!string.Equals(item.GetProviderId(ExternalServiceTag), serviceName, StringComparison.OrdinalIgnoreCase)) - { - forceUpdate = true; - } - - item.SetProviderId(ExternalServiceTag, serviceName); - - if (!string.Equals(channelInfo.Id, item.ExternalId, StringComparison.Ordinal)) - { - forceUpdate = true; - } - - item.ExternalId = channelInfo.Id; - - if (!string.Equals(channelInfo.Number, item.Number, StringComparison.Ordinal)) - { - forceUpdate = true; - } - - item.Number = channelInfo.Number; - - if (!string.Equals(channelInfo.Name, item.Name, StringComparison.Ordinal)) - { - forceUpdate = true; - } - - item.Name = channelInfo.Name; - - if (!item.HasImage(ImageType.Primary)) - { - if (!string.IsNullOrWhiteSpace(channelInfo.ImagePath)) - { - item.SetImagePath(ImageType.Primary, channelInfo.ImagePath); - forceUpdate = true; - } - else if (!string.IsNullOrWhiteSpace(channelInfo.ImageUrl)) - { - item.SetImagePath(ImageType.Primary, channelInfo.ImageUrl); - forceUpdate = true; - } - } - - if (isNew) - { - _libraryManager.CreateItem(item, parentFolder); - } - else if (forceUpdate) - { - await _libraryManager.UpdateItemAsync(item, parentFolder, ItemUpdateType.MetadataImport, cancellationToken).ConfigureAwait(false); - } - - return item; - } - - private (LiveTvProgram Item, bool IsNew, bool IsUpdated) GetProgram(ProgramInfo info, Dictionary allExistingPrograms, LiveTvChannel channel) - { - var id = _tvDtoService.GetInternalProgramId(info.Id); - - var isNew = false; - var forceUpdate = false; - - if (!allExistingPrograms.TryGetValue(id, out LiveTvProgram item)) - { - isNew = true; - item = new LiveTvProgram - { - Name = info.Name, - Id = id, - DateCreated = DateTime.UtcNow, - DateModified = DateTime.UtcNow - }; - - if (!string.IsNullOrEmpty(info.Etag)) - { - item.SetProviderId(EtagKey, info.Etag); - } - } - - if (!string.Equals(info.ShowId, item.ShowId, StringComparison.OrdinalIgnoreCase)) - { - item.ShowId = info.ShowId; - forceUpdate = true; - } - - var seriesId = info.SeriesId; - - if (!item.ParentId.Equals(channel.Id)) - { - forceUpdate = true; - } - - item.ParentId = channel.Id; - - item.Audio = info.Audio; - item.ChannelId = channel.Id; - item.CommunityRating ??= info.CommunityRating; - if ((item.CommunityRating ?? 0).Equals(0)) - { - item.CommunityRating = null; - } - - item.EpisodeTitle = info.EpisodeTitle; - item.ExternalId = info.Id; - - if (!string.IsNullOrWhiteSpace(seriesId) && !string.Equals(item.ExternalSeriesId, seriesId, StringComparison.Ordinal)) - { - forceUpdate = true; - } - - item.ExternalSeriesId = seriesId; - - var isSeries = info.IsSeries || !string.IsNullOrEmpty(info.EpisodeTitle); - - if (isSeries || !string.IsNullOrEmpty(info.EpisodeTitle)) - { - item.SeriesName = info.Name; - } - - var tags = new List(); - if (info.IsLive) - { - tags.Add("Live"); - } - - if (info.IsPremiere) - { - tags.Add("Premiere"); - } - - if (info.IsNews) - { - tags.Add("News"); - } - - if (info.IsSports) - { - tags.Add("Sports"); - } - - if (info.IsKids) - { - tags.Add("Kids"); - } - - if (info.IsRepeat) - { - tags.Add("Repeat"); - } - - if (info.IsMovie) - { - tags.Add("Movie"); - } - - if (isSeries) - { - tags.Add("Series"); - } - - item.Tags = tags.ToArray(); - - item.Genres = info.Genres.ToArray(); - - if (info.IsHD ?? false) - { - item.Width = 1280; - item.Height = 720; - } - - item.IsMovie = info.IsMovie; - item.IsRepeat = info.IsRepeat; - - if (item.IsSeries != isSeries) - { - forceUpdate = true; - } - - item.IsSeries = isSeries; - - item.Name = info.Name; - item.OfficialRating ??= info.OfficialRating; - item.Overview ??= info.Overview; - item.RunTimeTicks = (info.EndDate - info.StartDate).Ticks; - item.ProviderIds = info.ProviderIds; - - foreach (var providerId in info.SeriesProviderIds) - { - info.ProviderIds["Series" + providerId.Key] = providerId.Value; - } - - if (item.StartDate != info.StartDate) - { - forceUpdate = true; - } - - item.StartDate = info.StartDate; - - if (item.EndDate != info.EndDate) - { - forceUpdate = true; - } - - item.EndDate = info.EndDate; - - item.ProductionYear = info.ProductionYear; - - if (!isSeries || info.IsRepeat) - { - item.PremiereDate = info.OriginalAirDate; - } - - item.IndexNumber = info.EpisodeNumber; - item.ParentIndexNumber = info.SeasonNumber; - - if (!item.HasImage(ImageType.Primary)) - { - if (!string.IsNullOrWhiteSpace(info.ImagePath)) - { - item.SetImage( - new ItemImageInfo - { - Path = info.ImagePath, - Type = ImageType.Primary - }, - 0); - } - else if (!string.IsNullOrWhiteSpace(info.ImageUrl)) - { - item.SetImage( - new ItemImageInfo - { - Path = info.ImageUrl, - Type = ImageType.Primary - }, - 0); - } - } - - if (!item.HasImage(ImageType.Thumb)) - { - if (!string.IsNullOrWhiteSpace(info.ThumbImageUrl)) - { - item.SetImage( - new ItemImageInfo - { - Path = info.ThumbImageUrl, - Type = ImageType.Thumb - }, - 0); - } - } - - if (!item.HasImage(ImageType.Logo)) - { - if (!string.IsNullOrWhiteSpace(info.LogoImageUrl)) - { - item.SetImage( - new ItemImageInfo - { - Path = info.LogoImageUrl, - Type = ImageType.Logo - }, - 0); - } - } - - if (!item.HasImage(ImageType.Backdrop)) - { - if (!string.IsNullOrWhiteSpace(info.BackdropImageUrl)) - { - item.SetImage( - new ItemImageInfo - { - Path = info.BackdropImageUrl, - Type = ImageType.Backdrop - }, - 0); - } - } - - var isUpdated = false; - if (isNew) - { - } - else if (forceUpdate || string.IsNullOrWhiteSpace(info.Etag)) - { - isUpdated = true; - } - else - { - var etag = info.Etag; - - if (!string.Equals(etag, item.GetProviderId(EtagKey), StringComparison.OrdinalIgnoreCase)) - { - item.SetProviderId(EtagKey, etag); - isUpdated = true; - } - } - - if (isNew || isUpdated) - { - item.OnMetadataChanged(); - } - - return (item, isNew, isUpdated); - } - - public async Task GetProgram(string id, CancellationToken cancellationToken, User user = null) - { - var program = _libraryManager.GetItemById(id); - - var dto = _dtoService.GetBaseItemDto(program, new DtoOptions(), user); - - var list = new List<(BaseItemDto ItemDto, string ExternalId, string ExternalSeriesId)> - { - (dto, program.ExternalId, program.ExternalSeriesId) - }; - - await AddRecordingInfo(list, cancellationToken).ConfigureAwait(false); - - return dto; - } - - public async Task> GetPrograms(InternalItemsQuery query, DtoOptions options, CancellationToken cancellationToken) - { - var user = query.User; - - var topFolder = GetInternalLiveTvFolder(cancellationToken); - - if (query.OrderBy.Count == 0) - { - // Unless something else was specified, order by start date to take advantage of a specialized index - query.OrderBy = new[] - { - (ItemSortBy.StartDate, SortOrder.Ascending) - }; - } - - RemoveFields(options); - - var internalQuery = new InternalItemsQuery(user) - { - IncludeItemTypes = new[] { BaseItemKind.LiveTvProgram }, - MinEndDate = query.MinEndDate, - MinStartDate = query.MinStartDate, - MaxEndDate = query.MaxEndDate, - MaxStartDate = query.MaxStartDate, - ChannelIds = query.ChannelIds, - IsMovie = query.IsMovie, - IsSeries = query.IsSeries, - IsSports = query.IsSports, - IsKids = query.IsKids, - IsNews = query.IsNews, - Genres = query.Genres, - GenreIds = query.GenreIds, - StartIndex = query.StartIndex, - Limit = query.Limit, - OrderBy = query.OrderBy, - EnableTotalRecordCount = query.EnableTotalRecordCount, - TopParentIds = new[] { topFolder.Id }, - Name = query.Name, - DtoOptions = options, - HasAired = query.HasAired, - IsAiring = query.IsAiring - }; - - if (!string.IsNullOrWhiteSpace(query.SeriesTimerId)) - { - var seriesTimers = await GetSeriesTimersInternal(new SeriesTimerQuery(), cancellationToken).ConfigureAwait(false); - var seriesTimer = seriesTimers.Items.FirstOrDefault(i => string.Equals(_tvDtoService.GetInternalSeriesTimerId(i.Id).ToString("N", CultureInfo.InvariantCulture), query.SeriesTimerId, StringComparison.OrdinalIgnoreCase)); - if (seriesTimer is not null) - { - internalQuery.ExternalSeriesId = seriesTimer.SeriesId; - - if (string.IsNullOrWhiteSpace(seriesTimer.SeriesId)) - { - // Better to return nothing than every program in the database - return new QueryResult(); - } - } - else - { - // Better to return nothing than every program in the database - return new QueryResult(); - } - } - - var queryResult = _libraryManager.QueryItems(internalQuery); - - var returnArray = _dtoService.GetBaseItemDtos(queryResult.Items, options, user); - - return new QueryResult( - query.StartIndex, - queryResult.TotalRecordCount, - returnArray); - } - - public QueryResult GetRecommendedProgramsInternal(InternalItemsQuery query, DtoOptions options, CancellationToken cancellationToken) - { - var user = query.User; - - var topFolder = GetInternalLiveTvFolder(cancellationToken); - - var internalQuery = new InternalItemsQuery(user) - { - IncludeItemTypes = new[] { BaseItemKind.LiveTvProgram }, - IsAiring = query.IsAiring, - HasAired = query.HasAired, - IsNews = query.IsNews, - IsMovie = query.IsMovie, - IsSeries = query.IsSeries, - IsSports = query.IsSports, - IsKids = query.IsKids, - EnableTotalRecordCount = query.EnableTotalRecordCount, - OrderBy = new[] { (ItemSortBy.StartDate, SortOrder.Ascending) }, - TopParentIds = new[] { topFolder.Id }, - DtoOptions = options, - GenreIds = query.GenreIds - }; - - if (query.Limit.HasValue) - { - internalQuery.Limit = Math.Max(query.Limit.Value * 4, 200); - } - - var programList = _libraryManager.QueryItems(internalQuery).Items; - var totalCount = programList.Count; - - var orderedPrograms = programList.Cast().OrderBy(i => i.StartDate.Date); - - if (query.IsAiring ?? false) - { - orderedPrograms = orderedPrograms - .ThenByDescending(i => GetRecommendationScore(i, user, true)); - } - - IEnumerable programs = orderedPrograms; - - if (query.Limit.HasValue) - { - programs = programs.Take(query.Limit.Value); - } - - return new QueryResult( - query.StartIndex, - totalCount, - programs.ToArray()); - } - - public Task> GetRecommendedProgramsAsync(InternalItemsQuery query, DtoOptions options, CancellationToken cancellationToken) - { - if (!(query.IsAiring ?? false)) - { - return GetPrograms(query, options, cancellationToken); - } - - RemoveFields(options); - - var internalResult = GetRecommendedProgramsInternal(query, options, cancellationToken); - - return Task.FromResult(new QueryResult( - query.StartIndex, - internalResult.TotalRecordCount, - _dtoService.GetBaseItemDtos(internalResult.Items, options, query.User))); - } - - private int GetRecommendationScore(LiveTvProgram program, User user, bool factorChannelWatchCount) - { - var score = 0; - - if (program.IsLive) - { - score++; - } - - if (program.IsSeries && !program.IsRepeat) - { - score++; - } - - var channel = _libraryManager.GetItemById(program.ChannelId); - - if (channel is null) - { - return score; - } - - var channelUserdata = _userDataManager.GetUserData(user, channel); - - if (channelUserdata.Likes.HasValue) - { - score += channelUserdata.Likes.Value ? 2 : -2; - } - - if (channelUserdata.IsFavorite) - { - score += 3; - } - - if (factorChannelWatchCount) - { - score += channelUserdata.PlayCount; - } - - return score; - } - - private async Task AddRecordingInfo(IEnumerable<(BaseItemDto ItemDto, string ExternalId, string ExternalSeriesId)> programs, CancellationToken cancellationToken) - { - IReadOnlyList timerList = null; - IReadOnlyList seriesTimerList = null; - - foreach (var programTuple in programs) - { - var program = programTuple.ItemDto; - var externalProgramId = programTuple.ExternalId; - string externalSeriesId = programTuple.ExternalSeriesId; - - timerList ??= (await GetTimersInternal(new TimerQuery(), cancellationToken).ConfigureAwait(false)).Items; - - var timer = timerList.FirstOrDefault(i => string.Equals(i.ProgramId, externalProgramId, StringComparison.OrdinalIgnoreCase)); - var foundSeriesTimer = false; - - if (timer is not null) - { - if (timer.Status != RecordingStatus.Cancelled && timer.Status != RecordingStatus.Error) - { - program.TimerId = _tvDtoService.GetInternalTimerId(timer.Id); - - program.Status = timer.Status.ToString(); - } - - if (!string.IsNullOrEmpty(timer.SeriesTimerId)) - { - program.SeriesTimerId = _tvDtoService.GetInternalSeriesTimerId(timer.SeriesTimerId) - .ToString("N", CultureInfo.InvariantCulture); - - foundSeriesTimer = true; - } - } - - if (foundSeriesTimer || string.IsNullOrWhiteSpace(externalSeriesId)) - { - continue; - } - - seriesTimerList ??= (await GetSeriesTimersInternal(new SeriesTimerQuery(), cancellationToken).ConfigureAwait(false)).Items; - - var seriesTimer = seriesTimerList.FirstOrDefault(i => string.Equals(i.SeriesId, externalSeriesId, StringComparison.OrdinalIgnoreCase)); - - if (seriesTimer is not null) - { - program.SeriesTimerId = _tvDtoService.GetInternalSeriesTimerId(seriesTimer.Id) - .ToString("N", CultureInfo.InvariantCulture); - } - } - } - - internal Task RefreshChannels(IProgress progress, CancellationToken cancellationToken) - { - return RefreshChannelsInternal(progress, cancellationToken); - } - - private async Task RefreshChannelsInternal(IProgress progress, CancellationToken cancellationToken) - { - await EmbyTV.EmbyTV.Current.CreateRecordingFolders().ConfigureAwait(false); - - await EmbyTV.EmbyTV.Current.ScanForTunerDeviceChanges(cancellationToken).ConfigureAwait(false); - - var numComplete = 0; - double progressPerService = _services.Length == 0 - ? 0 - : 1.0 / _services.Length; - - var newChannelIdList = new List(); - var newProgramIdList = new List(); - - var cleanDatabase = true; - - foreach (var service in _services) - { - cancellationToken.ThrowIfCancellationRequested(); - - _logger.LogDebug("Refreshing guide from {Name}", service.Name); - - try - { - var innerProgress = new ActionableProgress(); - innerProgress.RegisterAction(p => progress.Report(p * progressPerService)); - - var idList = await RefreshChannelsInternal(service, innerProgress, cancellationToken).ConfigureAwait(false); - - newChannelIdList.AddRange(idList.Item1); - newProgramIdList.AddRange(idList.Item2); - } - catch (OperationCanceledException) - { - throw; - } - catch (Exception ex) - { - cleanDatabase = false; - _logger.LogError(ex, "Error refreshing channels for service"); - } - - numComplete++; - double percent = numComplete; - percent /= _services.Length; - - progress.Report(100 * percent); - } - - if (cleanDatabase) - { - CleanDatabaseInternal(newChannelIdList.ToArray(), new[] { BaseItemKind.LiveTvChannel }, progress, cancellationToken); - CleanDatabaseInternal(newProgramIdList.ToArray(), new[] { BaseItemKind.LiveTvProgram }, progress, cancellationToken); - } - - var coreService = _services.OfType().FirstOrDefault(); - - if (coreService is not null) - { - await coreService.RefreshSeriesTimers(cancellationToken).ConfigureAwait(false); - await coreService.RefreshTimers(cancellationToken).ConfigureAwait(false); - } - - // Load these now which will prefetch metadata - var dtoOptions = new DtoOptions(); - var fields = dtoOptions.Fields.ToList(); - dtoOptions.Fields = fields.ToArray(); - - progress.Report(100); - } - - private async Task, List>> RefreshChannelsInternal(ILiveTvService service, ActionableProgress progress, CancellationToken cancellationToken) - { - progress.Report(10); - - var allChannelsList = (await service.GetChannelsAsync(cancellationToken).ConfigureAwait(false)) - .Select(i => new Tuple(service.Name, i)) - .ToList(); - - var list = new List(); - - var numComplete = 0; - var parentFolder = GetInternalLiveTvFolder(cancellationToken); - - foreach (var channelInfo in allChannelsList) - { - cancellationToken.ThrowIfCancellationRequested(); - - try - { - var item = await GetChannelAsync(channelInfo.Item2, channelInfo.Item1, parentFolder, cancellationToken).ConfigureAwait(false); - - list.Add(item); - } - catch (OperationCanceledException) - { - throw; - } - catch (Exception ex) - { - _logger.LogError(ex, "Error getting channel information for {Name}", channelInfo.Item2.Name); - } - - numComplete++; - double percent = numComplete; - percent /= allChannelsList.Count; - - progress.Report((5 * percent) + 10); - } - - progress.Report(15); - - numComplete = 0; - var programs = new List(); - var channels = new List(); - - var guideDays = GetGuideDays(); - - _logger.LogInformation("Refreshing guide with {0} days of guide data", guideDays); - - cancellationToken.ThrowIfCancellationRequested(); - - foreach (var currentChannel in list) - { - channels.Add(currentChannel.Id); - cancellationToken.ThrowIfCancellationRequested(); - - try - { - var start = DateTime.UtcNow.AddHours(-1); - var end = start.AddDays(guideDays); - - var isMovie = false; - var isSports = false; - var isNews = false; - var isKids = false; - var iSSeries = false; - - var channelPrograms = (await service.GetProgramsAsync(currentChannel.ExternalId, start, end, cancellationToken).ConfigureAwait(false)).ToList(); - - var existingPrograms = _libraryManager.GetItemList(new InternalItemsQuery - { - IncludeItemTypes = new[] { BaseItemKind.LiveTvProgram }, - ChannelIds = new Guid[] { currentChannel.Id }, - DtoOptions = new DtoOptions(true) - }).Cast().ToDictionary(i => i.Id); - - var newPrograms = new List(); - var updatedPrograms = new List(); - - foreach (var program in channelPrograms) - { - var programTuple = GetProgram(program, existingPrograms, currentChannel); - var programItem = programTuple.Item; - - if (programTuple.IsNew) - { - newPrograms.Add(programItem); - } - else if (programTuple.IsUpdated) - { - updatedPrograms.Add(programItem); - } - - programs.Add(programItem.Id); - - isMovie |= program.IsMovie; - iSSeries |= program.IsSeries; - isSports |= program.IsSports; - isNews |= program.IsNews; - isKids |= program.IsKids; - } - - _logger.LogDebug("Channel {0} has {1} new programs and {2} updated programs", currentChannel.Name, newPrograms.Count, updatedPrograms.Count); - - if (newPrograms.Count > 0) - { - _libraryManager.CreateItems(newPrograms, null, cancellationToken); - } - - if (updatedPrograms.Count > 0) - { - await _libraryManager.UpdateItemsAsync( - updatedPrograms, - currentChannel, - ItemUpdateType.MetadataImport, - cancellationToken).ConfigureAwait(false); - } - - currentChannel.IsMovie = isMovie; - currentChannel.IsNews = isNews; - currentChannel.IsSports = isSports; - currentChannel.IsSeries = iSSeries; - - if (isKids) - { - currentChannel.AddTag("Kids"); - } - - await currentChannel.UpdateToRepositoryAsync(ItemUpdateType.MetadataImport, cancellationToken).ConfigureAwait(false); - await currentChannel.RefreshMetadata( - new MetadataRefreshOptions(new DirectoryService(_fileSystem)) - { - ForceSave = true - }, - cancellationToken).ConfigureAwait(false); - } - catch (OperationCanceledException) - { - throw; - } - catch (Exception ex) - { - _logger.LogError(ex, "Error getting programs for channel {Name}", currentChannel.Name); - } - - numComplete++; - double percent = numComplete / (double)allChannelsList.Count; - - progress.Report((85 * percent) + 15); - } - - progress.Report(100); - return new Tuple, List>(channels, programs); - } - - private void CleanDatabaseInternal(Guid[] currentIdList, BaseItemKind[] validTypes, IProgress progress, CancellationToken cancellationToken) - { - var list = _itemRepo.GetItemIdsList(new InternalItemsQuery - { - IncludeItemTypes = validTypes, - DtoOptions = new DtoOptions(false) - }); - - var numComplete = 0; - - foreach (var itemId in list) - { - cancellationToken.ThrowIfCancellationRequested(); - - if (itemId.Equals(default)) - { - // Somehow some invalid data got into the db. It probably predates the boundary checking - continue; - } - - if (!currentIdList.Contains(itemId)) - { - var item = _libraryManager.GetItemById(itemId); - - if (item is not null) - { - _libraryManager.DeleteItem( - item, - new DeleteOptions - { - DeleteFileLocation = false, - DeleteFromExternalProvider = false - }, - false); - } - } - - numComplete++; - double percent = numComplete / (double)list.Count; - - progress.Report(100 * percent); - } - } - - private double GetGuideDays() - { - var config = GetConfiguration(); - - if (config.GuideDays.HasValue) - { - return Math.Max(1, Math.Min(config.GuideDays.Value, MaxGuideDays)); - } - - return 7; - } - - private async Task> GetEmbyRecordingsAsync(RecordingQuery query, DtoOptions dtoOptions, User user) - { - if (user is null) - { - return new QueryResult(); - } - - var folders = await GetRecordingFoldersAsync(user, true).ConfigureAwait(false); - var folderIds = Array.ConvertAll(folders, x => x.Id); - - var excludeItemTypes = new List(); - - if (folderIds.Length == 0) - { - return new QueryResult(); - } - - var includeItemTypes = new List(); - var genres = new List(); - - if (query.IsMovie.HasValue) - { - if (query.IsMovie.Value) - { - includeItemTypes.Add(BaseItemKind.Movie); - } - else - { - excludeItemTypes.Add(BaseItemKind.Movie); - } - } - - if (query.IsSeries.HasValue) - { - if (query.IsSeries.Value) - { - includeItemTypes.Add(BaseItemKind.Episode); - } - else - { - excludeItemTypes.Add(BaseItemKind.Episode); - } - } - - if (query.IsSports ?? false) - { - genres.Add("Sports"); - } - - if (query.IsKids ?? false) - { - genres.Add("Kids"); - genres.Add("Children"); - genres.Add("Family"); - } - - var limit = query.Limit; - - if (query.IsInProgress ?? false) - { - // limit = (query.Limit ?? 10) * 2; - limit = null; - - // var allActivePaths = EmbyTV.EmbyTV.Current.GetAllActiveRecordings().Select(i => i.Path).ToArray(); - // var items = allActivePaths.Select(i => _libraryManager.FindByPath(i, false)).Where(i => i is not null).ToArray(); - - // return new QueryResult - // { - // Items = items, - // TotalRecordCount = items.Length - // }; - - dtoOptions.Fields = dtoOptions.Fields.Concat(new[] { ItemFields.Tags }).Distinct().ToArray(); - } - - var result = _libraryManager.GetItemsResult(new InternalItemsQuery(user) - { - MediaTypes = new[] { MediaType.Video }, - Recursive = true, - AncestorIds = folderIds, - IsFolder = false, - IsVirtualItem = false, - Limit = limit, - StartIndex = query.StartIndex, - OrderBy = new[] { (ItemSortBy.DateCreated, SortOrder.Descending) }, - EnableTotalRecordCount = query.EnableTotalRecordCount, - IncludeItemTypes = includeItemTypes.ToArray(), - ExcludeItemTypes = excludeItemTypes.ToArray(), - Genres = genres.ToArray(), - DtoOptions = dtoOptions - }); - - if (query.IsInProgress ?? false) - { - // TODO: Fix The co-variant conversion between Video[] and BaseItem[], this can generate runtime issues. - result.Items = result - .Items - .OfType diff --git a/Jellyfin.sln b/Jellyfin.sln index 31e302d94..30eab6cc2 100644 --- a/Jellyfin.sln +++ b/Jellyfin.sln @@ -89,6 +89,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jellyfin.MediaEncoding.Keyf EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jellyfin.LiveTv.Tests", "tests\Jellyfin.LiveTv.Tests\Jellyfin.LiveTv.Tests.csproj", "{C4F71272-C6BE-4C30-BE0D-4E6ED651D6D3}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jellyfin.LiveTv", "src\Jellyfin.LiveTv\Jellyfin.LiveTv.csproj", "{8C6B2B13-58A4-4506-9DAB-1F882A093FE0}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -239,6 +241,10 @@ Global {C4F71272-C6BE-4C30-BE0D-4E6ED651D6D3}.Debug|Any CPU.Build.0 = Debug|Any CPU {C4F71272-C6BE-4C30-BE0D-4E6ED651D6D3}.Release|Any CPU.ActiveCfg = Release|Any CPU {C4F71272-C6BE-4C30-BE0D-4E6ED651D6D3}.Release|Any CPU.Build.0 = Release|Any CPU + {8C6B2B13-58A4-4506-9DAB-1F882A093FE0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8C6B2B13-58A4-4506-9DAB-1F882A093FE0}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8C6B2B13-58A4-4506-9DAB-1F882A093FE0}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8C6B2B13-58A4-4506-9DAB-1F882A093FE0}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -266,6 +272,7 @@ Global {154872D9-6C12-4007-96E3-8F70A58386CE} = {C9F0AB5D-F4D7-40C8-A353-3305C86D6D4C} {0A3FCC4D-C714-4072-B90F-E374A15F9FF9} = {C9F0AB5D-F4D7-40C8-A353-3305C86D6D4C} {C4F71272-C6BE-4C30-BE0D-4E6ED651D6D3} = {FBBB5129-006E-4AD7-BAD5-8B7CA1D10ED6} + {8C6B2B13-58A4-4506-9DAB-1F882A093FE0} = {C9F0AB5D-F4D7-40C8-A353-3305C86D6D4C} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {3448830C-EBDC-426C-85CD-7BBB9651A7FE} diff --git a/src/Jellyfin.LiveTv/EmbyTV/DirectRecorder.cs b/src/Jellyfin.LiveTv/EmbyTV/DirectRecorder.cs new file mode 100644 index 000000000..2a25218b6 --- /dev/null +++ b/src/Jellyfin.LiveTv/EmbyTV/DirectRecorder.cs @@ -0,0 +1,118 @@ +#pragma warning disable CS1591 + +using System; +using System.IO; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using MediaBrowser.Common.Net; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Streaming; +using MediaBrowser.Model.Dto; +using MediaBrowser.Model.IO; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.LiveTv.EmbyTV +{ + public sealed class DirectRecorder : IRecorder + { + private readonly ILogger _logger; + private readonly IHttpClientFactory _httpClientFactory; + private readonly IStreamHelper _streamHelper; + + public DirectRecorder(ILogger logger, IHttpClientFactory httpClientFactory, IStreamHelper streamHelper) + { + _logger = logger; + _httpClientFactory = httpClientFactory; + _streamHelper = streamHelper; + } + + public string GetOutputPath(MediaSourceInfo mediaSource, string targetFile) + { + return targetFile; + } + + public Task Record(IDirectStreamProvider? directStreamProvider, MediaSourceInfo mediaSource, string targetFile, TimeSpan duration, Action onStarted, CancellationToken cancellationToken) + { + if (directStreamProvider is not null) + { + return RecordFromDirectStreamProvider(directStreamProvider, targetFile, duration, onStarted, cancellationToken); + } + + return RecordFromMediaSource(mediaSource, targetFile, duration, onStarted, cancellationToken); + } + + private async Task RecordFromDirectStreamProvider(IDirectStreamProvider directStreamProvider, string targetFile, TimeSpan duration, Action onStarted, CancellationToken cancellationToken) + { + Directory.CreateDirectory(Path.GetDirectoryName(targetFile) ?? throw new ArgumentException("Path can't be a root directory.", nameof(targetFile))); + + var output = new FileStream( + targetFile, + FileMode.CreateNew, + FileAccess.Write, + FileShare.Read, + IODefaults.FileStreamBufferSize, + FileOptions.Asynchronous); + + await using (output.ConfigureAwait(false)) + { + onStarted(); + + _logger.LogInformation("Copying recording to file {FilePath}", targetFile); + + // The media source is infinite so we need to handle stopping ourselves + using var durationToken = new CancellationTokenSource(duration); + using var cancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, durationToken.Token); + var linkedCancellationToken = cancellationTokenSource.Token; + var fileStream = new ProgressiveFileStream(directStreamProvider.GetStream()); + await using (fileStream.ConfigureAwait(false)) + { + await _streamHelper.CopyToAsync( + fileStream, + output, + IODefaults.CopyToBufferSize, + 1000, + linkedCancellationToken).ConfigureAwait(false); + } + } + + _logger.LogInformation("Recording completed: {FilePath}", targetFile); + } + + private async Task RecordFromMediaSource(MediaSourceInfo mediaSource, string targetFile, TimeSpan duration, Action onStarted, CancellationToken cancellationToken) + { + using var response = await _httpClientFactory.CreateClient(NamedClient.Default) + .GetAsync(mediaSource.Path, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); + + _logger.LogInformation("Opened recording stream from tuner provider"); + + Directory.CreateDirectory(Path.GetDirectoryName(targetFile) ?? throw new ArgumentException("Path can't be a root directory.", nameof(targetFile))); + + var output = new FileStream(targetFile, FileMode.CreateNew, FileAccess.Write, FileShare.Read, IODefaults.CopyToBufferSize, FileOptions.Asynchronous); + await using (output.ConfigureAwait(false)) + { + onStarted(); + + _logger.LogInformation("Copying recording stream to file {0}", targetFile); + + // The media source if infinite so we need to handle stopping ourselves + using var durationToken = new CancellationTokenSource(duration); + using var linkedCancellationToken = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, durationToken.Token); + cancellationToken = linkedCancellationToken.Token; + + await _streamHelper.CopyUntilCancelled( + await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false), + output, + IODefaults.CopyToBufferSize, + cancellationToken).ConfigureAwait(false); + + _logger.LogInformation("Recording completed to file {0}", targetFile); + } + } + + /// + public void Dispose() + { + } + } +} diff --git a/src/Jellyfin.LiveTv/EmbyTV/EmbyTV.cs b/src/Jellyfin.LiveTv/EmbyTV/EmbyTV.cs new file mode 100644 index 000000000..439ed965b --- /dev/null +++ b/src/Jellyfin.LiveTv/EmbyTV/EmbyTV.cs @@ -0,0 +1,2621 @@ +#nullable disable + +#pragma warning disable CS1591 + +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Diagnostics; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Net.Http; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using System.Xml; +using Jellyfin.Data.Enums; +using Jellyfin.Data.Events; +using Jellyfin.Extensions; +using MediaBrowser.Common.Configuration; +using MediaBrowser.Common.Extensions; +using MediaBrowser.Common.Progress; +using MediaBrowser.Controller.Configuration; +using MediaBrowser.Controller.Dto; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.TV; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.LiveTv; +using MediaBrowser.Controller.MediaEncoding; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.Configuration; +using MediaBrowser.Model.Dto; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.IO; +using MediaBrowser.Model.LiveTv; +using MediaBrowser.Model.MediaInfo; +using MediaBrowser.Model.Providers; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.LiveTv.EmbyTV +{ + public sealed class EmbyTV : ILiveTvService, ISupportsDirectStreamProvider, ISupportsNewTimerIds, IDisposable + { + public const string DateAddedFormat = "yyyy-MM-dd HH:mm:ss"; + + private const int TunerDiscoveryDurationMs = 3000; + + private readonly ILogger _logger; + private readonly IHttpClientFactory _httpClientFactory; + private readonly IServerConfigurationManager _config; + + private readonly ItemDataProvider _seriesTimerProvider; + private readonly TimerManager _timerProvider; + + private readonly LiveTvManager _liveTvManager; + private readonly IFileSystem _fileSystem; + + private readonly ILibraryMonitor _libraryMonitor; + private readonly ILibraryManager _libraryManager; + private readonly IProviderManager _providerManager; + private readonly IMediaEncoder _mediaEncoder; + private readonly IMediaSourceManager _mediaSourceManager; + private readonly IStreamHelper _streamHelper; + + private readonly ConcurrentDictionary _activeRecordings = + new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase); + + private readonly ConcurrentDictionary _epgChannels = + new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase); + + private readonly SemaphoreSlim _recordingDeleteSemaphore = new SemaphoreSlim(1, 1); + + private bool _disposed; + + public EmbyTV( + IStreamHelper streamHelper, + IMediaSourceManager mediaSourceManager, + ILogger logger, + IHttpClientFactory httpClientFactory, + IServerConfigurationManager config, + ILiveTvManager liveTvManager, + IFileSystem fileSystem, + ILibraryManager libraryManager, + ILibraryMonitor libraryMonitor, + IProviderManager providerManager, + IMediaEncoder mediaEncoder) + { + Current = this; + + _logger = logger; + _httpClientFactory = httpClientFactory; + _config = config; + _fileSystem = fileSystem; + _libraryManager = libraryManager; + _libraryMonitor = libraryMonitor; + _providerManager = providerManager; + _mediaEncoder = mediaEncoder; + _liveTvManager = (LiveTvManager)liveTvManager; + _mediaSourceManager = mediaSourceManager; + _streamHelper = streamHelper; + + _seriesTimerProvider = new SeriesTimerManager(_logger, Path.Combine(DataPath, "seriestimers.json")); + _timerProvider = new TimerManager(_logger, Path.Combine(DataPath, "timers.json")); + _timerProvider.TimerFired += OnTimerProviderTimerFired; + + _config.NamedConfigurationUpdated += OnNamedConfigurationUpdated; + } + + public event EventHandler> TimerCreated; + + public event EventHandler> TimerCancelled; + + public static EmbyTV Current { get; private set; } + + /// + public string Name => "Emby"; + + public string DataPath => Path.Combine(_config.CommonApplicationPaths.DataPath, "livetv"); + + /// + public string HomePageUrl => "https://github.com/jellyfin/jellyfin"; + + private string DefaultRecordingPath => Path.Combine(DataPath, "recordings"); + + private string RecordingPath + { + get + { + var path = GetConfiguration().RecordingPath; + + return string.IsNullOrWhiteSpace(path) + ? DefaultRecordingPath + : path; + } + } + + private async void OnNamedConfigurationUpdated(object sender, ConfigurationUpdateEventArgs e) + { + if (string.Equals(e.Key, "livetv", StringComparison.OrdinalIgnoreCase)) + { + await CreateRecordingFolders().ConfigureAwait(false); + } + } + + public Task Start() + { + _timerProvider.RestartTimers(); + + return CreateRecordingFolders(); + } + + internal async Task CreateRecordingFolders() + { + try + { + var recordingFolders = GetRecordingFolders().ToArray(); + var virtualFolders = _libraryManager.GetVirtualFolders(); + + var allExistingPaths = virtualFolders.SelectMany(i => i.Locations).ToList(); + + var pathsAdded = new List(); + + foreach (var recordingFolder in recordingFolders) + { + var pathsToCreate = recordingFolder.Locations + .Where(i => !allExistingPaths.Any(p => _fileSystem.AreEqual(p, i))) + .ToList(); + + if (pathsToCreate.Count == 0) + { + continue; + } + + var mediaPathInfos = pathsToCreate.Select(i => new MediaPathInfo(i)).ToArray(); + + var libraryOptions = new LibraryOptions + { + PathInfos = mediaPathInfos + }; + try + { + await _libraryManager.AddVirtualFolder(recordingFolder.Name, recordingFolder.CollectionType, libraryOptions, true).ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error creating virtual folder"); + } + + pathsAdded.AddRange(pathsToCreate); + } + + var config = GetConfiguration(); + + var pathsToRemove = config.MediaLocationsCreated + .Except(recordingFolders.SelectMany(i => i.Locations)) + .ToList(); + + if (pathsAdded.Count > 0 || pathsToRemove.Count > 0) + { + pathsAdded.InsertRange(0, config.MediaLocationsCreated); + config.MediaLocationsCreated = pathsAdded.Except(pathsToRemove).Distinct(StringComparer.OrdinalIgnoreCase).ToArray(); + _config.SaveConfiguration("livetv", config); + } + + foreach (var path in pathsToRemove) + { + await RemovePathFromLibraryAsync(path).ConfigureAwait(false); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Error creating recording folders"); + } + } + + private async Task RemovePathFromLibraryAsync(string path) + { + _logger.LogDebug("Removing path from library: {0}", path); + + var requiresRefresh = false; + var virtualFolders = _libraryManager.GetVirtualFolders(); + + foreach (var virtualFolder in virtualFolders) + { + if (!virtualFolder.Locations.Contains(path, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + if (virtualFolder.Locations.Length == 1) + { + // remove entire virtual folder + try + { + await _libraryManager.RemoveVirtualFolder(virtualFolder.Name, true).ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error removing virtual folder"); + } + } + else + { + try + { + _libraryManager.RemoveMediaPath(virtualFolder.Name, path); + requiresRefresh = true; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error removing media path"); + } + } + } + + if (requiresRefresh) + { + await _libraryManager.ValidateMediaLibrary(new SimpleProgress(), CancellationToken.None).ConfigureAwait(false); + } + } + + public async Task RefreshSeriesTimers(CancellationToken cancellationToken) + { + var seriesTimers = await GetSeriesTimersAsync(cancellationToken).ConfigureAwait(false); + + foreach (var timer in seriesTimers) + { + UpdateTimersForSeriesTimer(timer, false, true); + } + } + + public async Task RefreshTimers(CancellationToken cancellationToken) + { + var timers = await GetTimersAsync(cancellationToken).ConfigureAwait(false); + + var tempChannelCache = new Dictionary(); + + foreach (var timer in timers) + { + if (DateTime.UtcNow > timer.EndDate && !_activeRecordings.ContainsKey(timer.Id)) + { + OnTimerOutOfDate(timer); + continue; + } + + if (string.IsNullOrWhiteSpace(timer.ProgramId) || string.IsNullOrWhiteSpace(timer.ChannelId)) + { + continue; + } + + var program = GetProgramInfoFromCache(timer); + if (program is null) + { + OnTimerOutOfDate(timer); + continue; + } + + CopyProgramInfoToTimerInfo(program, timer, tempChannelCache); + _timerProvider.Update(timer); + } + } + + private void OnTimerOutOfDate(TimerInfo timer) + { + _timerProvider.Delete(timer); + } + + private async Task> GetChannelsAsync(bool enableCache, CancellationToken cancellationToken) + { + var list = new List(); + + foreach (var hostInstance in _liveTvManager.TunerHosts) + { + try + { + var channels = await hostInstance.GetChannels(enableCache, cancellationToken).ConfigureAwait(false); + + list.AddRange(channels); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error getting channels"); + } + } + + foreach (var provider in GetListingProviders()) + { + var enabledChannels = list + .Where(i => IsListingProviderEnabledForTuner(provider.Item2, i.TunerHostId)) + .ToList(); + + if (enabledChannels.Count > 0) + { + try + { + await AddMetadata(provider.Item1, provider.Item2, enabledChannels, enableCache, cancellationToken).ConfigureAwait(false); + } + catch (NotSupportedException) + { + } + catch (Exception ex) + { + _logger.LogError(ex, "Error adding metadata"); + } + } + } + + return list; + } + + private async Task AddMetadata( + IListingsProvider provider, + ListingsProviderInfo info, + IEnumerable tunerChannels, + bool enableCache, + CancellationToken cancellationToken) + { + var epgChannels = await GetEpgChannels(provider, info, enableCache, cancellationToken).ConfigureAwait(false); + + foreach (var tunerChannel in tunerChannels) + { + var epgChannel = GetEpgChannelFromTunerChannel(info, tunerChannel, epgChannels); + + if (epgChannel is not null) + { + if (!string.IsNullOrWhiteSpace(epgChannel.Name)) + { + // tunerChannel.Name = epgChannel.Name; + } + + if (!string.IsNullOrWhiteSpace(epgChannel.ImageUrl)) + { + tunerChannel.ImageUrl = epgChannel.ImageUrl; + } + } + } + } + + private async Task GetEpgChannels( + IListingsProvider provider, + ListingsProviderInfo info, + bool enableCache, + CancellationToken cancellationToken) + { + if (!enableCache || !_epgChannels.TryGetValue(info.Id, out var result)) + { + var channels = await provider.GetChannels(info, cancellationToken).ConfigureAwait(false); + + foreach (var channel in channels) + { + _logger.LogInformation("Found epg channel in {0} {1} {2} {3}", provider.Name, info.ListingsId, channel.Name, channel.Id); + } + + result = new EpgChannelData(channels); + _epgChannels.AddOrUpdate(info.Id, result, (_, _) => result); + } + + return result; + } + + private async Task GetEpgChannelFromTunerChannel(IListingsProvider provider, ListingsProviderInfo info, ChannelInfo tunerChannel, CancellationToken cancellationToken) + { + var epgChannels = await GetEpgChannels(provider, info, true, cancellationToken).ConfigureAwait(false); + + return GetEpgChannelFromTunerChannel(info, tunerChannel, epgChannels); + } + + private static string GetMappedChannel(string channelId, NameValuePair[] mappings) + { + foreach (NameValuePair mapping in mappings) + { + if (string.Equals(mapping.Name, channelId, StringComparison.OrdinalIgnoreCase)) + { + return mapping.Value; + } + } + + return channelId; + } + + internal ChannelInfo GetEpgChannelFromTunerChannel(NameValuePair[] mappings, ChannelInfo tunerChannel, List epgChannels) + { + return GetEpgChannelFromTunerChannel(mappings, tunerChannel, new EpgChannelData(epgChannels)); + } + + private ChannelInfo GetEpgChannelFromTunerChannel(ListingsProviderInfo info, ChannelInfo tunerChannel, EpgChannelData epgChannels) + { + return GetEpgChannelFromTunerChannel(info.ChannelMappings, tunerChannel, epgChannels); + } + + private ChannelInfo GetEpgChannelFromTunerChannel( + NameValuePair[] mappings, + ChannelInfo tunerChannel, + EpgChannelData epgChannelData) + { + if (!string.IsNullOrWhiteSpace(tunerChannel.Id)) + { + var mappedTunerChannelId = GetMappedChannel(tunerChannel.Id, mappings); + + if (string.IsNullOrWhiteSpace(mappedTunerChannelId)) + { + mappedTunerChannelId = tunerChannel.Id; + } + + var channel = epgChannelData.GetChannelById(mappedTunerChannelId); + + if (channel is not null) + { + return channel; + } + } + + if (!string.IsNullOrWhiteSpace(tunerChannel.TunerChannelId)) + { + var tunerChannelId = tunerChannel.TunerChannelId; + if (tunerChannelId.Contains(".json.schedulesdirect.org", StringComparison.OrdinalIgnoreCase)) + { + tunerChannelId = tunerChannelId.Replace(".json.schedulesdirect.org", string.Empty, StringComparison.OrdinalIgnoreCase).TrimStart('I'); + } + + var mappedTunerChannelId = GetMappedChannel(tunerChannelId, mappings); + + if (string.IsNullOrWhiteSpace(mappedTunerChannelId)) + { + mappedTunerChannelId = tunerChannelId; + } + + var channel = epgChannelData.GetChannelById(mappedTunerChannelId); + + if (channel is not null) + { + return channel; + } + } + + if (!string.IsNullOrWhiteSpace(tunerChannel.Number)) + { + var tunerChannelNumber = GetMappedChannel(tunerChannel.Number, mappings); + + if (string.IsNullOrWhiteSpace(tunerChannelNumber)) + { + tunerChannelNumber = tunerChannel.Number; + } + + var channel = epgChannelData.GetChannelByNumber(tunerChannelNumber); + + if (channel is not null) + { + return channel; + } + } + + if (!string.IsNullOrWhiteSpace(tunerChannel.Name)) + { + var normalizedName = EpgChannelData.NormalizeName(tunerChannel.Name); + + var channel = epgChannelData.GetChannelByName(normalizedName); + + if (channel is not null) + { + return channel; + } + } + + return null; + } + + public async Task> GetChannelsForListingsProvider(ListingsProviderInfo listingsProvider, CancellationToken cancellationToken) + { + var list = new List(); + + foreach (var hostInstance in _liveTvManager.TunerHosts) + { + try + { + var channels = await hostInstance.GetChannels(false, cancellationToken).ConfigureAwait(false); + + list.AddRange(channels); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error getting channels"); + } + } + + return list + .Where(i => IsListingProviderEnabledForTuner(listingsProvider, i.TunerHostId)) + .ToList(); + } + + public Task> GetChannelsAsync(CancellationToken cancellationToken) + { + return GetChannelsAsync(false, cancellationToken); + } + + public Task CancelSeriesTimerAsync(string timerId, CancellationToken cancellationToken) + { + var timers = _timerProvider + .GetAll() + .Where(i => string.Equals(i.SeriesTimerId, timerId, StringComparison.OrdinalIgnoreCase)) + .ToList(); + + foreach (var timer in timers) + { + CancelTimerInternal(timer.Id, true, true); + } + + var remove = _seriesTimerProvider.GetAll().FirstOrDefault(r => string.Equals(r.Id, timerId, StringComparison.OrdinalIgnoreCase)); + if (remove is not null) + { + _seriesTimerProvider.Delete(remove); + } + + return Task.CompletedTask; + } + + private void CancelTimerInternal(string timerId, bool isSeriesCancelled, bool isManualCancellation) + { + var timer = _timerProvider.GetTimer(timerId); + if (timer is not null) + { + var statusChanging = timer.Status != RecordingStatus.Cancelled; + timer.Status = RecordingStatus.Cancelled; + + if (isManualCancellation) + { + timer.IsManual = true; + } + + if (string.IsNullOrWhiteSpace(timer.SeriesTimerId) || isSeriesCancelled) + { + _timerProvider.Delete(timer); + } + else + { + _timerProvider.AddOrUpdate(timer, false); + } + + if (statusChanging && TimerCancelled is not null) + { + TimerCancelled(this, new GenericEventArgs(timerId)); + } + } + + if (_activeRecordings.TryGetValue(timerId, out var activeRecordingInfo)) + { + activeRecordingInfo.Timer = timer; + activeRecordingInfo.CancellationTokenSource.Cancel(); + } + } + + public Task CancelTimerAsync(string timerId, CancellationToken cancellationToken) + { + CancelTimerInternal(timerId, false, true); + return Task.CompletedTask; + } + + public Task CreateSeriesTimerAsync(SeriesTimerInfo info, CancellationToken cancellationToken) + { + throw new NotImplementedException(); + } + + public Task CreateTimerAsync(TimerInfo info, CancellationToken cancellationToken) + { + throw new NotImplementedException(); + } + + public Task CreateTimer(TimerInfo info, CancellationToken cancellationToken) + { + var existingTimer = string.IsNullOrWhiteSpace(info.ProgramId) ? + null : + _timerProvider.GetTimerByProgramId(info.ProgramId); + + if (existingTimer is not null) + { + if (existingTimer.Status == RecordingStatus.Cancelled + || existingTimer.Status == RecordingStatus.Completed) + { + existingTimer.Status = RecordingStatus.New; + existingTimer.IsManual = true; + _timerProvider.Update(existingTimer); + return Task.FromResult(existingTimer.Id); + } + + throw new ArgumentException("A scheduled recording already exists for this program."); + } + + info.Id = Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture); + + LiveTvProgram programInfo = null; + + if (!string.IsNullOrWhiteSpace(info.ProgramId)) + { + programInfo = GetProgramInfoFromCache(info); + } + + if (programInfo is null) + { + _logger.LogInformation("Unable to find program with Id {0}. Will search using start date", info.ProgramId); + programInfo = GetProgramInfoFromCache(info.ChannelId, info.StartDate); + } + + if (programInfo is not null) + { + CopyProgramInfoToTimerInfo(programInfo, info); + } + + info.IsManual = true; + _timerProvider.Add(info); + + TimerCreated?.Invoke(this, new GenericEventArgs(info)); + + return Task.FromResult(info.Id); + } + + public async Task CreateSeriesTimer(SeriesTimerInfo info, CancellationToken cancellationToken) + { + info.Id = Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture); + + // populate info.seriesID + var program = GetProgramInfoFromCache(info.ProgramId); + + if (program is not null) + { + info.SeriesId = program.ExternalSeriesId; + } + else + { + throw new InvalidOperationException("SeriesId for program not found"); + } + + // If any timers have already been manually created, make sure they don't get cancelled + var existingTimers = (await GetTimersAsync(CancellationToken.None).ConfigureAwait(false)) + .Where(i => + { + if (string.Equals(i.ProgramId, info.ProgramId, StringComparison.OrdinalIgnoreCase) && !string.IsNullOrWhiteSpace(info.ProgramId)) + { + return true; + } + + if (string.Equals(i.SeriesId, info.SeriesId, StringComparison.OrdinalIgnoreCase) && !string.IsNullOrWhiteSpace(info.SeriesId)) + { + return true; + } + + return false; + }) + .ToList(); + + _seriesTimerProvider.Add(info); + + foreach (var timer in existingTimers) + { + timer.SeriesTimerId = info.Id; + timer.IsManual = true; + + _timerProvider.AddOrUpdate(timer, false); + } + + UpdateTimersForSeriesTimer(info, true, false); + + return info.Id; + } + + public Task UpdateSeriesTimerAsync(SeriesTimerInfo info, CancellationToken cancellationToken) + { + var instance = _seriesTimerProvider.GetAll().FirstOrDefault(i => string.Equals(i.Id, info.Id, StringComparison.OrdinalIgnoreCase)); + + if (instance is not null) + { + instance.ChannelId = info.ChannelId; + instance.Days = info.Days; + instance.EndDate = info.EndDate; + instance.IsPostPaddingRequired = info.IsPostPaddingRequired; + instance.IsPrePaddingRequired = info.IsPrePaddingRequired; + instance.PostPaddingSeconds = info.PostPaddingSeconds; + instance.PrePaddingSeconds = info.PrePaddingSeconds; + instance.Priority = info.Priority; + instance.RecordAnyChannel = info.RecordAnyChannel; + instance.RecordAnyTime = info.RecordAnyTime; + instance.RecordNewOnly = info.RecordNewOnly; + instance.SkipEpisodesInLibrary = info.SkipEpisodesInLibrary; + instance.KeepUpTo = info.KeepUpTo; + instance.KeepUntil = info.KeepUntil; + instance.StartDate = info.StartDate; + + _seriesTimerProvider.Update(instance); + + UpdateTimersForSeriesTimer(instance, true, true); + } + + return Task.CompletedTask; + } + + public Task UpdateTimerAsync(TimerInfo updatedTimer, CancellationToken cancellationToken) + { + var existingTimer = _timerProvider.GetTimer(updatedTimer.Id); + + if (existingTimer is null) + { + throw new ResourceNotFoundException(); + } + + // Only update if not currently active + if (!_activeRecordings.TryGetValue(updatedTimer.Id, out _)) + { + existingTimer.PrePaddingSeconds = updatedTimer.PrePaddingSeconds; + existingTimer.PostPaddingSeconds = updatedTimer.PostPaddingSeconds; + existingTimer.IsPostPaddingRequired = updatedTimer.IsPostPaddingRequired; + existingTimer.IsPrePaddingRequired = updatedTimer.IsPrePaddingRequired; + + _timerProvider.Update(existingTimer); + } + + return Task.CompletedTask; + } + + private static void UpdateExistingTimerWithNewMetadata(TimerInfo existingTimer, TimerInfo updatedTimer) + { + // Update the program info but retain the status + existingTimer.ChannelId = updatedTimer.ChannelId; + existingTimer.CommunityRating = updatedTimer.CommunityRating; + existingTimer.EndDate = updatedTimer.EndDate; + existingTimer.EpisodeNumber = updatedTimer.EpisodeNumber; + existingTimer.EpisodeTitle = updatedTimer.EpisodeTitle; + existingTimer.Genres = updatedTimer.Genres; + existingTimer.IsMovie = updatedTimer.IsMovie; + existingTimer.IsSeries = updatedTimer.IsSeries; + existingTimer.Tags = updatedTimer.Tags; + existingTimer.IsProgramSeries = updatedTimer.IsProgramSeries; + existingTimer.IsRepeat = updatedTimer.IsRepeat; + existingTimer.Name = updatedTimer.Name; + existingTimer.OfficialRating = updatedTimer.OfficialRating; + existingTimer.OriginalAirDate = updatedTimer.OriginalAirDate; + existingTimer.Overview = updatedTimer.Overview; + existingTimer.ProductionYear = updatedTimer.ProductionYear; + existingTimer.ProgramId = updatedTimer.ProgramId; + existingTimer.SeasonNumber = updatedTimer.SeasonNumber; + existingTimer.StartDate = updatedTimer.StartDate; + existingTimer.ShowId = updatedTimer.ShowId; + existingTimer.ProviderIds = updatedTimer.ProviderIds; + existingTimer.SeriesProviderIds = updatedTimer.SeriesProviderIds; + } + + public string GetActiveRecordingPath(string id) + { + if (_activeRecordings.TryGetValue(id, out var info)) + { + return info.Path; + } + + return null; + } + + public ActiveRecordingInfo GetActiveRecordingInfo(string path) + { + if (string.IsNullOrWhiteSpace(path) || _activeRecordings.IsEmpty) + { + return null; + } + + foreach (var (_, recordingInfo) in _activeRecordings) + { + if (string.Equals(recordingInfo.Path, path, StringComparison.Ordinal) && !recordingInfo.CancellationTokenSource.IsCancellationRequested) + { + var timer = recordingInfo.Timer; + if (timer.Status != RecordingStatus.InProgress) + { + return null; + } + + return recordingInfo; + } + } + + return null; + } + + public Task> GetTimersAsync(CancellationToken cancellationToken) + { + var excludeStatues = new List + { + RecordingStatus.Completed + }; + + var timers = _timerProvider.GetAll() + .Where(i => !excludeStatues.Contains(i.Status)); + + return Task.FromResult(timers); + } + + public Task GetNewTimerDefaultsAsync(CancellationToken cancellationToken, ProgramInfo program = null) + { + var config = GetConfiguration(); + + var defaults = new SeriesTimerInfo() + { + PostPaddingSeconds = Math.Max(config.PostPaddingSeconds, 0), + PrePaddingSeconds = Math.Max(config.PrePaddingSeconds, 0), + RecordAnyChannel = false, + RecordAnyTime = true, + RecordNewOnly = true, + + Days = new List + { + DayOfWeek.Sunday, + DayOfWeek.Monday, + DayOfWeek.Tuesday, + DayOfWeek.Wednesday, + DayOfWeek.Thursday, + DayOfWeek.Friday, + DayOfWeek.Saturday + } + }; + + if (program is not null) + { + defaults.SeriesId = program.SeriesId; + defaults.ProgramId = program.Id; + defaults.RecordNewOnly = !program.IsRepeat; + defaults.Name = program.Name; + } + + defaults.SkipEpisodesInLibrary = defaults.RecordNewOnly; + defaults.KeepUntil = KeepUntil.UntilDeleted; + + return Task.FromResult(defaults); + } + + public Task> GetSeriesTimersAsync(CancellationToken cancellationToken) + { + return Task.FromResult((IEnumerable)_seriesTimerProvider.GetAll()); + } + + private bool IsListingProviderEnabledForTuner(ListingsProviderInfo info, string tunerHostId) + { + if (info.EnableAllTuners) + { + return true; + } + + if (string.IsNullOrWhiteSpace(tunerHostId)) + { + throw new ArgumentNullException(nameof(tunerHostId)); + } + + return info.EnabledTuners.Contains(tunerHostId, StringComparison.OrdinalIgnoreCase); + } + + public async Task> GetProgramsAsync(string channelId, DateTime startDateUtc, DateTime endDateUtc, CancellationToken cancellationToken) + { + var channels = await GetChannelsAsync(true, cancellationToken).ConfigureAwait(false); + var channel = channels.First(i => string.Equals(i.Id, channelId, StringComparison.OrdinalIgnoreCase)); + + foreach (var provider in GetListingProviders()) + { + if (!IsListingProviderEnabledForTuner(provider.Item2, channel.TunerHostId)) + { + _logger.LogDebug("Skipping getting programs for channel {0}-{1} from {2}-{3}, because it's not enabled for this tuner.", channel.Number, channel.Name, provider.Item1.Name, provider.Item2.ListingsId ?? string.Empty); + continue; + } + + _logger.LogDebug("Getting programs for channel {0}-{1} from {2}-{3}", channel.Number, channel.Name, provider.Item1.Name, provider.Item2.ListingsId ?? string.Empty); + + var epgChannel = await GetEpgChannelFromTunerChannel(provider.Item1, provider.Item2, channel, cancellationToken).ConfigureAwait(false); + + if (epgChannel is null) + { + _logger.LogDebug("EPG channel not found for tuner channel {0}-{1} from {2}-{3}", channel.Number, channel.Name, provider.Item1.Name, provider.Item2.ListingsId ?? string.Empty); + continue; + } + + List programs = (await provider.Item1.GetProgramsAsync(provider.Item2, epgChannel.Id, startDateUtc, endDateUtc, cancellationToken) + .ConfigureAwait(false)).ToList(); + + // Replace the value that came from the provider with a normalized value + foreach (var program in programs) + { + program.ChannelId = channelId; + + program.Id += "_" + channelId; + } + + if (programs.Count > 0) + { + return programs; + } + } + + return Enumerable.Empty(); + } + + private List> GetListingProviders() + { + return GetConfiguration().ListingProviders + .Select(i => + { + var provider = _liveTvManager.ListingProviders.FirstOrDefault(l => string.Equals(l.Type, i.Type, StringComparison.OrdinalIgnoreCase)); + + return provider is null ? null : new Tuple(provider, i); + }) + .Where(i => i is not null) + .ToList(); + } + + public Task GetChannelStream(string channelId, string streamId, CancellationToken cancellationToken) + { + throw new NotImplementedException(); + } + + public async Task GetChannelStreamWithDirectStreamProvider(string channelId, string streamId, List currentLiveStreams, CancellationToken cancellationToken) + { + _logger.LogInformation("Streaming Channel {Id}", channelId); + + var result = string.IsNullOrEmpty(streamId) ? + null : + currentLiveStreams.FirstOrDefault(i => string.Equals(i.OriginalStreamId, streamId, StringComparison.OrdinalIgnoreCase)); + + if (result is not null && result.EnableStreamSharing) + { + result.ConsumerCount++; + + _logger.LogInformation("Live stream {0} consumer count is now {1}", streamId, result.ConsumerCount); + + return result; + } + + foreach (var hostInstance in _liveTvManager.TunerHosts) + { + try + { + result = await hostInstance.GetChannelStream(channelId, streamId, currentLiveStreams, cancellationToken).ConfigureAwait(false); + + var openedMediaSource = result.MediaSource; + + result.OriginalStreamId = streamId; + + _logger.LogInformation("Returning mediasource streamId {0}, mediaSource.Id {1}, mediaSource.LiveStreamId {2}", streamId, openedMediaSource.Id, openedMediaSource.LiveStreamId); + + return result; + } + catch (FileNotFoundException) + { + } + catch (OperationCanceledException) + { + } + } + + throw new ResourceNotFoundException("Tuner not found."); + } + + public async Task> GetChannelStreamMediaSources(string channelId, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(channelId)) + { + throw new ArgumentNullException(nameof(channelId)); + } + + foreach (var hostInstance in _liveTvManager.TunerHosts) + { + try + { + var sources = await hostInstance.GetChannelStreamMediaSources(channelId, cancellationToken).ConfigureAwait(false); + + if (sources.Count > 0) + { + return sources; + } + } + catch (NotImplementedException) + { + } + } + + throw new NotImplementedException(); + } + + public Task CloseLiveStream(string id, CancellationToken cancellationToken) + { + return Task.CompletedTask; + } + + public Task RecordLiveStream(string id, CancellationToken cancellationToken) + { + return Task.CompletedTask; + } + + public Task ResetTuner(string id, CancellationToken cancellationToken) + { + return Task.CompletedTask; + } + + private async void OnTimerProviderTimerFired(object sender, GenericEventArgs e) + { + var timer = e.Argument; + + _logger.LogInformation("Recording timer fired for {0}.", timer.Name); + + try + { + var recordingEndDate = timer.EndDate.AddSeconds(timer.PostPaddingSeconds); + + if (recordingEndDate <= DateTime.UtcNow) + { + _logger.LogWarning("Recording timer fired for updatedTimer {0}, Id: {1}, but the program has already ended.", timer.Name, timer.Id); + OnTimerOutOfDate(timer); + return; + } + + var activeRecordingInfo = new ActiveRecordingInfo + { + CancellationTokenSource = new CancellationTokenSource(), + Timer = timer, + Id = timer.Id + }; + + if (!_activeRecordings.ContainsKey(timer.Id)) + { + await RecordStream(timer, recordingEndDate, activeRecordingInfo).ConfigureAwait(false); + } + else + { + _logger.LogInformation("Skipping RecordStream because it's already in progress."); + } + } + catch (OperationCanceledException) + { + } + catch (Exception ex) + { + _logger.LogError(ex, "Error recording stream"); + } + } + + private string GetRecordingPath(TimerInfo timer, RemoteSearchResult metadata, out string seriesPath) + { + var recordPath = RecordingPath; + var config = GetConfiguration(); + seriesPath = null; + + if (timer.IsProgramSeries) + { + var customRecordingPath = config.SeriesRecordingPath; + var allowSubfolder = true; + if (!string.IsNullOrWhiteSpace(customRecordingPath)) + { + allowSubfolder = string.Equals(customRecordingPath, recordPath, StringComparison.OrdinalIgnoreCase); + recordPath = customRecordingPath; + } + + if (allowSubfolder && config.EnableRecordingSubfolders) + { + recordPath = Path.Combine(recordPath, "Series"); + } + + // trim trailing period from the folder name + var folderName = _fileSystem.GetValidFilename(timer.Name).Trim().TrimEnd('.').Trim(); + + if (metadata is not null && metadata.ProductionYear.HasValue) + { + folderName += " (" + metadata.ProductionYear.Value.ToString(CultureInfo.InvariantCulture) + ")"; + } + + // Can't use the year here in the folder name because it is the year of the episode, not the series. + recordPath = Path.Combine(recordPath, folderName); + + seriesPath = recordPath; + + if (timer.SeasonNumber.HasValue) + { + folderName = string.Format( + CultureInfo.InvariantCulture, + "Season {0}", + timer.SeasonNumber.Value); + recordPath = Path.Combine(recordPath, folderName); + } + } + else if (timer.IsMovie) + { + var customRecordingPath = config.MovieRecordingPath; + var allowSubfolder = true; + if (!string.IsNullOrWhiteSpace(customRecordingPath)) + { + allowSubfolder = string.Equals(customRecordingPath, recordPath, StringComparison.OrdinalIgnoreCase); + recordPath = customRecordingPath; + } + + if (allowSubfolder && config.EnableRecordingSubfolders) + { + recordPath = Path.Combine(recordPath, "Movies"); + } + + var folderName = _fileSystem.GetValidFilename(timer.Name).Trim(); + if (timer.ProductionYear.HasValue) + { + folderName += " (" + timer.ProductionYear.Value.ToString(CultureInfo.InvariantCulture) + ")"; + } + + // trim trailing period from the folder name + folderName = folderName.TrimEnd('.').Trim(); + + recordPath = Path.Combine(recordPath, folderName); + } + else if (timer.IsKids) + { + if (config.EnableRecordingSubfolders) + { + recordPath = Path.Combine(recordPath, "Kids"); + } + + var folderName = _fileSystem.GetValidFilename(timer.Name).Trim(); + if (timer.ProductionYear.HasValue) + { + folderName += " (" + timer.ProductionYear.Value.ToString(CultureInfo.InvariantCulture) + ")"; + } + + // trim trailing period from the folder name + folderName = folderName.TrimEnd('.').Trim(); + + recordPath = Path.Combine(recordPath, folderName); + } + else if (timer.IsSports) + { + if (config.EnableRecordingSubfolders) + { + recordPath = Path.Combine(recordPath, "Sports"); + } + + recordPath = Path.Combine(recordPath, _fileSystem.GetValidFilename(timer.Name).Trim()); + } + else + { + if (config.EnableRecordingSubfolders) + { + recordPath = Path.Combine(recordPath, "Other"); + } + + recordPath = Path.Combine(recordPath, _fileSystem.GetValidFilename(timer.Name).Trim()); + } + + var recordingFileName = _fileSystem.GetValidFilename(RecordingHelper.GetRecordingName(timer)).Trim() + ".ts"; + + return Path.Combine(recordPath, recordingFileName); + } + + private async Task RecordStream(TimerInfo timer, DateTime recordingEndDate, ActiveRecordingInfo activeRecordingInfo) + { + ArgumentNullException.ThrowIfNull(timer); + + LiveTvProgram programInfo = null; + + if (!string.IsNullOrWhiteSpace(timer.ProgramId)) + { + programInfo = GetProgramInfoFromCache(timer); + } + + if (programInfo is null) + { + _logger.LogInformation("Unable to find program with Id {0}. Will search using start date", timer.ProgramId); + programInfo = GetProgramInfoFromCache(timer.ChannelId, timer.StartDate); + } + + if (programInfo is not null) + { + CopyProgramInfoToTimerInfo(programInfo, timer); + } + + var remoteMetadata = await FetchInternetMetadata(timer, CancellationToken.None).ConfigureAwait(false); + var recordPath = GetRecordingPath(timer, remoteMetadata, out string seriesPath); + + var channelItem = _liveTvManager.GetLiveTvChannel(timer, this); + + string liveStreamId = null; + RecordingStatus recordingStatus; + try + { + var allMediaSources = await _mediaSourceManager.GetPlaybackMediaSources(channelItem, null, true, false, CancellationToken.None).ConfigureAwait(false); + + var mediaStreamInfo = allMediaSources[0]; + IDirectStreamProvider directStreamProvider = null; + + if (mediaStreamInfo.RequiresOpening) + { + var liveStreamResponse = await _mediaSourceManager.OpenLiveStreamInternal( + new LiveStreamRequest + { + ItemId = channelItem.Id, + OpenToken = mediaStreamInfo.OpenToken + }, + CancellationToken.None).ConfigureAwait(false); + + mediaStreamInfo = liveStreamResponse.Item1.MediaSource; + liveStreamId = mediaStreamInfo.LiveStreamId; + directStreamProvider = liveStreamResponse.Item2; + } + + using var recorder = GetRecorder(mediaStreamInfo); + + recordPath = recorder.GetOutputPath(mediaStreamInfo, recordPath); + recordPath = EnsureFileUnique(recordPath, timer.Id); + + _libraryMonitor.ReportFileSystemChangeBeginning(recordPath); + + var duration = recordingEndDate - DateTime.UtcNow; + + _logger.LogInformation("Beginning recording. Will record for {0} minutes.", duration.TotalMinutes.ToString(CultureInfo.InvariantCulture)); + + _logger.LogInformation("Writing file to: {Path}", recordPath); + + Action onStarted = async () => + { + activeRecordingInfo.Path = recordPath; + + _activeRecordings.TryAdd(timer.Id, activeRecordingInfo); + + timer.Status = RecordingStatus.InProgress; + _timerProvider.AddOrUpdate(timer, false); + + await SaveRecordingMetadata(timer, recordPath, seriesPath).ConfigureAwait(false); + + await CreateRecordingFolders().ConfigureAwait(false); + + TriggerRefresh(recordPath); + await EnforceKeepUpTo(timer, seriesPath).ConfigureAwait(false); + }; + + await recorder.Record(directStreamProvider, mediaStreamInfo, recordPath, duration, onStarted, activeRecordingInfo.CancellationTokenSource.Token).ConfigureAwait(false); + + recordingStatus = RecordingStatus.Completed; + _logger.LogInformation("Recording completed: {RecordPath}", recordPath); + } + catch (OperationCanceledException) + { + _logger.LogInformation("Recording stopped: {RecordPath}", recordPath); + recordingStatus = RecordingStatus.Completed; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error recording to {RecordPath}", recordPath); + recordingStatus = RecordingStatus.Error; + } + + if (!string.IsNullOrWhiteSpace(liveStreamId)) + { + try + { + await _mediaSourceManager.CloseLiveStream(liveStreamId).ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error closing live stream"); + } + } + + DeleteFileIfEmpty(recordPath); + + TriggerRefresh(recordPath); + _libraryMonitor.ReportFileSystemChangeComplete(recordPath, false); + + _activeRecordings.TryRemove(timer.Id, out _); + + if (recordingStatus != RecordingStatus.Completed && DateTime.UtcNow < timer.EndDate && timer.RetryCount < 10) + { + const int RetryIntervalSeconds = 60; + _logger.LogInformation("Retrying recording in {0} seconds.", RetryIntervalSeconds); + + timer.Status = RecordingStatus.New; + timer.PrePaddingSeconds = 0; + timer.StartDate = DateTime.UtcNow.AddSeconds(RetryIntervalSeconds); + timer.RetryCount++; + _timerProvider.AddOrUpdate(timer); + } + else if (File.Exists(recordPath)) + { + timer.RecordingPath = recordPath; + timer.Status = RecordingStatus.Completed; + _timerProvider.AddOrUpdate(timer, false); + OnSuccessfulRecording(timer, recordPath); + } + else + { + _timerProvider.Delete(timer); + } + } + + private async Task FetchInternetMetadata(TimerInfo timer, CancellationToken cancellationToken) + { + if (timer.IsSeries) + { + if (timer.SeriesProviderIds.Count == 0) + { + return null; + } + + var query = new RemoteSearchQuery() + { + SearchInfo = new SeriesInfo + { + ProviderIds = timer.SeriesProviderIds, + Name = timer.Name, + MetadataCountryCode = _config.Configuration.MetadataCountryCode, + MetadataLanguage = _config.Configuration.PreferredMetadataLanguage + } + }; + + var results = await _providerManager.GetRemoteSearchResults(query, cancellationToken).ConfigureAwait(false); + + return results.FirstOrDefault(); + } + + return null; + } + + private void DeleteFileIfEmpty(string path) + { + var file = _fileSystem.GetFileInfo(path); + + if (file.Exists && file.Length == 0) + { + try + { + _fileSystem.DeleteFile(path); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error deleting 0-byte failed recording file {Path}", path); + } + } + } + + private void TriggerRefresh(string path) + { + _logger.LogInformation("Triggering refresh on {Path}", path); + + var item = GetAffectedBaseItem(Path.GetDirectoryName(path)); + + if (item is not null) + { + _logger.LogInformation("Refreshing recording parent {Path}", item.Path); + + _providerManager.QueueRefresh( + item.Id, + new MetadataRefreshOptions(new DirectoryService(_fileSystem)) + { + RefreshPaths = new string[] + { + path, + Path.GetDirectoryName(path), + Path.GetDirectoryName(Path.GetDirectoryName(path)) + } + }, + RefreshPriority.High); + } + } + + private BaseItem GetAffectedBaseItem(string path) + { + BaseItem item = null; + + var parentPath = Path.GetDirectoryName(path); + + while (item is null && !string.IsNullOrEmpty(path)) + { + item = _libraryManager.FindByPath(path, null); + + path = Path.GetDirectoryName(path); + } + + if (item is not null) + { + if (item.GetType() == typeof(Folder) && string.Equals(item.Path, parentPath, StringComparison.OrdinalIgnoreCase)) + { + var parentItem = item.GetParent(); + if (parentItem is not null && parentItem is not AggregateFolder) + { + item = parentItem; + } + } + } + + return item; + } + + private async Task EnforceKeepUpTo(TimerInfo timer, string seriesPath) + { + if (string.IsNullOrWhiteSpace(timer.SeriesTimerId)) + { + return; + } + + if (string.IsNullOrWhiteSpace(seriesPath)) + { + return; + } + + var seriesTimerId = timer.SeriesTimerId; + var seriesTimer = _seriesTimerProvider.GetAll().FirstOrDefault(i => string.Equals(i.Id, seriesTimerId, StringComparison.OrdinalIgnoreCase)); + + if (seriesTimer is null || seriesTimer.KeepUpTo <= 0) + { + return; + } + + if (_disposed) + { + return; + } + + await _recordingDeleteSemaphore.WaitAsync().ConfigureAwait(false); + + try + { + if (_disposed) + { + return; + } + + var timersToDelete = _timerProvider.GetAll() + .Where(i => i.Status == RecordingStatus.Completed && !string.IsNullOrWhiteSpace(i.RecordingPath)) + .Where(i => string.Equals(i.SeriesTimerId, seriesTimerId, StringComparison.OrdinalIgnoreCase)) + .OrderByDescending(i => i.EndDate) + .Where(i => File.Exists(i.RecordingPath)) + .Skip(seriesTimer.KeepUpTo - 1) + .ToList(); + + DeleteLibraryItemsForTimers(timersToDelete); + + if (_libraryManager.FindByPath(seriesPath, true) is not Folder librarySeries) + { + return; + } + + var episodesToDelete = librarySeries.GetItemList( + new InternalItemsQuery + { + OrderBy = new[] { (ItemSortBy.DateCreated, SortOrder.Descending) }, + IsVirtualItem = false, + IsFolder = false, + Recursive = true, + DtoOptions = new DtoOptions(true) + }) + .Where(i => i.IsFileProtocol && File.Exists(i.Path)) + .Skip(seriesTimer.KeepUpTo - 1) + .ToList(); + + foreach (var item in episodesToDelete) + { + try + { + _libraryManager.DeleteItem( + item, + new DeleteOptions + { + DeleteFileLocation = true + }, + true); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error deleting item"); + } + } + } + finally + { + _recordingDeleteSemaphore.Release(); + } + } + + private void DeleteLibraryItemsForTimers(List timers) + { + foreach (var timer in timers) + { + if (_disposed) + { + return; + } + + try + { + DeleteLibraryItemForTimer(timer); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error deleting recording"); + } + } + } + + private void DeleteLibraryItemForTimer(TimerInfo timer) + { + var libraryItem = _libraryManager.FindByPath(timer.RecordingPath, false); + + if (libraryItem is not null) + { + _libraryManager.DeleteItem( + libraryItem, + new DeleteOptions + { + DeleteFileLocation = true + }, + true); + } + else if (File.Exists(timer.RecordingPath)) + { + _fileSystem.DeleteFile(timer.RecordingPath); + } + + _timerProvider.Delete(timer); + } + + private string EnsureFileUnique(string path, string timerId) + { + var originalPath = path; + var index = 1; + + while (FileExists(path, timerId)) + { + var parent = Path.GetDirectoryName(originalPath); + var name = Path.GetFileNameWithoutExtension(originalPath); + name += " - " + index.ToString(CultureInfo.InvariantCulture); + + path = Path.ChangeExtension(Path.Combine(parent, name), Path.GetExtension(originalPath)); + index++; + } + + return path; + } + + private bool FileExists(string path, string timerId) + { + if (File.Exists(path)) + { + return true; + } + + return _activeRecordings + .Any(i => string.Equals(i.Value.Path, path, StringComparison.OrdinalIgnoreCase) && !string.Equals(i.Value.Timer.Id, timerId, StringComparison.OrdinalIgnoreCase)); + } + + private IRecorder GetRecorder(MediaSourceInfo mediaSource) + { + if (mediaSource.RequiresLooping || !(mediaSource.Container ?? string.Empty).EndsWith("ts", StringComparison.OrdinalIgnoreCase) || (mediaSource.Protocol != MediaProtocol.File && mediaSource.Protocol != MediaProtocol.Http)) + { + return new EncodedRecorder(_logger, _mediaEncoder, _config.ApplicationPaths, _config); + } + + return new DirectRecorder(_logger, _httpClientFactory, _streamHelper); + } + + private void OnSuccessfulRecording(TimerInfo timer, string path) + { + PostProcessRecording(timer, path); + } + + private void PostProcessRecording(TimerInfo timer, string path) + { + var options = GetConfiguration(); + if (string.IsNullOrWhiteSpace(options.RecordingPostProcessor)) + { + return; + } + + try + { + var process = new Process + { + StartInfo = new ProcessStartInfo + { + Arguments = GetPostProcessArguments(path, options.RecordingPostProcessorArguments), + CreateNoWindow = true, + ErrorDialog = false, + FileName = options.RecordingPostProcessor, + WindowStyle = ProcessWindowStyle.Hidden, + UseShellExecute = false + }, + EnableRaisingEvents = true + }; + + _logger.LogInformation("Running recording post processor {0} {1}", process.StartInfo.FileName, process.StartInfo.Arguments); + + process.Exited += OnProcessExited; + process.Start(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error running recording post processor"); + } + } + + private static string GetPostProcessArguments(string path, string arguments) + { + return arguments.Replace("{path}", path, StringComparison.OrdinalIgnoreCase); + } + + private void OnProcessExited(object sender, EventArgs e) + { + using (var process = (Process)sender) + { + _logger.LogInformation("Recording post-processing script completed with exit code {ExitCode}", process.ExitCode); + } + } + + private async Task SaveRecordingImage(string recordingPath, LiveTvProgram program, ItemImageInfo image) + { + if (!image.IsLocalFile) + { + image = await _libraryManager.ConvertImageToLocal(program, image, 0).ConfigureAwait(false); + } + + string imageSaveFilenameWithoutExtension = image.Type switch + { + ImageType.Primary => program.IsSeries ? Path.GetFileNameWithoutExtension(recordingPath) + "-thumb" : "poster", + ImageType.Logo => "logo", + ImageType.Thumb => program.IsSeries ? Path.GetFileNameWithoutExtension(recordingPath) + "-thumb" : "landscape", + ImageType.Backdrop => "fanart", + _ => null + }; + + if (imageSaveFilenameWithoutExtension is null) + { + return; + } + + var imageSavePath = Path.Combine(Path.GetDirectoryName(recordingPath), imageSaveFilenameWithoutExtension); + + // preserve original image extension + imageSavePath = Path.ChangeExtension(imageSavePath, Path.GetExtension(image.Path)); + + File.Copy(image.Path, imageSavePath, true); + } + + private async Task SaveRecordingImages(string recordingPath, LiveTvProgram program) + { + var image = program.IsSeries ? + (program.GetImageInfo(ImageType.Thumb, 0) ?? program.GetImageInfo(ImageType.Primary, 0)) : + (program.GetImageInfo(ImageType.Primary, 0) ?? program.GetImageInfo(ImageType.Thumb, 0)); + + if (image is not null) + { + try + { + await SaveRecordingImage(recordingPath, program, image).ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error saving recording image"); + } + } + + if (!program.IsSeries) + { + image = program.GetImageInfo(ImageType.Backdrop, 0); + if (image is not null) + { + try + { + await SaveRecordingImage(recordingPath, program, image).ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error saving recording image"); + } + } + + image = program.GetImageInfo(ImageType.Thumb, 0); + if (image is not null) + { + try + { + await SaveRecordingImage(recordingPath, program, image).ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error saving recording image"); + } + } + + image = program.GetImageInfo(ImageType.Logo, 0); + if (image is not null) + { + try + { + await SaveRecordingImage(recordingPath, program, image).ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error saving recording image"); + } + } + } + } + + private async Task SaveRecordingMetadata(TimerInfo timer, string recordingPath, string seriesPath) + { + try + { + var program = string.IsNullOrWhiteSpace(timer.ProgramId) ? null : _libraryManager.GetItemList(new InternalItemsQuery + { + IncludeItemTypes = new[] { BaseItemKind.LiveTvProgram }, + Limit = 1, + ExternalId = timer.ProgramId, + DtoOptions = new DtoOptions(true) + }).FirstOrDefault() as LiveTvProgram; + + // dummy this up + if (program is null) + { + program = new LiveTvProgram + { + Name = timer.Name, + Overview = timer.Overview, + Genres = timer.Genres, + CommunityRating = timer.CommunityRating, + OfficialRating = timer.OfficialRating, + ProductionYear = timer.ProductionYear, + PremiereDate = timer.OriginalAirDate, + IndexNumber = timer.EpisodeNumber, + ParentIndexNumber = timer.SeasonNumber + }; + } + + if (timer.IsSports) + { + program.AddGenre("Sports"); + } + + if (timer.IsKids) + { + program.AddGenre("Kids"); + program.AddGenre("Children"); + } + + if (timer.IsNews) + { + program.AddGenre("News"); + } + + var config = GetConfiguration(); + + if (config.SaveRecordingNFO) + { + if (timer.IsProgramSeries) + { + await SaveSeriesNfoAsync(timer, seriesPath).ConfigureAwait(false); + await SaveVideoNfoAsync(timer, recordingPath, program, false).ConfigureAwait(false); + } + else if (!timer.IsMovie || timer.IsSports || timer.IsNews) + { + await SaveVideoNfoAsync(timer, recordingPath, program, true).ConfigureAwait(false); + } + else + { + await SaveVideoNfoAsync(timer, recordingPath, program, false).ConfigureAwait(false); + } + } + + if (config.SaveRecordingImages) + { + await SaveRecordingImages(recordingPath, program).ConfigureAwait(false); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Error saving nfo"); + } + } + + private async Task SaveSeriesNfoAsync(TimerInfo timer, string seriesPath) + { + var nfoPath = Path.Combine(seriesPath, "tvshow.nfo"); + + if (File.Exists(nfoPath)) + { + return; + } + + var stream = new FileStream(nfoPath, FileMode.CreateNew, FileAccess.Write, FileShare.None); + await using (stream.ConfigureAwait(false)) + { + var settings = new XmlWriterSettings + { + Indent = true, + Encoding = Encoding.UTF8, + Async = true + }; + + var writer = XmlWriter.Create(stream, settings); + await using (writer.ConfigureAwait(false)) + { + await writer.WriteStartDocumentAsync(true).ConfigureAwait(false); + await writer.WriteStartElementAsync(null, "tvshow", null).ConfigureAwait(false); + if (timer.SeriesProviderIds.TryGetValue(MetadataProvider.Tvdb.ToString(), out var id)) + { + await writer.WriteElementStringAsync(null, "id", null, id).ConfigureAwait(false); + } + + if (timer.SeriesProviderIds.TryGetValue(MetadataProvider.Imdb.ToString(), out id)) + { + await writer.WriteElementStringAsync(null, "imdb_id", null, id).ConfigureAwait(false); + } + + if (timer.SeriesProviderIds.TryGetValue(MetadataProvider.Tmdb.ToString(), out id)) + { + await writer.WriteElementStringAsync(null, "tmdbid", null, id).ConfigureAwait(false); + } + + if (timer.SeriesProviderIds.TryGetValue(MetadataProvider.Zap2It.ToString(), out id)) + { + await writer.WriteElementStringAsync(null, "zap2itid", null, id).ConfigureAwait(false); + } + + if (!string.IsNullOrWhiteSpace(timer.Name)) + { + await writer.WriteElementStringAsync(null, "title", null, timer.Name).ConfigureAwait(false); + } + + if (!string.IsNullOrWhiteSpace(timer.OfficialRating)) + { + await writer.WriteElementStringAsync(null, "mpaa", null, timer.OfficialRating).ConfigureAwait(false); + } + + foreach (var genre in timer.Genres) + { + await writer.WriteElementStringAsync(null, "genre", null, genre).ConfigureAwait(false); + } + + await writer.WriteEndElementAsync().ConfigureAwait(false); + await writer.WriteEndDocumentAsync().ConfigureAwait(false); + } + } + } + + private async Task SaveVideoNfoAsync(TimerInfo timer, string recordingPath, BaseItem item, bool lockData) + { + var nfoPath = Path.ChangeExtension(recordingPath, ".nfo"); + + if (File.Exists(nfoPath)) + { + return; + } + + var stream = new FileStream(nfoPath, FileMode.CreateNew, FileAccess.Write, FileShare.None); + await using (stream.ConfigureAwait(false)) + { + var settings = new XmlWriterSettings + { + Indent = true, + Encoding = Encoding.UTF8, + Async = true + }; + + var options = _config.GetNfoConfiguration(); + + var isSeriesEpisode = timer.IsProgramSeries; + + var writer = XmlWriter.Create(stream, settings); + await using (writer.ConfigureAwait(false)) + { + await writer.WriteStartDocumentAsync(true).ConfigureAwait(false); + + if (isSeriesEpisode) + { + await writer.WriteStartElementAsync(null, "episodedetails", null).ConfigureAwait(false); + + if (!string.IsNullOrWhiteSpace(timer.EpisodeTitle)) + { + await writer.WriteElementStringAsync(null, "title", null, timer.EpisodeTitle).ConfigureAwait(false); + } + + var premiereDate = item.PremiereDate ?? (!timer.IsRepeat ? DateTime.UtcNow : null); + + if (premiereDate.HasValue) + { + var formatString = options.ReleaseDateFormat; + + await writer.WriteElementStringAsync( + null, + "aired", + null, + premiereDate.Value.ToLocalTime().ToString(formatString, CultureInfo.InvariantCulture)).ConfigureAwait(false); + } + + if (item.IndexNumber.HasValue) + { + await writer.WriteElementStringAsync(null, "episode", null, item.IndexNumber.Value.ToString(CultureInfo.InvariantCulture)).ConfigureAwait(false); + } + + if (item.ParentIndexNumber.HasValue) + { + await writer.WriteElementStringAsync(null, "season", null, item.ParentIndexNumber.Value.ToString(CultureInfo.InvariantCulture)).ConfigureAwait(false); + } + } + else + { + await writer.WriteStartElementAsync(null, "movie", null).ConfigureAwait(false); + + if (!string.IsNullOrWhiteSpace(item.Name)) + { + await writer.WriteElementStringAsync(null, "title", null, item.Name).ConfigureAwait(false); + } + + if (!string.IsNullOrWhiteSpace(item.OriginalTitle)) + { + await writer.WriteElementStringAsync(null, "originaltitle", null, item.OriginalTitle).ConfigureAwait(false); + } + + if (item.PremiereDate.HasValue) + { + var formatString = options.ReleaseDateFormat; + + await writer.WriteElementStringAsync( + null, + "premiered", + null, + item.PremiereDate.Value.ToLocalTime().ToString(formatString, CultureInfo.InvariantCulture)).ConfigureAwait(false); + await writer.WriteElementStringAsync( + null, + "releasedate", + null, + item.PremiereDate.Value.ToLocalTime().ToString(formatString, CultureInfo.InvariantCulture)).ConfigureAwait(false); + } + } + + await writer.WriteElementStringAsync( + null, + "dateadded", + null, + DateTime.Now.ToString(DateAddedFormat, CultureInfo.InvariantCulture)).ConfigureAwait(false); + + if (item.ProductionYear.HasValue) + { + await writer.WriteElementStringAsync(null, "year", null, item.ProductionYear.Value.ToString(CultureInfo.InvariantCulture)).ConfigureAwait(false); + } + + if (!string.IsNullOrEmpty(item.OfficialRating)) + { + await writer.WriteElementStringAsync(null, "mpaa", null, item.OfficialRating).ConfigureAwait(false); + } + + var overview = (item.Overview ?? string.Empty) + .StripHtml() + .Replace(""", "'", StringComparison.Ordinal); + + await writer.WriteElementStringAsync(null, "plot", null, overview).ConfigureAwait(false); + + if (item.CommunityRating.HasValue) + { + await writer.WriteElementStringAsync(null, "rating", null, item.CommunityRating.Value.ToString(CultureInfo.InvariantCulture)).ConfigureAwait(false); + } + + foreach (var genre in item.Genres) + { + await writer.WriteElementStringAsync(null, "genre", null, genre).ConfigureAwait(false); + } + + var people = item.Id.Equals(default) ? new List() : _libraryManager.GetPeople(item); + + var directors = people + .Where(i => i.IsType(PersonKind.Director)) + .Select(i => i.Name) + .ToList(); + + foreach (var person in directors) + { + await writer.WriteElementStringAsync(null, "director", null, person).ConfigureAwait(false); + } + + var writers = people + .Where(i => i.IsType(PersonKind.Writer)) + .Select(i => i.Name) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + + foreach (var person in writers) + { + await writer.WriteElementStringAsync(null, "writer", null, person).ConfigureAwait(false); + } + + foreach (var person in writers) + { + await writer.WriteElementStringAsync(null, "credits", null, person).ConfigureAwait(false); + } + + var tmdbCollection = item.GetProviderId(MetadataProvider.TmdbCollection); + + if (!string.IsNullOrEmpty(tmdbCollection)) + { + await writer.WriteElementStringAsync(null, "collectionnumber", null, tmdbCollection).ConfigureAwait(false); + } + + var imdb = item.GetProviderId(MetadataProvider.Imdb); + if (!string.IsNullOrEmpty(imdb)) + { + if (!isSeriesEpisode) + { + await writer.WriteElementStringAsync(null, "id", null, imdb).ConfigureAwait(false); + } + + await writer.WriteElementStringAsync(null, "imdbid", null, imdb).ConfigureAwait(false); + + // No need to lock if we have identified the content already + lockData = false; + } + + var tvdb = item.GetProviderId(MetadataProvider.Tvdb); + if (!string.IsNullOrEmpty(tvdb)) + { + await writer.WriteElementStringAsync(null, "tvdbid", null, tvdb).ConfigureAwait(false); + + // No need to lock if we have identified the content already + lockData = false; + } + + var tmdb = item.GetProviderId(MetadataProvider.Tmdb); + if (!string.IsNullOrEmpty(tmdb)) + { + await writer.WriteElementStringAsync(null, "tmdbid", null, tmdb).ConfigureAwait(false); + + // No need to lock if we have identified the content already + lockData = false; + } + + if (lockData) + { + await writer.WriteElementStringAsync(null, "lockdata", null, "true").ConfigureAwait(false); + } + + if (item.CriticRating.HasValue) + { + await writer.WriteElementStringAsync(null, "criticrating", null, item.CriticRating.Value.ToString(CultureInfo.InvariantCulture)).ConfigureAwait(false); + } + + if (!string.IsNullOrWhiteSpace(item.Tagline)) + { + await writer.WriteElementStringAsync(null, "tagline", null, item.Tagline).ConfigureAwait(false); + } + + foreach (var studio in item.Studios) + { + await writer.WriteElementStringAsync(null, "studio", null, studio).ConfigureAwait(false); + } + + await writer.WriteEndElementAsync().ConfigureAwait(false); + await writer.WriteEndDocumentAsync().ConfigureAwait(false); + } + } + } + + private LiveTvProgram GetProgramInfoFromCache(string programId) + { + var query = new InternalItemsQuery + { + ItemIds = new[] { _liveTvManager.GetInternalProgramId(programId) }, + Limit = 1, + DtoOptions = new DtoOptions() + }; + + return _libraryManager.GetItemList(query).Cast().FirstOrDefault(); + } + + private LiveTvProgram GetProgramInfoFromCache(TimerInfo timer) + { + return GetProgramInfoFromCache(timer.ProgramId); + } + + private LiveTvProgram GetProgramInfoFromCache(string channelId, DateTime startDateUtc) + { + var query = new InternalItemsQuery + { + IncludeItemTypes = new[] { BaseItemKind.LiveTvProgram }, + Limit = 1, + DtoOptions = new DtoOptions(true) + { + EnableImages = false + }, + MinStartDate = startDateUtc.AddMinutes(-3), + MaxStartDate = startDateUtc.AddMinutes(3), + OrderBy = new[] { (ItemSortBy.StartDate, SortOrder.Ascending) } + }; + + if (!string.IsNullOrWhiteSpace(channelId)) + { + query.ChannelIds = new[] { _liveTvManager.GetInternalChannelId(Name, channelId) }; + } + + return _libraryManager.GetItemList(query).Cast().FirstOrDefault(); + } + + private LiveTvOptions GetConfiguration() + { + return _config.GetConfiguration("livetv"); + } + + private bool ShouldCancelTimerForSeriesTimer(SeriesTimerInfo seriesTimer, TimerInfo timer) + { + if (timer.IsManual) + { + return false; + } + + if (!seriesTimer.RecordAnyTime + && Math.Abs(seriesTimer.StartDate.TimeOfDay.Ticks - timer.StartDate.TimeOfDay.Ticks) >= TimeSpan.FromMinutes(10).Ticks) + { + return true; + } + + if (seriesTimer.RecordNewOnly && timer.IsRepeat) + { + return true; + } + + if (!seriesTimer.RecordAnyChannel + && !string.Equals(timer.ChannelId, seriesTimer.ChannelId, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + return seriesTimer.SkipEpisodesInLibrary && IsProgramAlreadyInLibrary(timer); + } + + private void HandleDuplicateShowIds(List timers) + { + // sort showings by HD channels first, then by startDate, record earliest showing possible + foreach (var timer in timers.OrderByDescending(t => _liveTvManager.GetLiveTvChannel(t, this).IsHD).ThenBy(t => t.StartDate).Skip(1)) + { + timer.Status = RecordingStatus.Cancelled; + _timerProvider.Update(timer); + } + } + + private void SearchForDuplicateShowIds(IEnumerable timers) + { + var groups = timers.ToLookup(i => i.ShowId ?? string.Empty).ToList(); + + foreach (var group in groups) + { + if (string.IsNullOrWhiteSpace(group.Key)) + { + continue; + } + + var groupTimers = group.ToList(); + + if (groupTimers.Count < 2) + { + continue; + } + + // Skip ShowId without SubKey from duplicate removal actions - https://github.com/jellyfin/jellyfin/issues/5856 + if (group.Key.EndsWith("0000", StringComparison.Ordinal)) + { + continue; + } + + HandleDuplicateShowIds(groupTimers); + } + } + + private void UpdateTimersForSeriesTimer(SeriesTimerInfo seriesTimer, bool updateTimerSettings, bool deleteInvalidTimers) + { + var allTimers = GetTimersForSeries(seriesTimer).ToList(); + + var enabledTimersForSeries = new List(); + foreach (var timer in allTimers) + { + var existingTimer = _timerProvider.GetTimer(timer.Id) + ?? (string.IsNullOrWhiteSpace(timer.ProgramId) + ? null + : _timerProvider.GetTimerByProgramId(timer.ProgramId)); + + if (existingTimer is null) + { + if (ShouldCancelTimerForSeriesTimer(seriesTimer, timer)) + { + timer.Status = RecordingStatus.Cancelled; + } + else + { + enabledTimersForSeries.Add(timer); + } + + _timerProvider.Add(timer); + + TimerCreated?.Invoke(this, new GenericEventArgs(timer)); + } + + // Only update if not currently active - test both new timer and existing in case Id's are different + // Id's could be different if the timer was created manually prior to series timer creation + else if (!_activeRecordings.TryGetValue(timer.Id, out _) && !_activeRecordings.TryGetValue(existingTimer.Id, out _)) + { + UpdateExistingTimerWithNewMetadata(existingTimer, timer); + + // Needed by ShouldCancelTimerForSeriesTimer + timer.IsManual = existingTimer.IsManual; + + if (ShouldCancelTimerForSeriesTimer(seriesTimer, timer)) + { + existingTimer.Status = RecordingStatus.Cancelled; + } + else if (!existingTimer.IsManual) + { + existingTimer.Status = RecordingStatus.New; + } + + if (existingTimer.Status != RecordingStatus.Cancelled) + { + enabledTimersForSeries.Add(existingTimer); + } + + if (updateTimerSettings) + { + existingTimer.KeepUntil = seriesTimer.KeepUntil; + existingTimer.IsPostPaddingRequired = seriesTimer.IsPostPaddingRequired; + existingTimer.IsPrePaddingRequired = seriesTimer.IsPrePaddingRequired; + existingTimer.PostPaddingSeconds = seriesTimer.PostPaddingSeconds; + existingTimer.PrePaddingSeconds = seriesTimer.PrePaddingSeconds; + existingTimer.Priority = seriesTimer.Priority; + existingTimer.SeriesTimerId = seriesTimer.Id; + } + + existingTimer.SeriesTimerId = seriesTimer.Id; + _timerProvider.Update(existingTimer); + } + } + + SearchForDuplicateShowIds(enabledTimersForSeries); + + if (deleteInvalidTimers) + { + var allTimerIds = allTimers + .Select(i => i.Id) + .ToList(); + + var deleteStatuses = new[] + { + RecordingStatus.New + }; + + var deletes = _timerProvider.GetAll() + .Where(i => string.Equals(i.SeriesTimerId, seriesTimer.Id, StringComparison.OrdinalIgnoreCase)) + .Where(i => !allTimerIds.Contains(i.Id, StringComparison.OrdinalIgnoreCase) && i.StartDate > DateTime.UtcNow) + .Where(i => deleteStatuses.Contains(i.Status)) + .ToList(); + + foreach (var timer in deletes) + { + CancelTimerInternal(timer.Id, false, false); + } + } + } + + private IEnumerable GetTimersForSeries(SeriesTimerInfo seriesTimer) + { + ArgumentNullException.ThrowIfNull(seriesTimer); + + var query = new InternalItemsQuery + { + IncludeItemTypes = new[] { BaseItemKind.LiveTvProgram }, + ExternalSeriesId = seriesTimer.SeriesId, + DtoOptions = new DtoOptions(true) + { + EnableImages = false + }, + MinEndDate = DateTime.UtcNow + }; + + if (string.IsNullOrEmpty(seriesTimer.SeriesId)) + { + query.Name = seriesTimer.Name; + } + + if (!seriesTimer.RecordAnyChannel) + { + query.ChannelIds = new[] { _liveTvManager.GetInternalChannelId(Name, seriesTimer.ChannelId) }; + } + + var tempChannelCache = new Dictionary(); + + return _libraryManager.GetItemList(query).Cast().Select(i => CreateTimer(i, seriesTimer, tempChannelCache)); + } + + private TimerInfo CreateTimer(LiveTvProgram parent, SeriesTimerInfo seriesTimer, Dictionary tempChannelCache) + { + string channelId = seriesTimer.RecordAnyChannel ? null : seriesTimer.ChannelId; + + if (string.IsNullOrWhiteSpace(channelId) && !parent.ChannelId.Equals(default)) + { + if (!tempChannelCache.TryGetValue(parent.ChannelId, out LiveTvChannel channel)) + { + channel = _libraryManager.GetItemList( + new InternalItemsQuery + { + IncludeItemTypes = new[] { BaseItemKind.LiveTvChannel }, + ItemIds = new[] { parent.ChannelId }, + DtoOptions = new DtoOptions() + }).FirstOrDefault() as LiveTvChannel; + + if (channel is not null && !string.IsNullOrWhiteSpace(channel.ExternalId)) + { + tempChannelCache[parent.ChannelId] = channel; + } + } + + if (channel is not null || tempChannelCache.TryGetValue(parent.ChannelId, out channel)) + { + channelId = channel.ExternalId; + } + } + + var timer = new TimerInfo + { + ChannelId = channelId, + Id = (seriesTimer.Id + parent.ExternalId).GetMD5().ToString("N", CultureInfo.InvariantCulture), + StartDate = parent.StartDate, + EndDate = parent.EndDate.Value, + ProgramId = parent.ExternalId, + PrePaddingSeconds = seriesTimer.PrePaddingSeconds, + PostPaddingSeconds = seriesTimer.PostPaddingSeconds, + IsPostPaddingRequired = seriesTimer.IsPostPaddingRequired, + IsPrePaddingRequired = seriesTimer.IsPrePaddingRequired, + KeepUntil = seriesTimer.KeepUntil, + Priority = seriesTimer.Priority, + Name = parent.Name, + Overview = parent.Overview, + SeriesId = parent.ExternalSeriesId, + SeriesTimerId = seriesTimer.Id, + ShowId = parent.ShowId + }; + + CopyProgramInfoToTimerInfo(parent, timer, tempChannelCache); + + return timer; + } + + private void CopyProgramInfoToTimerInfo(LiveTvProgram programInfo, TimerInfo timerInfo) + { + var tempChannelCache = new Dictionary(); + CopyProgramInfoToTimerInfo(programInfo, timerInfo, tempChannelCache); + } + + private void CopyProgramInfoToTimerInfo(LiveTvProgram programInfo, TimerInfo timerInfo, Dictionary tempChannelCache) + { + string channelId = null; + + if (!programInfo.ChannelId.Equals(default)) + { + if (!tempChannelCache.TryGetValue(programInfo.ChannelId, out LiveTvChannel channel)) + { + channel = _libraryManager.GetItemList( + new InternalItemsQuery + { + IncludeItemTypes = new[] { BaseItemKind.LiveTvChannel }, + ItemIds = new[] { programInfo.ChannelId }, + DtoOptions = new DtoOptions() + }).FirstOrDefault() as LiveTvChannel; + + if (channel is not null && !string.IsNullOrWhiteSpace(channel.ExternalId)) + { + tempChannelCache[programInfo.ChannelId] = channel; + } + } + + if (channel is not null || tempChannelCache.TryGetValue(programInfo.ChannelId, out channel)) + { + channelId = channel.ExternalId; + } + } + + timerInfo.Name = programInfo.Name; + timerInfo.StartDate = programInfo.StartDate; + timerInfo.EndDate = programInfo.EndDate.Value; + + if (!string.IsNullOrWhiteSpace(channelId)) + { + timerInfo.ChannelId = channelId; + } + + timerInfo.SeasonNumber = programInfo.ParentIndexNumber; + timerInfo.EpisodeNumber = programInfo.IndexNumber; + timerInfo.IsMovie = programInfo.IsMovie; + timerInfo.ProductionYear = programInfo.ProductionYear; + timerInfo.EpisodeTitle = programInfo.EpisodeTitle; + timerInfo.OriginalAirDate = programInfo.PremiereDate; + timerInfo.IsProgramSeries = programInfo.IsSeries; + + timerInfo.IsSeries = programInfo.IsSeries; + + timerInfo.CommunityRating = programInfo.CommunityRating; + timerInfo.Overview = programInfo.Overview; + timerInfo.OfficialRating = programInfo.OfficialRating; + timerInfo.IsRepeat = programInfo.IsRepeat; + timerInfo.SeriesId = programInfo.ExternalSeriesId; + timerInfo.ProviderIds = programInfo.ProviderIds; + timerInfo.Tags = programInfo.Tags; + + var seriesProviderIds = new Dictionary(StringComparer.OrdinalIgnoreCase); + + foreach (var providerId in timerInfo.ProviderIds) + { + const string Search = "Series"; + if (providerId.Key.StartsWith(Search, StringComparison.OrdinalIgnoreCase)) + { + seriesProviderIds[providerId.Key.Substring(Search.Length)] = providerId.Value; + } + } + + timerInfo.SeriesProviderIds = seriesProviderIds; + } + + private bool IsProgramAlreadyInLibrary(TimerInfo program) + { + if ((program.EpisodeNumber.HasValue && program.SeasonNumber.HasValue) || !string.IsNullOrWhiteSpace(program.EpisodeTitle)) + { + var seriesIds = _libraryManager.GetItemIds( + new InternalItemsQuery + { + IncludeItemTypes = new[] { BaseItemKind.Series }, + Name = program.Name + }).ToArray(); + + if (seriesIds.Length == 0) + { + return false; + } + + if (program.EpisodeNumber.HasValue && program.SeasonNumber.HasValue) + { + var result = _libraryManager.GetItemIds(new InternalItemsQuery + { + IncludeItemTypes = new[] { BaseItemKind.Episode }, + ParentIndexNumber = program.SeasonNumber.Value, + IndexNumber = program.EpisodeNumber.Value, + AncestorIds = seriesIds, + IsVirtualItem = false, + Limit = 1 + }); + + if (result.Count > 0) + { + return true; + } + } + } + + return false; + } + + /// + public void Dispose() + { + if (_disposed) + { + return; + } + + _recordingDeleteSemaphore.Dispose(); + + foreach (var pair in _activeRecordings.ToList()) + { + pair.Value.CancellationTokenSource.Cancel(); + } + + _disposed = true; + } + + public IEnumerable GetRecordingFolders() + { + var defaultFolder = RecordingPath; + var defaultName = "Recordings"; + + if (Directory.Exists(defaultFolder)) + { + yield return new VirtualFolderInfo + { + Locations = new string[] { defaultFolder }, + Name = defaultName + }; + } + + var customPath = GetConfiguration().MovieRecordingPath; + if (!string.IsNullOrWhiteSpace(customPath) && !string.Equals(customPath, defaultFolder, StringComparison.OrdinalIgnoreCase) && Directory.Exists(customPath)) + { + yield return new VirtualFolderInfo + { + Locations = new string[] { customPath }, + Name = "Recorded Movies", + CollectionType = CollectionTypeOptions.Movies + }; + } + + customPath = GetConfiguration().SeriesRecordingPath; + if (!string.IsNullOrWhiteSpace(customPath) && !string.Equals(customPath, defaultFolder, StringComparison.OrdinalIgnoreCase) && Directory.Exists(customPath)) + { + yield return new VirtualFolderInfo + { + Locations = new string[] { customPath }, + Name = "Recorded Shows", + CollectionType = CollectionTypeOptions.TvShows + }; + } + } + + public async Task> DiscoverTuners(bool newDevicesOnly, CancellationToken cancellationToken) + { + var list = new List(); + + var configuredDeviceIds = GetConfiguration().TunerHosts + .Where(i => !string.IsNullOrWhiteSpace(i.DeviceId)) + .Select(i => i.DeviceId) + .ToList(); + + foreach (var host in _liveTvManager.TunerHosts) + { + var discoveredDevices = await DiscoverDevices(host, TunerDiscoveryDurationMs, cancellationToken).ConfigureAwait(false); + + if (newDevicesOnly) + { + discoveredDevices = discoveredDevices.Where(d => !configuredDeviceIds.Contains(d.DeviceId, StringComparison.OrdinalIgnoreCase)) + .ToList(); + } + + list.AddRange(discoveredDevices); + } + + return list; + } + + public async Task ScanForTunerDeviceChanges(CancellationToken cancellationToken) + { + foreach (var host in _liveTvManager.TunerHosts) + { + await ScanForTunerDeviceChanges(host, cancellationToken).ConfigureAwait(false); + } + } + + private async Task ScanForTunerDeviceChanges(ITunerHost host, CancellationToken cancellationToken) + { + var discoveredDevices = await DiscoverDevices(host, TunerDiscoveryDurationMs, cancellationToken).ConfigureAwait(false); + + var configuredDevices = GetConfiguration().TunerHosts + .Where(i => string.Equals(i.Type, host.Type, StringComparison.OrdinalIgnoreCase)) + .ToList(); + + foreach (var device in discoveredDevices) + { + var configuredDevice = configuredDevices.FirstOrDefault(i => string.Equals(i.DeviceId, device.DeviceId, StringComparison.OrdinalIgnoreCase)); + + if (configuredDevice is not null && !string.Equals(device.Url, configuredDevice.Url, StringComparison.OrdinalIgnoreCase)) + { + _logger.LogInformation("Tuner url has changed from {PreviousUrl} to {NewUrl}", configuredDevice.Url, device.Url); + + configuredDevice.Url = device.Url; + await _liveTvManager.SaveTunerHost(configuredDevice).ConfigureAwait(false); + } + } + } + + private async Task> DiscoverDevices(ITunerHost host, int discoveryDurationMs, CancellationToken cancellationToken) + { + try + { + var discoveredDevices = await host.DiscoverDevices(discoveryDurationMs, cancellationToken).ConfigureAwait(false); + + foreach (var device in discoveredDevices) + { + _logger.LogInformation("Discovered tuner device {0} at {1}", host.Name, device.Url); + } + + return discoveredDevices; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error discovering tuner devices"); + + return new List(); + } + } + } +} diff --git a/src/Jellyfin.LiveTv/EmbyTV/EncodedRecorder.cs b/src/Jellyfin.LiveTv/EmbyTV/EncodedRecorder.cs new file mode 100644 index 000000000..132a5fc51 --- /dev/null +++ b/src/Jellyfin.LiveTv/EmbyTV/EncodedRecorder.cs @@ -0,0 +1,362 @@ +#nullable disable + +#pragma warning disable CS1591 + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Globalization; +using System.IO; +using System.Text; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Extensions; +using Jellyfin.Extensions.Json; +using MediaBrowser.Common; +using MediaBrowser.Common.Configuration; +using MediaBrowser.Controller; +using MediaBrowser.Controller.Configuration; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.MediaEncoding; +using MediaBrowser.Model.Dto; +using MediaBrowser.Model.IO; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.LiveTv.EmbyTV +{ + public class EncodedRecorder : IRecorder + { + private readonly ILogger _logger; + private readonly IMediaEncoder _mediaEncoder; + private readonly IServerApplicationPaths _appPaths; + private readonly TaskCompletionSource _taskCompletionSource = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + private readonly IServerConfigurationManager _serverConfigurationManager; + private readonly JsonSerializerOptions _jsonOptions = JsonDefaults.Options; + private bool _hasExited; + private FileStream _logFileStream; + private string _targetPath; + private Process _process; + private bool _disposed; + + public EncodedRecorder( + ILogger logger, + IMediaEncoder mediaEncoder, + IServerApplicationPaths appPaths, + IServerConfigurationManager serverConfigurationManager) + { + _logger = logger; + _mediaEncoder = mediaEncoder; + _appPaths = appPaths; + _serverConfigurationManager = serverConfigurationManager; + } + + private static bool CopySubtitles => false; + + public string GetOutputPath(MediaSourceInfo mediaSource, string targetFile) + { + return Path.ChangeExtension(targetFile, ".ts"); + } + + public async Task Record(IDirectStreamProvider directStreamProvider, MediaSourceInfo mediaSource, string targetFile, TimeSpan duration, Action onStarted, CancellationToken cancellationToken) + { + // The media source is infinite so we need to handle stopping ourselves + using var durationToken = new CancellationTokenSource(duration); + using var cancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, durationToken.Token); + + await RecordFromFile(mediaSource, mediaSource.Path, targetFile, onStarted, cancellationTokenSource.Token).ConfigureAwait(false); + + _logger.LogInformation("Recording completed to file {Path}", targetFile); + } + + private async Task RecordFromFile(MediaSourceInfo mediaSource, string inputFile, string targetFile, Action onStarted, CancellationToken cancellationToken) + { + _targetPath = targetFile; + Directory.CreateDirectory(Path.GetDirectoryName(targetFile)); + + var processStartInfo = new ProcessStartInfo + { + CreateNoWindow = true, + UseShellExecute = false, + + RedirectStandardError = true, + RedirectStandardInput = true, + + FileName = _mediaEncoder.EncoderPath, + Arguments = GetCommandLineArgs(mediaSource, inputFile, targetFile), + + WindowStyle = ProcessWindowStyle.Hidden, + ErrorDialog = false + }; + + _logger.LogInformation("{Filename} {Arguments}", processStartInfo.FileName, processStartInfo.Arguments); + + var logFilePath = Path.Combine(_appPaths.LogDirectoryPath, "record-transcode-" + Guid.NewGuid() + ".txt"); + Directory.CreateDirectory(Path.GetDirectoryName(logFilePath)); + + // FFMpeg writes debug/error info to stderr. This is useful when debugging so let's put it in the log directory. + _logFileStream = new FileStream(logFilePath, FileMode.CreateNew, FileAccess.Write, FileShare.Read, IODefaults.FileStreamBufferSize, FileOptions.Asynchronous); + + await JsonSerializer.SerializeAsync(_logFileStream, mediaSource, _jsonOptions, cancellationToken).ConfigureAwait(false); + await _logFileStream.WriteAsync(Encoding.UTF8.GetBytes(Environment.NewLine + Environment.NewLine + processStartInfo.FileName + " " + processStartInfo.Arguments + Environment.NewLine + Environment.NewLine), cancellationToken).ConfigureAwait(false); + + _process = new Process + { + StartInfo = processStartInfo, + EnableRaisingEvents = true + }; + _process.Exited += (_, _) => OnFfMpegProcessExited(_process); + + _process.Start(); + + cancellationToken.Register(Stop); + + onStarted(); + + // Important - don't await the log task or we won't be able to kill ffmpeg when the user stops playback + _ = StartStreamingLog(_process.StandardError.BaseStream, _logFileStream); + + _logger.LogInformation("ffmpeg recording process started for {Path}", _targetPath); + + // Block until ffmpeg exits + await _taskCompletionSource.Task.ConfigureAwait(false); + } + + private string GetCommandLineArgs(MediaSourceInfo mediaSource, string inputTempFile, string targetFile) + { + string videoArgs; + if (EncodeVideo(mediaSource)) + { + const int MaxBitrate = 25000000; + videoArgs = string.Format( + CultureInfo.InvariantCulture, + "-codec:v:0 libx264 -force_key_frames \"expr:gte(t,n_forced*5)\" {0} -pix_fmt yuv420p -preset superfast -crf 23 -b:v {1} -maxrate {1} -bufsize ({1}*2) -vsync -1 -profile:v high -level 41", + GetOutputSizeParam(), + MaxBitrate); + } + else + { + videoArgs = "-codec:v:0 copy"; + } + + videoArgs += " -fflags +genpts"; + + var flags = new List(); + if (mediaSource.IgnoreDts) + { + flags.Add("+igndts"); + } + + if (mediaSource.IgnoreIndex) + { + flags.Add("+ignidx"); + } + + if (mediaSource.GenPtsInput) + { + flags.Add("+genpts"); + } + + var inputModifier = "-async 1 -vsync -1"; + + if (flags.Count > 0) + { + inputModifier += " -fflags " + string.Join(string.Empty, flags); + } + + if (mediaSource.ReadAtNativeFramerate) + { + inputModifier += " -re"; + } + + if (mediaSource.RequiresLooping) + { + inputModifier += " -stream_loop -1 -reconnect_at_eof 1 -reconnect_streamed 1 -reconnect_delay_max 2"; + } + + var analyzeDurationSeconds = 5; + var analyzeDuration = " -analyzeduration " + + (analyzeDurationSeconds * 1000000).ToString(CultureInfo.InvariantCulture); + inputModifier += analyzeDuration; + + var subtitleArgs = CopySubtitles ? " -codec:s copy" : " -sn"; + + // var outputParam = string.Equals(Path.GetExtension(targetFile), ".mp4", StringComparison.OrdinalIgnoreCase) ? + // " -f mp4 -movflags frag_keyframe+empty_moov" : + // string.Empty; + + var outputParam = string.Empty; + + var threads = EncodingHelper.GetNumberOfThreads(null, _serverConfigurationManager.GetEncodingOptions(), null); + var commandLineArgs = string.Format( + CultureInfo.InvariantCulture, + "-i \"{0}\" {2} -map_metadata -1 -threads {6} {3}{4}{5} -y \"{1}\"", + inputTempFile, + targetFile.Replace("\"", "\\\"", StringComparison.Ordinal), // Escape quotes in filename + videoArgs, + GetAudioArgs(mediaSource), + subtitleArgs, + outputParam, + threads); + + return inputModifier + " " + commandLineArgs; + } + + private static string GetAudioArgs(MediaSourceInfo mediaSource) + { + return "-codec:a:0 copy"; + + // var audioChannels = 2; + // var audioStream = mediaStreams.FirstOrDefault(i => i.Type == MediaStreamType.Audio); + // if (audioStream is not null) + // { + // audioChannels = audioStream.Channels ?? audioChannels; + // } + // return "-codec:a:0 aac -strict experimental -ab 320000"; + } + + private static bool EncodeVideo(MediaSourceInfo mediaSource) + { + return false; + } + + protected string GetOutputSizeParam() + => "-vf \"yadif=0:-1:0\""; + + private void Stop() + { + if (!_hasExited) + { + try + { + _logger.LogInformation("Stopping ffmpeg recording process for {Path}", _targetPath); + + _process.StandardInput.WriteLine("q"); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error stopping recording transcoding job for {Path}", _targetPath); + } + + if (_hasExited) + { + return; + } + + try + { + _logger.LogInformation("Calling recording process.WaitForExit for {Path}", _targetPath); + + if (_process.WaitForExit(10000)) + { + return; + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Error waiting for recording process to exit for {Path}", _targetPath); + } + + if (_hasExited) + { + return; + } + + try + { + _logger.LogInformation("Killing ffmpeg recording process for {Path}", _targetPath); + + _process.Kill(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error killing recording transcoding job for {Path}", _targetPath); + } + } + } + + /// + /// Processes the exited. + /// + private void OnFfMpegProcessExited(Process process) + { + using (process) + { + _hasExited = true; + + _logFileStream?.Dispose(); + _logFileStream = null; + + var exitCode = process.ExitCode; + + _logger.LogInformation("FFMpeg recording exited with code {ExitCode} for {Path}", exitCode, _targetPath); + + if (exitCode == 0) + { + _taskCompletionSource.TrySetResult(true); + } + else + { + _taskCompletionSource.TrySetException( + new FfmpegException( + string.Format( + CultureInfo.InvariantCulture, + "Recording for {0} failed. Exit code {1}", + _targetPath, + exitCode))); + } + } + } + + private async Task StartStreamingLog(Stream source, FileStream target) + { + try + { + using (var reader = new StreamReader(source)) + { + await foreach (var line in reader.ReadAllLinesAsync().ConfigureAwait(false)) + { + var bytes = Encoding.UTF8.GetBytes(Environment.NewLine + line); + + await target.WriteAsync(bytes.AsMemory()).ConfigureAwait(false); + await target.FlushAsync().ConfigureAwait(false); + } + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Error reading ffmpeg recording log"); + } + } + + /// + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + /// + /// Releases unmanaged and optionally managed resources. + /// + /// true to release both managed and unmanaged resources; false to release only unmanaged resources. + protected virtual void Dispose(bool disposing) + { + if (_disposed) + { + return; + } + + if (disposing) + { + _logFileStream?.Dispose(); + _process?.Dispose(); + } + + _logFileStream = null; + _process = null; + + _disposed = true; + } + } +} diff --git a/src/Jellyfin.LiveTv/EmbyTV/EntryPoint.cs b/src/Jellyfin.LiveTv/EmbyTV/EntryPoint.cs new file mode 100644 index 000000000..e750c05ac --- /dev/null +++ b/src/Jellyfin.LiveTv/EmbyTV/EntryPoint.cs @@ -0,0 +1,21 @@ +#pragma warning disable CS1591 + +using System.Threading.Tasks; +using MediaBrowser.Controller.Plugins; + +namespace Jellyfin.LiveTv.EmbyTV +{ + public sealed class EntryPoint : IServerEntryPoint + { + /// + public Task RunAsync() + { + return EmbyTV.Current.Start(); + } + + /// + public void Dispose() + { + } + } +} diff --git a/src/Jellyfin.LiveTv/EmbyTV/EpgChannelData.cs b/src/Jellyfin.LiveTv/EmbyTV/EpgChannelData.cs new file mode 100644 index 000000000..43d308c43 --- /dev/null +++ b/src/Jellyfin.LiveTv/EmbyTV/EpgChannelData.cs @@ -0,0 +1,54 @@ +#pragma warning disable CS1591 + +using System; +using System.Collections.Generic; +using MediaBrowser.Controller.LiveTv; + +namespace Jellyfin.LiveTv.EmbyTV +{ + internal class EpgChannelData + { + private readonly Dictionary _channelsById; + + private readonly Dictionary _channelsByNumber; + + private readonly Dictionary _channelsByName; + + public EpgChannelData(IEnumerable channels) + { + _channelsById = new Dictionary(StringComparer.OrdinalIgnoreCase); + _channelsByNumber = new Dictionary(StringComparer.OrdinalIgnoreCase); + _channelsByName = new Dictionary(StringComparer.OrdinalIgnoreCase); + + foreach (var channel in channels) + { + _channelsById[channel.Id] = channel; + + if (!string.IsNullOrEmpty(channel.Number)) + { + _channelsByNumber[channel.Number] = channel; + } + + var normalizedName = NormalizeName(channel.Name ?? string.Empty); + if (!string.IsNullOrWhiteSpace(normalizedName)) + { + _channelsByName[normalizedName] = channel; + } + } + } + + public ChannelInfo? GetChannelById(string id) + => _channelsById.GetValueOrDefault(id); + + public ChannelInfo? GetChannelByNumber(string number) + => _channelsByNumber.GetValueOrDefault(number); + + public ChannelInfo? GetChannelByName(string name) + => _channelsByName.GetValueOrDefault(name); + + public static string NormalizeName(string value) + { + return value.Replace(" ", string.Empty, StringComparison.Ordinal).Replace("-", string.Empty, StringComparison.Ordinal); + } + } +} diff --git a/src/Jellyfin.LiveTv/EmbyTV/IRecorder.cs b/src/Jellyfin.LiveTv/EmbyTV/IRecorder.cs new file mode 100644 index 000000000..7ed42e263 --- /dev/null +++ b/src/Jellyfin.LiveTv/EmbyTV/IRecorder.cs @@ -0,0 +1,27 @@ +#pragma warning disable CS1591 + +using System; +using System.Threading; +using System.Threading.Tasks; +using MediaBrowser.Controller.Library; +using MediaBrowser.Model.Dto; + +namespace Jellyfin.LiveTv.EmbyTV +{ + public interface IRecorder : IDisposable + { + /// + /// Records the specified media source. + /// + /// The direct stream provider, or null. + /// The media source. + /// The target file. + /// The duration to record. + /// An action to perform when recording starts. + /// The cancellation token. + /// A that represents the recording operation. + Task Record(IDirectStreamProvider? directStreamProvider, MediaSourceInfo mediaSource, string targetFile, TimeSpan duration, Action onStarted, CancellationToken cancellationToken); + + string GetOutputPath(MediaSourceInfo mediaSource, string targetFile); + } +} diff --git a/src/Jellyfin.LiveTv/EmbyTV/ItemDataProvider.cs b/src/Jellyfin.LiveTv/EmbyTV/ItemDataProvider.cs new file mode 100644 index 000000000..547ffeb66 --- /dev/null +++ b/src/Jellyfin.LiveTv/EmbyTV/ItemDataProvider.cs @@ -0,0 +1,163 @@ +#pragma warning disable CS1591 + +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.IO; +using System.Linq; +using System.Text.Json; +using Jellyfin.Extensions.Json; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.LiveTv.EmbyTV +{ + public class ItemDataProvider + where T : class + { + private readonly string _dataPath; + private readonly object _fileDataLock = new object(); + private readonly JsonSerializerOptions _jsonOptions = JsonDefaults.Options; + private T[]? _items; + + public ItemDataProvider( + ILogger logger, + string dataPath, + Func equalityComparer) + { + Logger = logger; + _dataPath = dataPath; + EqualityComparer = equalityComparer; + } + + protected ILogger Logger { get; } + + protected Func EqualityComparer { get; } + + [MemberNotNull(nameof(_items))] + private void EnsureLoaded() + { + if (_items is not null) + { + return; + } + + if (File.Exists(_dataPath)) + { + Logger.LogInformation("Loading live tv data from {Path}", _dataPath); + + try + { + var bytes = File.ReadAllBytes(_dataPath); + _items = JsonSerializer.Deserialize(bytes, _jsonOptions); + if (_items is null) + { + Logger.LogError("Error deserializing {Path}, data was null", _dataPath); + _items = Array.Empty(); + } + + return; + } + catch (JsonException ex) + { + Logger.LogError(ex, "Error deserializing {Path}", _dataPath); + } + } + + _items = Array.Empty(); + } + + private void SaveList() + { + Directory.CreateDirectory(Path.GetDirectoryName(_dataPath) ?? throw new ArgumentException("Path can't be a root directory.", nameof(_dataPath))); + var jsonString = JsonSerializer.Serialize(_items, _jsonOptions); + File.WriteAllText(_dataPath, jsonString); + } + + public IReadOnlyList GetAll() + { + lock (_fileDataLock) + { + EnsureLoaded(); + return (T[])_items.Clone(); + } + } + + public virtual void Update(T item) + { + ArgumentNullException.ThrowIfNull(item); + + lock (_fileDataLock) + { + EnsureLoaded(); + + var index = Array.FindIndex(_items, i => EqualityComparer(i, item)); + if (index == -1) + { + throw new ArgumentException("item not found"); + } + + _items[index] = item; + + SaveList(); + } + } + + public virtual void Add(T item) + { + ArgumentNullException.ThrowIfNull(item); + + lock (_fileDataLock) + { + EnsureLoaded(); + + if (_items.Any(i => EqualityComparer(i, item))) + { + throw new ArgumentException("item already exists", nameof(item)); + } + + int oldLen = _items.Length; + var newList = new T[oldLen + 1]; + _items.CopyTo(newList, 0); + newList[oldLen] = item; + _items = newList; + + SaveList(); + } + } + + public virtual void AddOrUpdate(T item) + { + lock (_fileDataLock) + { + EnsureLoaded(); + + int index = Array.FindIndex(_items, i => EqualityComparer(i, item)); + if (index == -1) + { + int oldLen = _items.Length; + var newList = new T[oldLen + 1]; + _items.CopyTo(newList, 0); + newList[oldLen] = item; + _items = newList; + } + else + { + _items[index] = item; + } + + SaveList(); + } + } + + public virtual void Delete(T item) + { + lock (_fileDataLock) + { + EnsureLoaded(); + _items = _items.Where(i => !EqualityComparer(i, item)).ToArray(); + + SaveList(); + } + } + } +} diff --git a/src/Jellyfin.LiveTv/EmbyTV/NfoConfigurationExtensions.cs b/src/Jellyfin.LiveTv/EmbyTV/NfoConfigurationExtensions.cs new file mode 100644 index 000000000..e8570f0e0 --- /dev/null +++ b/src/Jellyfin.LiveTv/EmbyTV/NfoConfigurationExtensions.cs @@ -0,0 +1,19 @@ +using MediaBrowser.Common.Configuration; +using MediaBrowser.Model.Configuration; + +namespace Jellyfin.LiveTv.EmbyTV +{ + /// + /// Class containing extension methods for working with the nfo configuration. + /// + public static class NfoConfigurationExtensions + { + /// + /// Gets the nfo configuration. + /// + /// The configuration manager. + /// The nfo configuration. + public static XbmcMetadataOptions GetNfoConfiguration(this IConfigurationManager configurationManager) + => configurationManager.GetConfiguration("xbmcmetadata"); + } +} diff --git a/src/Jellyfin.LiveTv/EmbyTV/RecordingHelper.cs b/src/Jellyfin.LiveTv/EmbyTV/RecordingHelper.cs new file mode 100644 index 000000000..6bda231b2 --- /dev/null +++ b/src/Jellyfin.LiveTv/EmbyTV/RecordingHelper.cs @@ -0,0 +1,83 @@ +#pragma warning disable CS1591 + +using System; +using System.Globalization; +using System.Text; +using MediaBrowser.Controller.LiveTv; + +namespace Jellyfin.LiveTv.EmbyTV +{ + internal static class RecordingHelper + { + public static DateTime GetStartTime(TimerInfo timer) + { + return timer.StartDate.AddSeconds(-timer.PrePaddingSeconds); + } + + public static string GetRecordingName(TimerInfo info) + { + var name = info.Name; + + if (info.IsProgramSeries) + { + var addHyphen = true; + + if (info.SeasonNumber.HasValue && info.EpisodeNumber.HasValue) + { + name += string.Format( + CultureInfo.InvariantCulture, + " S{0}E{1}", + info.SeasonNumber.Value.ToString("00", CultureInfo.InvariantCulture), + info.EpisodeNumber.Value.ToString("00", CultureInfo.InvariantCulture)); + addHyphen = false; + } + else if (info.OriginalAirDate.HasValue) + { + if (info.OriginalAirDate.Value.Date.Equals(info.StartDate.Date)) + { + name += " " + GetDateString(info.StartDate); + } + else + { + name += " " + info.OriginalAirDate.Value.ToLocalTime().ToString("yyyy-MM-dd", CultureInfo.InvariantCulture); + } + } + else + { + name += " " + GetDateString(info.StartDate); + } + + if (!string.IsNullOrWhiteSpace(info.EpisodeTitle)) + { + var tmpName = name; + if (addHyphen) + { + tmpName += " -"; + } + + tmpName += " " + info.EpisodeTitle; + // Since the filename will be used with file ext. (.mp4, .ts, etc) + if (Encoding.UTF8.GetByteCount(tmpName) < 250) + { + name = tmpName; + } + } + } + else if (info.IsMovie && info.ProductionYear is not null) + { + name += " (" + info.ProductionYear + ")"; + } + else + { + name += " " + GetDateString(info.StartDate); + } + + return name; + } + + private static string GetDateString(DateTime date) + { + return date.ToLocalTime().ToString("yyyy_MM_dd_HH_mm_ss", CultureInfo.InvariantCulture); + } + } +} diff --git a/src/Jellyfin.LiveTv/EmbyTV/SeriesTimerManager.cs b/src/Jellyfin.LiveTv/EmbyTV/SeriesTimerManager.cs new file mode 100644 index 000000000..2ebe60b29 --- /dev/null +++ b/src/Jellyfin.LiveTv/EmbyTV/SeriesTimerManager.cs @@ -0,0 +1,24 @@ +#pragma warning disable CS1591 + +using System; +using MediaBrowser.Controller.LiveTv; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.LiveTv.EmbyTV +{ + public class SeriesTimerManager : ItemDataProvider + { + public SeriesTimerManager(ILogger logger, string dataPath) + : base(logger, dataPath, (r1, r2) => string.Equals(r1.Id, r2.Id, StringComparison.OrdinalIgnoreCase)) + { + } + + /// + public override void Add(SeriesTimerInfo item) + { + ArgumentException.ThrowIfNullOrEmpty(item.Id); + + base.Add(item); + } + } +} diff --git a/src/Jellyfin.LiveTv/EmbyTV/TimerManager.cs b/src/Jellyfin.LiveTv/EmbyTV/TimerManager.cs new file mode 100644 index 000000000..37b1fa14c --- /dev/null +++ b/src/Jellyfin.LiveTv/EmbyTV/TimerManager.cs @@ -0,0 +1,181 @@ +#pragma warning disable CS1591 + +using System; +using System.Collections.Concurrent; +using System.Globalization; +using System.Linq; +using System.Threading; +using Jellyfin.Data.Events; +using MediaBrowser.Controller.LiveTv; +using MediaBrowser.Model.LiveTv; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.LiveTv.EmbyTV +{ + public class TimerManager : ItemDataProvider + { + private readonly ConcurrentDictionary _timers = new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase); + + public TimerManager(ILogger logger, string dataPath) + : base(logger, dataPath, (r1, r2) => string.Equals(r1.Id, r2.Id, StringComparison.OrdinalIgnoreCase)) + { + } + + public event EventHandler>? TimerFired; + + public void RestartTimers() + { + StopTimers(); + + foreach (var item in GetAll()) + { + AddOrUpdateSystemTimer(item); + } + } + + public void StopTimers() + { + foreach (var pair in _timers.ToList()) + { + pair.Value.Dispose(); + } + + _timers.Clear(); + } + + public override void Delete(TimerInfo item) + { + base.Delete(item); + StopTimer(item); + } + + public override void Update(TimerInfo item) + { + base.Update(item); + AddOrUpdateSystemTimer(item); + } + + public void AddOrUpdate(TimerInfo item, bool resetTimer) + { + if (resetTimer) + { + AddOrUpdate(item); + return; + } + + base.AddOrUpdate(item); + } + + public override void AddOrUpdate(TimerInfo item) + { + base.AddOrUpdate(item); + AddOrUpdateSystemTimer(item); + } + + public override void Add(TimerInfo item) + { + ArgumentException.ThrowIfNullOrEmpty(item.Id); + + base.Add(item); + AddOrUpdateSystemTimer(item); + } + + private static bool ShouldStartTimer(TimerInfo item) + { + if (item.Status == RecordingStatus.Completed + || item.Status == RecordingStatus.Cancelled) + { + return false; + } + + return true; + } + + private void AddOrUpdateSystemTimer(TimerInfo item) + { + StopTimer(item); + + if (!ShouldStartTimer(item)) + { + return; + } + + var startDate = RecordingHelper.GetStartTime(item); + var now = DateTime.UtcNow; + + if (startDate < now) + { + TimerFired?.Invoke(this, new GenericEventArgs(item)); + return; + } + + var dueTime = startDate - now; + StartTimer(item, dueTime); + } + + private void StartTimer(TimerInfo item, TimeSpan dueTime) + { + var timer = new Timer(TimerCallback, item.Id, dueTime, TimeSpan.Zero); + + if (_timers.TryAdd(item.Id, timer)) + { + if (item.IsSeries) + { + Logger.LogInformation( + "Creating recording timer for {Id}, {Name} {SeasonNumber}x{EpisodeNumber:D2} on channel {ChannelId}. Timer will fire in {Minutes} minutes at {StartDate}", + item.Id, + item.Name, + item.SeasonNumber, + item.EpisodeNumber, + item.ChannelId, + dueTime.TotalMinutes.ToString(CultureInfo.InvariantCulture), + item.StartDate); + } + else + { + Logger.LogInformation( + "Creating recording timer for {Id}, {Name} on channel {ChannelId}. Timer will fire in {Minutes} minutes at {StartDate}", + item.Id, + item.Name, + item.ChannelId, + dueTime.TotalMinutes.ToString(CultureInfo.InvariantCulture), + item.StartDate); + } + } + else + { + timer.Dispose(); + Logger.LogWarning("Timer already exists for item {Id}", item.Id); + } + } + + private void StopTimer(TimerInfo item) + { + if (_timers.TryRemove(item.Id, out var timer)) + { + timer.Dispose(); + } + } + + private void TimerCallback(object? state) + { + var timerId = (string?)state ?? throw new ArgumentNullException(nameof(state)); + + var timer = GetAll().FirstOrDefault(i => string.Equals(i.Id, timerId, StringComparison.OrdinalIgnoreCase)); + if (timer is not null) + { + TimerFired?.Invoke(this, new GenericEventArgs(timer)); + } + } + + public TimerInfo? GetTimer(string id) + { + return GetAll().FirstOrDefault(r => string.Equals(r.Id, id, StringComparison.OrdinalIgnoreCase)); + } + + public TimerInfo? GetTimerByProgramId(string programId) + { + return GetAll().FirstOrDefault(r => string.Equals(r.ProgramId, programId, StringComparison.OrdinalIgnoreCase)); + } + } +} diff --git a/src/Jellyfin.LiveTv/ExclusiveLiveStream.cs b/src/Jellyfin.LiveTv/ExclusiveLiveStream.cs new file mode 100644 index 000000000..9d442e20c --- /dev/null +++ b/src/Jellyfin.LiveTv/ExclusiveLiveStream.cs @@ -0,0 +1,61 @@ +#nullable disable + +#pragma warning disable CA1711 +#pragma warning disable CS1591 + +using System; +using System.Globalization; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using MediaBrowser.Controller.Library; +using MediaBrowser.Model.Dto; + +namespace Jellyfin.LiveTv +{ + public sealed class ExclusiveLiveStream : ILiveStream + { + private readonly Func _closeFn; + + public ExclusiveLiveStream(MediaSourceInfo mediaSource, Func closeFn) + { + MediaSource = mediaSource; + EnableStreamSharing = false; + _closeFn = closeFn; + ConsumerCount = 1; + UniqueId = Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture); + } + + public int ConsumerCount { get; set; } + + public string OriginalStreamId { get; set; } + + public string TunerHostId => null; + + public bool EnableStreamSharing { get; set; } + + public MediaSourceInfo MediaSource { get; set; } + + public string UniqueId { get; } + + public Task Close() + { + return _closeFn(); + } + + public Stream GetStream() + { + throw new NotSupportedException(); + } + + public Task Open(CancellationToken openCancellationToken) + { + return Task.CompletedTask; + } + + /// + public void Dispose() + { + } + } +} diff --git a/src/Jellyfin.LiveTv/Jellyfin.LiveTv.csproj b/src/Jellyfin.LiveTv/Jellyfin.LiveTv.csproj new file mode 100644 index 000000000..391006449 --- /dev/null +++ b/src/Jellyfin.LiveTv/Jellyfin.LiveTv.csproj @@ -0,0 +1,22 @@ + + + net8.0 + true + + + + + <_Parameter1>Jellyfin.LiveTv.Tests + + + + + + + + + + + + + diff --git a/src/Jellyfin.LiveTv/Listings/SchedulesDirect.cs b/src/Jellyfin.LiveTv/Listings/SchedulesDirect.cs new file mode 100644 index 000000000..3b20cd160 --- /dev/null +++ b/src/Jellyfin.LiveTv/Listings/SchedulesDirect.cs @@ -0,0 +1,810 @@ +#nullable disable + +#pragma warning disable CS1591 + +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Net.Http.Json; +using System.Net.Mime; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Extensions; +using Jellyfin.Extensions.Json; +using Jellyfin.LiveTv.Listings.SchedulesDirectDtos; +using MediaBrowser.Common.Net; +using MediaBrowser.Controller.Authentication; +using MediaBrowser.Controller.LiveTv; +using MediaBrowser.Model.Dto; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.LiveTv; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.LiveTv.Listings +{ + public class SchedulesDirect : IListingsProvider, IDisposable + { + private const string ApiUrl = "https://json.schedulesdirect.org/20141201"; + + private readonly ILogger _logger; + private readonly IHttpClientFactory _httpClientFactory; + private readonly SemaphoreSlim _tokenSemaphore = new SemaphoreSlim(1, 1); + + private readonly ConcurrentDictionary _tokens = new ConcurrentDictionary(); + private readonly JsonSerializerOptions _jsonOptions = JsonDefaults.Options; + private DateTime _lastErrorResponse; + private bool _disposed = false; + + public SchedulesDirect( + ILogger logger, + IHttpClientFactory httpClientFactory) + { + _logger = logger; + _httpClientFactory = httpClientFactory; + } + + /// + public string Name => "Schedules Direct"; + + /// + public string Type => nameof(SchedulesDirect); + + private static List GetScheduleRequestDates(DateTime startDateUtc, DateTime endDateUtc) + { + var dates = new List(); + + var start = new[] { startDateUtc, startDateUtc.ToLocalTime() }.Min().Date; + var end = new[] { endDateUtc, endDateUtc.ToLocalTime() }.Max().Date; + + while (start <= end) + { + dates.Add(start.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)); + start = start.AddDays(1); + } + + return dates; + } + + public async Task> GetProgramsAsync(ListingsProviderInfo info, string channelId, DateTime startDateUtc, DateTime endDateUtc, CancellationToken cancellationToken) + { + ArgumentException.ThrowIfNullOrEmpty(channelId); + + // Normalize incoming input + channelId = channelId.Replace(".json.schedulesdirect.org", string.Empty, StringComparison.OrdinalIgnoreCase).TrimStart('I'); + + var token = await GetToken(info, cancellationToken).ConfigureAwait(false); + + if (string.IsNullOrEmpty(token)) + { + _logger.LogWarning("SchedulesDirect token is empty, returning empty program list"); + + return Enumerable.Empty(); + } + + var dates = GetScheduleRequestDates(startDateUtc, endDateUtc); + + _logger.LogInformation("Channel Station ID is: {ChannelID}", channelId); + var requestList = new List() + { + new RequestScheduleForChannelDto() + { + StationId = channelId, + Date = dates + } + }; + + _logger.LogDebug("Request string for schedules is: {@RequestString}", requestList); + + using var options = new HttpRequestMessage(HttpMethod.Post, ApiUrl + "/schedules"); + options.Content = JsonContent.Create(requestList, options: _jsonOptions); + options.Headers.TryAddWithoutValidation("token", token); + using var response = await Send(options, true, info, cancellationToken).ConfigureAwait(false); + var dailySchedules = await response.Content.ReadFromJsonAsync>(_jsonOptions, cancellationToken).ConfigureAwait(false); + if (dailySchedules is null) + { + return Array.Empty(); + } + + _logger.LogDebug("Found {ScheduleCount} programs on {ChannelID} ScheduleDirect", dailySchedules.Count, channelId); + + using var programRequestOptions = new HttpRequestMessage(HttpMethod.Post, ApiUrl + "/programs"); + programRequestOptions.Headers.TryAddWithoutValidation("token", token); + + var programIds = dailySchedules.SelectMany(d => d.Programs.Select(s => s.ProgramId)).Distinct(); + programRequestOptions.Content = JsonContent.Create(programIds, options: _jsonOptions); + + using var innerResponse = await Send(programRequestOptions, true, info, cancellationToken).ConfigureAwait(false); + var programDetails = await innerResponse.Content.ReadFromJsonAsync>(_jsonOptions, cancellationToken).ConfigureAwait(false); + if (programDetails is null) + { + return Array.Empty(); + } + + var programDict = programDetails.ToDictionary(p => p.ProgramId, y => y); + + var programIdsWithImages = programDetails + .Where(p => p.HasImageArtwork).Select(p => p.ProgramId) + .ToList(); + + var images = await GetImageForPrograms(info, programIdsWithImages, cancellationToken).ConfigureAwait(false); + + var programsInfo = new List(); + foreach (ProgramDto schedule in dailySchedules.SelectMany(d => d.Programs)) + { + // _logger.LogDebug("Proccesing Schedule for statio ID " + stationID + + // " which corresponds to channel " + channelNumber + " and program id " + + // schedule.ProgramId + " which says it has images? " + + // programDict[schedule.ProgramId].hasImageArtwork); + + if (string.IsNullOrEmpty(schedule.ProgramId)) + { + continue; + } + + if (images is not null) + { + var imageIndex = images.FindIndex(i => i.ProgramId == schedule.ProgramId[..10]); + if (imageIndex > -1) + { + var programEntry = programDict[schedule.ProgramId]; + + var allImages = images[imageIndex].Data; + var imagesWithText = allImages.Where(i => string.Equals(i.Text, "yes", StringComparison.OrdinalIgnoreCase)).ToList(); + var imagesWithoutText = allImages.Where(i => string.Equals(i.Text, "no", StringComparison.OrdinalIgnoreCase)).ToList(); + + const double DesiredAspect = 2.0 / 3; + + programEntry.PrimaryImage = GetProgramImage(ApiUrl, imagesWithText, DesiredAspect, token) ?? + GetProgramImage(ApiUrl, allImages, DesiredAspect, token); + + const double WideAspect = 16.0 / 9; + + programEntry.ThumbImage = GetProgramImage(ApiUrl, imagesWithText, WideAspect, token); + + // Don't supply the same image twice + if (string.Equals(programEntry.PrimaryImage, programEntry.ThumbImage, StringComparison.Ordinal)) + { + programEntry.ThumbImage = null; + } + + programEntry.BackdropImage = GetProgramImage(ApiUrl, imagesWithoutText, WideAspect, token); + + // programEntry.bannerImage = GetProgramImage(ApiUrl, data, "Banner", false) ?? + // GetProgramImage(ApiUrl, data, "Banner-L1", false) ?? + // GetProgramImage(ApiUrl, data, "Banner-LO", false) ?? + // GetProgramImage(ApiUrl, data, "Banner-LOT", false); + } + } + + programsInfo.Add(GetProgram(channelId, schedule, programDict[schedule.ProgramId])); + } + + return programsInfo; + } + + private static int GetSizeOrder(ImageDataDto image) + { + if (int.TryParse(image.Height, out int value)) + { + return value; + } + + return 0; + } + + private static string GetChannelNumber(MapDto map) + { + var channelNumber = map.LogicalChannelNumber; + + if (string.IsNullOrWhiteSpace(channelNumber)) + { + channelNumber = map.Channel; + } + + if (string.IsNullOrWhiteSpace(channelNumber)) + { + channelNumber = map.AtscMajor + "." + map.AtscMinor; + } + + return channelNumber.TrimStart('0'); + } + + private static bool IsMovie(ProgramDetailsDto programInfo) + { + return string.Equals(programInfo.EntityType, "movie", StringComparison.OrdinalIgnoreCase); + } + + private ProgramInfo GetProgram(string channelId, ProgramDto programInfo, ProgramDetailsDto details) + { + if (programInfo.AirDateTime is null) + { + return null; + } + + var startAt = programInfo.AirDateTime.Value; + var endAt = startAt.AddSeconds(programInfo.Duration); + var audioType = ProgramAudio.Stereo; + + var programId = programInfo.ProgramId ?? string.Empty; + + string newID = programId + "T" + startAt.Ticks + "C" + channelId; + + if (programInfo.AudioProperties.Count != 0) + { + if (programInfo.AudioProperties.Contains("atmos", StringComparison.OrdinalIgnoreCase)) + { + audioType = ProgramAudio.Atmos; + } + else if (programInfo.AudioProperties.Contains("dd 5.1", StringComparison.OrdinalIgnoreCase)) + { + audioType = ProgramAudio.DolbyDigital; + } + else if (programInfo.AudioProperties.Contains("dd", StringComparison.OrdinalIgnoreCase)) + { + audioType = ProgramAudio.DolbyDigital; + } + else if (programInfo.AudioProperties.Contains("stereo", StringComparison.OrdinalIgnoreCase)) + { + audioType = ProgramAudio.Stereo; + } + else + { + audioType = ProgramAudio.Mono; + } + } + + string episodeTitle = null; + if (details.EpisodeTitle150 is not null) + { + episodeTitle = details.EpisodeTitle150; + } + + var info = new ProgramInfo + { + ChannelId = channelId, + Id = newID, + StartDate = startAt, + EndDate = endAt, + Name = details.Titles[0].Title120 ?? "Unknown", + OfficialRating = null, + CommunityRating = null, + EpisodeTitle = episodeTitle, + Audio = audioType, + // IsNew = programInfo.@new ?? false, + IsRepeat = programInfo.New is null, + IsSeries = string.Equals(details.EntityType, "episode", StringComparison.OrdinalIgnoreCase), + ImageUrl = details.PrimaryImage, + ThumbImageUrl = details.ThumbImage, + IsKids = string.Equals(details.Audience, "children", StringComparison.OrdinalIgnoreCase), + IsSports = string.Equals(details.EntityType, "sports", StringComparison.OrdinalIgnoreCase), + IsMovie = IsMovie(details), + Etag = programInfo.Md5, + IsLive = string.Equals(programInfo.LiveTapeDelay, "live", StringComparison.OrdinalIgnoreCase), + IsPremiere = programInfo.Premiere || (programInfo.IsPremiereOrFinale ?? string.Empty).Contains("premiere", StringComparison.OrdinalIgnoreCase) + }; + + var showId = programId; + + if (!info.IsSeries) + { + // It's also a series if it starts with SH + info.IsSeries = showId.StartsWith("SH", StringComparison.OrdinalIgnoreCase) && showId.Length >= 14; + } + + // According to SchedulesDirect, these are generic, unidentified episodes + // SH005316560000 + var hasUniqueShowId = !showId.StartsWith("SH", StringComparison.OrdinalIgnoreCase) || + !showId.EndsWith("0000", StringComparison.OrdinalIgnoreCase); + + if (!hasUniqueShowId) + { + showId = newID; + } + + info.ShowId = showId; + + if (programInfo.VideoProperties is not null) + { + info.IsHD = programInfo.VideoProperties.Contains("hdtv", StringComparison.OrdinalIgnoreCase); + info.Is3D = programInfo.VideoProperties.Contains("3d", StringComparison.OrdinalIgnoreCase); + } + + if (details.ContentRating is not null && details.ContentRating.Count > 0) + { + info.OfficialRating = details.ContentRating[0].Code.Replace("TV", "TV-", StringComparison.Ordinal) + .Replace("--", "-", StringComparison.Ordinal); + + var invalid = new[] { "N/A", "Approved", "Not Rated", "Passed" }; + if (invalid.Contains(info.OfficialRating, StringComparison.OrdinalIgnoreCase)) + { + info.OfficialRating = null; + } + } + + if (details.Descriptions is not null) + { + if (details.Descriptions.Description1000 is not null && details.Descriptions.Description1000.Count > 0) + { + info.Overview = details.Descriptions.Description1000[0].Description; + } + else if (details.Descriptions.Description100 is not null && details.Descriptions.Description100.Count > 0) + { + info.Overview = details.Descriptions.Description100[0].Description; + } + } + + if (info.IsSeries) + { + info.SeriesId = programId.Substring(0, 10); + + info.SeriesProviderIds[MetadataProvider.Zap2It.ToString()] = info.SeriesId; + + if (details.Metadata is not null) + { + foreach (var metadataProgram in details.Metadata) + { + var gracenote = metadataProgram.Gracenote; + if (gracenote is not null) + { + info.SeasonNumber = gracenote.Season; + + if (gracenote.Episode > 0) + { + info.EpisodeNumber = gracenote.Episode; + } + + break; + } + } + } + } + + if (details.OriginalAirDate is not null) + { + info.OriginalAirDate = details.OriginalAirDate; + info.ProductionYear = info.OriginalAirDate.Value.Year; + } + + if (details.Movie is not null) + { + if (!string.IsNullOrEmpty(details.Movie.Year) + && int.TryParse(details.Movie.Year, out int year)) + { + info.ProductionYear = year; + } + } + + if (details.Genres is not null) + { + info.Genres = details.Genres.Where(g => !string.IsNullOrWhiteSpace(g)).ToList(); + info.IsNews = details.Genres.Contains("news", StringComparison.OrdinalIgnoreCase); + + if (info.Genres.Contains("children", StringComparison.OrdinalIgnoreCase)) + { + info.IsKids = true; + } + } + + return info; + } + + private static string GetProgramImage(string apiUrl, IEnumerable images, double desiredAspect, string token) + { + var match = images + .OrderBy(i => Math.Abs(desiredAspect - GetAspectRatio(i))) + .ThenByDescending(i => GetSizeOrder(i)) + .FirstOrDefault(); + + if (match is null) + { + return null; + } + + var uri = match.Uri; + + if (string.IsNullOrWhiteSpace(uri)) + { + return null; + } + + if (uri.Contains("http", StringComparison.OrdinalIgnoreCase)) + { + return uri; + } + + return apiUrl + "/image/" + uri + "?token=" + token; + } + + private static double GetAspectRatio(ImageDataDto i) + { + int width = 0; + int height = 0; + + if (!string.IsNullOrWhiteSpace(i.Width)) + { + _ = int.TryParse(i.Width, out width); + } + + if (!string.IsNullOrWhiteSpace(i.Height)) + { + _ = int.TryParse(i.Height, out height); + } + + if (height == 0 || width == 0) + { + return 0; + } + + double result = width; + result /= height; + return result; + } + + private async Task> GetImageForPrograms( + ListingsProviderInfo info, + IReadOnlyList programIds, + CancellationToken cancellationToken) + { + var token = await GetToken(info, cancellationToken).ConfigureAwait(false); + + if (programIds.Count == 0) + { + return Array.Empty(); + } + + StringBuilder str = new StringBuilder("[", 1 + (programIds.Count * 13)); + foreach (var i in programIds) + { + str.Append('"') + .Append(i[..10]) + .Append("\","); + } + + // Remove last , + str.Length--; + str.Append(']'); + + using var message = new HttpRequestMessage(HttpMethod.Post, ApiUrl + "/metadata/programs") + { + Content = new StringContent(str.ToString(), Encoding.UTF8, MediaTypeNames.Application.Json) + }; + message.Headers.TryAddWithoutValidation("token", token); + + try + { + using var innerResponse2 = await Send(message, true, info, cancellationToken).ConfigureAwait(false); + return await innerResponse2.Content.ReadFromJsonAsync>(_jsonOptions, cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error getting image info from schedules direct"); + + return Array.Empty(); + } + } + + public async Task> GetHeadends(ListingsProviderInfo info, string country, string location, CancellationToken cancellationToken) + { + var token = await GetToken(info, cancellationToken).ConfigureAwait(false); + + var lineups = new List(); + + if (string.IsNullOrWhiteSpace(token)) + { + return lineups; + } + + using var options = new HttpRequestMessage(HttpMethod.Get, ApiUrl + "/headends?country=" + country + "&postalcode=" + location); + options.Headers.TryAddWithoutValidation("token", token); + + try + { + using var httpResponse = await Send(options, false, info, cancellationToken).ConfigureAwait(false); + var root = await httpResponse.Content.ReadFromJsonAsync>(_jsonOptions, cancellationToken).ConfigureAwait(false); + if (root is not null) + { + foreach (HeadendsDto headend in root) + { + foreach (LineupDto lineup in headend.Lineups) + { + lineups.Add(new NameIdPair + { + Name = string.IsNullOrWhiteSpace(lineup.Name) ? lineup.Lineup : lineup.Name, + Id = lineup.Uri?[18..] + }); + } + } + } + else + { + _logger.LogInformation("No lineups available"); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Error getting headends"); + } + + return lineups; + } + + private async Task GetToken(ListingsProviderInfo info, CancellationToken cancellationToken) + { + var username = info.Username; + + // Reset the token if there's no username + if (string.IsNullOrWhiteSpace(username)) + { + return null; + } + + var password = info.Password; + if (string.IsNullOrEmpty(password)) + { + return null; + } + + // Avoid hammering SD + if ((DateTime.UtcNow - _lastErrorResponse).TotalMinutes < 1) + { + return null; + } + + if (!_tokens.TryGetValue(username, out NameValuePair savedToken)) + { + savedToken = new NameValuePair(); + _tokens.TryAdd(username, savedToken); + } + + if (!string.IsNullOrEmpty(savedToken.Name) + && long.TryParse(savedToken.Value, CultureInfo.InvariantCulture, out long ticks)) + { + // If it's under 24 hours old we can still use it + if (DateTime.UtcNow.Ticks - ticks < TimeSpan.FromHours(20).Ticks) + { + return savedToken.Name; + } + } + + await _tokenSemaphore.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + var result = await GetTokenInternal(username, password, cancellationToken).ConfigureAwait(false); + savedToken.Name = result; + savedToken.Value = DateTime.UtcNow.Ticks.ToString(CultureInfo.InvariantCulture); + return result; + } + catch (HttpRequestException ex) + { + if (ex.StatusCode.HasValue && ex.StatusCode.Value == HttpStatusCode.BadRequest) + { + _tokens.Clear(); + _lastErrorResponse = DateTime.UtcNow; + } + + throw; + } + finally + { + _tokenSemaphore.Release(); + } + } + + private async Task Send( + HttpRequestMessage options, + bool enableRetry, + ListingsProviderInfo providerInfo, + CancellationToken cancellationToken, + HttpCompletionOption completionOption = HttpCompletionOption.ResponseContentRead) + { + var response = await _httpClientFactory.CreateClient(NamedClient.Default) + .SendAsync(options, completionOption, cancellationToken).ConfigureAwait(false); + if (response.IsSuccessStatusCode) + { + return response; + } + + // Response is automatically disposed in the calling function, + // so dispose manually if not returning. +#pragma warning disable IDISP016, IDISP017 + response.Dispose(); + if (!enableRetry || (int)response.StatusCode >= 500) + { + throw new HttpRequestException( + string.Format(CultureInfo.InvariantCulture, "Request failed: {0}", response.ReasonPhrase), + null, + response.StatusCode); + } +#pragma warning restore IDISP016, IDISP017 + + _tokens.Clear(); + options.Headers.TryAddWithoutValidation("token", await GetToken(providerInfo, cancellationToken).ConfigureAwait(false)); + return await Send(options, false, providerInfo, cancellationToken).ConfigureAwait(false); + } + + private async Task GetTokenInternal( + string username, + string password, + CancellationToken cancellationToken) + { + using var options = new HttpRequestMessage(HttpMethod.Post, ApiUrl + "/token"); +#pragma warning disable CA5350 // SchedulesDirect is always SHA1. + var hashedPasswordBytes = SHA1.HashData(Encoding.ASCII.GetBytes(password)); +#pragma warning restore CA5350 + // TODO: remove ToLower when Convert.ToHexString supports lowercase + // Schedules Direct requires the hex to be lowercase + string hashedPassword = Convert.ToHexString(hashedPasswordBytes).ToLowerInvariant(); + options.Content = new StringContent("{\"username\":\"" + username + "\",\"password\":\"" + hashedPassword + "\"}", Encoding.UTF8, MediaTypeNames.Application.Json); + + using var response = await Send(options, false, null, cancellationToken).ConfigureAwait(false); + response.EnsureSuccessStatusCode(); + var root = await response.Content.ReadFromJsonAsync(_jsonOptions, cancellationToken).ConfigureAwait(false); + if (string.Equals(root?.Message, "OK", StringComparison.Ordinal)) + { + _logger.LogInformation("Authenticated with Schedules Direct token: {Token}", root.Token); + return root.Token; + } + + throw new AuthenticationException("Could not authenticate with Schedules Direct Error: " + root.Message); + } + + private async Task AddLineupToAccount(ListingsProviderInfo info, CancellationToken cancellationToken) + { + var token = await GetToken(info, cancellationToken).ConfigureAwait(false); + + ArgumentException.ThrowIfNullOrEmpty(token); + ArgumentException.ThrowIfNullOrEmpty(info.ListingsId); + + _logger.LogInformation("Adding new LineUp "); + + using var options = new HttpRequestMessage(HttpMethod.Put, ApiUrl + "/lineups/" + info.ListingsId); + options.Headers.TryAddWithoutValidation("token", token); + using var response = await _httpClientFactory.CreateClient(NamedClient.Default).SendAsync(options, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); + } + + private async Task HasLineup(ListingsProviderInfo info, CancellationToken cancellationToken) + { + ArgumentException.ThrowIfNullOrEmpty(info.ListingsId); + + var token = await GetToken(info, cancellationToken).ConfigureAwait(false); + + ArgumentException.ThrowIfNullOrEmpty(token); + + _logger.LogInformation("Headends on account "); + + using var options = new HttpRequestMessage(HttpMethod.Get, ApiUrl + "/lineups"); + options.Headers.TryAddWithoutValidation("token", token); + + try + { + using var httpResponse = await Send(options, false, null, cancellationToken).ConfigureAwait(false); + httpResponse.EnsureSuccessStatusCode(); + var root = await httpResponse.Content.ReadFromJsonAsync(_jsonOptions, cancellationToken).ConfigureAwait(false); + return root?.Lineups.Any(i => string.Equals(info.ListingsId, i.Lineup, StringComparison.OrdinalIgnoreCase)) ?? false; + } + catch (HttpRequestException ex) + { + // SchedulesDirect returns 400 if no lineups are configured. + if (ex.StatusCode is HttpStatusCode.BadRequest) + { + return false; + } + + throw; + } + } + + public async Task Validate(ListingsProviderInfo info, bool validateLogin, bool validateListings) + { + if (validateLogin) + { + ArgumentException.ThrowIfNullOrEmpty(info.Username); + ArgumentException.ThrowIfNullOrEmpty(info.Password); + } + + if (validateListings) + { + ArgumentException.ThrowIfNullOrEmpty(info.ListingsId); + + var hasLineup = await HasLineup(info, CancellationToken.None).ConfigureAwait(false); + + if (!hasLineup) + { + await AddLineupToAccount(info, CancellationToken.None).ConfigureAwait(false); + } + } + } + + public Task> GetLineups(ListingsProviderInfo info, string country, string location) + { + return GetHeadends(info, country, location, CancellationToken.None); + } + + public async Task> GetChannels(ListingsProviderInfo info, CancellationToken cancellationToken) + { + var listingsId = info.ListingsId; + ArgumentException.ThrowIfNullOrEmpty(listingsId); + + var token = await GetToken(info, cancellationToken).ConfigureAwait(false); + + ArgumentException.ThrowIfNullOrEmpty(token); + + using var options = new HttpRequestMessage(HttpMethod.Get, ApiUrl + "/lineups/" + listingsId); + options.Headers.TryAddWithoutValidation("token", token); + + using var httpResponse = await Send(options, true, info, cancellationToken).ConfigureAwait(false); + var root = await httpResponse.Content.ReadFromJsonAsync(_jsonOptions, cancellationToken).ConfigureAwait(false); + if (root is null) + { + return new List(); + } + + _logger.LogInformation("Found {ChannelCount} channels on the lineup on ScheduleDirect", root.Map.Count); + _logger.LogInformation("Mapping Stations to Channel"); + + var allStations = root.Stations; + + var map = root.Map; + var list = new List(map.Count); + foreach (var channel in map) + { + var channelNumber = GetChannelNumber(channel); + + var stationIndex = allStations.FindIndex(item => string.Equals(item.StationId, channel.StationId, StringComparison.OrdinalIgnoreCase)); + var station = stationIndex == -1 + ? new StationDto { StationId = channel.StationId } + : allStations[stationIndex]; + + var channelInfo = new ChannelInfo + { + Id = station.StationId, + CallSign = station.Callsign, + Number = channelNumber, + Name = string.IsNullOrWhiteSpace(station.Name) ? channelNumber : station.Name + }; + + if (station.Logo is not null) + { + channelInfo.ImageUrl = station.Logo.Url; + } + + list.Add(channelInfo); + } + + return list; + } + + /// + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + /// + /// Releases unmanaged and optionally managed resources. + /// + /// true to release both managed and unmanaged resources; false to release only unmanaged resources. + protected virtual void Dispose(bool disposing) + { + if (_disposed) + { + return; + } + + if (disposing) + { + _tokenSemaphore?.Dispose(); + } + + _disposed = true; + } + } +} diff --git a/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/BroadcasterDto.cs b/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/BroadcasterDto.cs new file mode 100644 index 000000000..c1a502fd5 --- /dev/null +++ b/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/BroadcasterDto.cs @@ -0,0 +1,34 @@ +using System.Text.Json.Serialization; + +namespace Jellyfin.LiveTv.Listings.SchedulesDirectDtos +{ + /// + /// Broadcaster dto. + /// + public class BroadcasterDto + { + /// + /// Gets or sets the city. + /// + [JsonPropertyName("city")] + public string? City { get; set; } + + /// + /// Gets or sets the state. + /// + [JsonPropertyName("state")] + public string? State { get; set; } + + /// + /// Gets or sets the postal code. + /// + [JsonPropertyName("postalCode")] + public string? Postalcode { get; set; } + + /// + /// Gets or sets the country. + /// + [JsonPropertyName("country")] + public string? Country { get; set; } + } +} diff --git a/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/CaptionDto.cs b/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/CaptionDto.cs new file mode 100644 index 000000000..0cc39f3bb --- /dev/null +++ b/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/CaptionDto.cs @@ -0,0 +1,22 @@ +using System.Text.Json.Serialization; + +namespace Jellyfin.LiveTv.Listings.SchedulesDirectDtos +{ + /// + /// Caption dto. + /// + public class CaptionDto + { + /// + /// Gets or sets the content. + /// + [JsonPropertyName("content")] + public string? Content { get; set; } + + /// + /// Gets or sets the lang. + /// + [JsonPropertyName("lang")] + public string? Lang { get; set; } + } +} diff --git a/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/CastDto.cs b/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/CastDto.cs new file mode 100644 index 000000000..bdcf87fda --- /dev/null +++ b/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/CastDto.cs @@ -0,0 +1,46 @@ +using System.Text.Json.Serialization; + +namespace Jellyfin.LiveTv.Listings.SchedulesDirectDtos +{ + /// + /// Cast dto. + /// + public class CastDto + { + /// + /// Gets or sets the billing order. + /// + [JsonPropertyName("billingOrder")] + public string? BillingOrder { get; set; } + + /// + /// Gets or sets the role. + /// + [JsonPropertyName("role")] + public string? Role { get; set; } + + /// + /// Gets or sets the name id. + /// + [JsonPropertyName("nameId")] + public string? NameId { get; set; } + + /// + /// Gets or sets the person id. + /// + [JsonPropertyName("personId")] + public string? PersonId { get; set; } + + /// + /// Gets or sets the name. + /// + [JsonPropertyName("name")] + public string? Name { get; set; } + + /// + /// Gets or sets the character name. + /// + [JsonPropertyName("characterName")] + public string? CharacterName { get; set; } + } +} diff --git a/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/ChannelDto.cs b/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/ChannelDto.cs new file mode 100644 index 000000000..4e0d74078 --- /dev/null +++ b/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/ChannelDto.cs @@ -0,0 +1,30 @@ +using System; +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace Jellyfin.LiveTv.Listings.SchedulesDirectDtos +{ + /// + /// Channel dto. + /// + public class ChannelDto + { + /// + /// Gets or sets the list of maps. + /// + [JsonPropertyName("map")] + public IReadOnlyList Map { get; set; } = Array.Empty(); + + /// + /// Gets or sets the list of stations. + /// + [JsonPropertyName("stations")] + public IReadOnlyList Stations { get; set; } = Array.Empty(); + + /// + /// Gets or sets the metadata. + /// + [JsonPropertyName("metadata")] + public MetadataDto? Metadata { get; set; } + } +} diff --git a/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/ContentRatingDto.cs b/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/ContentRatingDto.cs new file mode 100644 index 000000000..5c624c288 --- /dev/null +++ b/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/ContentRatingDto.cs @@ -0,0 +1,22 @@ +using System.Text.Json.Serialization; + +namespace Jellyfin.LiveTv.Listings.SchedulesDirectDtos +{ + /// + /// Content rating dto. + /// + public class ContentRatingDto + { + /// + /// Gets or sets the body. + /// + [JsonPropertyName("body")] + public string? Body { get; set; } + + /// + /// Gets or sets the code. + /// + [JsonPropertyName("code")] + public string? Code { get; set; } + } +} diff --git a/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/CrewDto.cs b/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/CrewDto.cs new file mode 100644 index 000000000..6d3c79c18 --- /dev/null +++ b/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/CrewDto.cs @@ -0,0 +1,40 @@ +using System.Text.Json.Serialization; + +namespace Jellyfin.LiveTv.Listings.SchedulesDirectDtos +{ + /// + /// Crew dto. + /// + public class CrewDto + { + /// + /// Gets or sets the billing order. + /// + [JsonPropertyName("billingOrder")] + public string? BillingOrder { get; set; } + + /// + /// Gets or sets the role. + /// + [JsonPropertyName("role")] + public string? Role { get; set; } + + /// + /// Gets or sets the name id. + /// + [JsonPropertyName("nameId")] + public string? NameId { get; set; } + + /// + /// Gets or sets the person id. + /// + [JsonPropertyName("personId")] + public string? PersonId { get; set; } + + /// + /// Gets or sets the name. + /// + [JsonPropertyName("name")] + public string? Name { get; set; } + } +} diff --git a/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/DayDto.cs b/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/DayDto.cs new file mode 100644 index 000000000..094f9a319 --- /dev/null +++ b/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/DayDto.cs @@ -0,0 +1,30 @@ +using System; +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace Jellyfin.LiveTv.Listings.SchedulesDirectDtos +{ + /// + /// Day dto. + /// + public class DayDto + { + /// + /// Gets or sets the station id. + /// + [JsonPropertyName("stationID")] + public string? StationId { get; set; } + + /// + /// Gets or sets the list of programs. + /// + [JsonPropertyName("programs")] + public IReadOnlyList Programs { get; set; } = Array.Empty(); + + /// + /// Gets or sets the metadata schedule. + /// + [JsonPropertyName("metadata")] + public MetadataScheduleDto? Metadata { get; set; } + } +} diff --git a/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/Description1000Dto.cs b/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/Description1000Dto.cs new file mode 100644 index 000000000..0063f4cc3 --- /dev/null +++ b/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/Description1000Dto.cs @@ -0,0 +1,22 @@ +using System.Text.Json.Serialization; + +namespace Jellyfin.LiveTv.Listings.SchedulesDirectDtos +{ + /// + /// Description 1_000 dto. + /// + public class Description1000Dto + { + /// + /// Gets or sets the description language. + /// + [JsonPropertyName("descriptionLanguage")] + public string? DescriptionLanguage { get; set; } + + /// + /// Gets or sets the description. + /// + [JsonPropertyName("description")] + public string? Description { get; set; } + } +} diff --git a/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/Description100Dto.cs b/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/Description100Dto.cs new file mode 100644 index 000000000..1d9a18cc7 --- /dev/null +++ b/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/Description100Dto.cs @@ -0,0 +1,22 @@ +using System.Text.Json.Serialization; + +namespace Jellyfin.LiveTv.Listings.SchedulesDirectDtos +{ + /// + /// Description 100 dto. + /// + public class Description100Dto + { + /// + /// Gets or sets the description language. + /// + [JsonPropertyName("descriptionLanguage")] + public string? DescriptionLanguage { get; set; } + + /// + /// Gets or sets the description. + /// + [JsonPropertyName("description")] + public string? Description { get; set; } + } +} diff --git a/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/DescriptionsProgramDto.cs b/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/DescriptionsProgramDto.cs new file mode 100644 index 000000000..75e91547b --- /dev/null +++ b/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/DescriptionsProgramDto.cs @@ -0,0 +1,24 @@ +using System; +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace Jellyfin.LiveTv.Listings.SchedulesDirectDtos +{ + /// + /// Descriptions program dto. + /// + public class DescriptionsProgramDto + { + /// + /// Gets or sets the list of description 100. + /// + [JsonPropertyName("description100")] + public IReadOnlyList Description100 { get; set; } = Array.Empty(); + + /// + /// Gets or sets the list of description1000. + /// + [JsonPropertyName("description1000")] + public IReadOnlyList Description1000 { get; set; } = Array.Empty(); + } +} diff --git a/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/EventDetailsDto.cs b/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/EventDetailsDto.cs new file mode 100644 index 000000000..28abe094e --- /dev/null +++ b/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/EventDetailsDto.cs @@ -0,0 +1,16 @@ +using System.Text.Json.Serialization; + +namespace Jellyfin.LiveTv.Listings.SchedulesDirectDtos +{ + /// + /// Event details dto. + /// + public class EventDetailsDto + { + /// + /// Gets or sets the sub type. + /// + [JsonPropertyName("subType")] + public string? SubType { get; set; } + } +} diff --git a/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/GracenoteDto.cs b/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/GracenoteDto.cs new file mode 100644 index 000000000..6eefc1744 --- /dev/null +++ b/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/GracenoteDto.cs @@ -0,0 +1,22 @@ +using System.Text.Json.Serialization; + +namespace Jellyfin.LiveTv.Listings.SchedulesDirectDtos +{ + /// + /// Gracenote dto. + /// + public class GracenoteDto + { + /// + /// Gets or sets the season. + /// + [JsonPropertyName("season")] + public int Season { get; set; } + + /// + /// Gets or sets the episode. + /// + [JsonPropertyName("episode")] + public int Episode { get; set; } + } +} diff --git a/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/HeadendsDto.cs b/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/HeadendsDto.cs new file mode 100644 index 000000000..a62ae61f9 --- /dev/null +++ b/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/HeadendsDto.cs @@ -0,0 +1,36 @@ +using System; +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace Jellyfin.LiveTv.Listings.SchedulesDirectDtos +{ + /// + /// Headends dto. + /// + public class HeadendsDto + { + /// + /// Gets or sets the headend. + /// + [JsonPropertyName("headend")] + public string? Headend { get; set; } + + /// + /// Gets or sets the transport. + /// + [JsonPropertyName("transport")] + public string? Transport { get; set; } + + /// + /// Gets or sets the location. + /// + [JsonPropertyName("location")] + public string? Location { get; set; } + + /// + /// Gets or sets the list of lineups. + /// + [JsonPropertyName("lineups")] + public IReadOnlyList Lineups { get; set; } = Array.Empty(); + } +} diff --git a/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/ImageDataDto.cs b/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/ImageDataDto.cs new file mode 100644 index 000000000..21b595f24 --- /dev/null +++ b/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/ImageDataDto.cs @@ -0,0 +1,70 @@ +using System.Text.Json.Serialization; + +namespace Jellyfin.LiveTv.Listings.SchedulesDirectDtos +{ + /// + /// Image data dto. + /// + public class ImageDataDto + { + /// + /// Gets or sets the width. + /// + [JsonPropertyName("width")] + public string? Width { get; set; } + + /// + /// Gets or sets the height. + /// + [JsonPropertyName("height")] + public string? Height { get; set; } + + /// + /// Gets or sets the uri. + /// + [JsonPropertyName("uri")] + public string? Uri { get; set; } + + /// + /// Gets or sets the size. + /// + [JsonPropertyName("size")] + public string? Size { get; set; } + + /// + /// Gets or sets the aspect. + /// + [JsonPropertyName("aspect")] + public string? Aspect { get; set; } + + /// + /// Gets or sets the category. + /// + [JsonPropertyName("category")] + public string? Category { get; set; } + + /// + /// Gets or sets the text. + /// + [JsonPropertyName("text")] + public string? Text { get; set; } + + /// + /// Gets or sets the primary. + /// + [JsonPropertyName("primary")] + public string? Primary { get; set; } + + /// + /// Gets or sets the tier. + /// + [JsonPropertyName("tier")] + public string? Tier { get; set; } + + /// + /// Gets or sets the caption. + /// + [JsonPropertyName("caption")] + public CaptionDto? Caption { get; set; } + } +} diff --git a/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/LineupDto.cs b/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/LineupDto.cs new file mode 100644 index 000000000..856b7a89b --- /dev/null +++ b/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/LineupDto.cs @@ -0,0 +1,46 @@ +using System.Text.Json.Serialization; + +namespace Jellyfin.LiveTv.Listings.SchedulesDirectDtos +{ + /// + /// The lineup dto. + /// + public class LineupDto + { + /// + /// Gets or sets the linup. + /// + [JsonPropertyName("lineup")] + public string? Lineup { get; set; } + + /// + /// Gets or sets the lineup name. + /// + [JsonPropertyName("name")] + public string? Name { get; set; } + + /// + /// Gets or sets the transport. + /// + [JsonPropertyName("transport")] + public string? Transport { get; set; } + + /// + /// Gets or sets the location. + /// + [JsonPropertyName("location")] + public string? Location { get; set; } + + /// + /// Gets or sets the uri. + /// + [JsonPropertyName("uri")] + public string? Uri { get; set; } + + /// + /// Gets or sets a value indicating whether this lineup was deleted. + /// + [JsonPropertyName("isDeleted")] + public bool? IsDeleted { get; set; } + } +} diff --git a/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/LineupsDto.cs b/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/LineupsDto.cs new file mode 100644 index 000000000..99f80ce8a --- /dev/null +++ b/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/LineupsDto.cs @@ -0,0 +1,36 @@ +using System; +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace Jellyfin.LiveTv.Listings.SchedulesDirectDtos +{ + /// + /// Lineups dto. + /// + public class LineupsDto + { + /// + /// Gets or sets the response code. + /// + [JsonPropertyName("code")] + public int Code { get; set; } + + /// + /// Gets or sets the server id. + /// + [JsonPropertyName("serverID")] + public string? ServerId { get; set; } + + /// + /// Gets or sets the datetime. + /// + [JsonPropertyName("datetime")] + public DateTime? LineupTimestamp { get; set; } + + /// + /// Gets or sets the list of lineups. + /// + [JsonPropertyName("lineups")] + public IReadOnlyList Lineups { get; set; } = Array.Empty(); + } +} diff --git a/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/LogoDto.cs b/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/LogoDto.cs new file mode 100644 index 000000000..d7836384e --- /dev/null +++ b/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/LogoDto.cs @@ -0,0 +1,34 @@ +using System.Text.Json.Serialization; + +namespace Jellyfin.LiveTv.Listings.SchedulesDirectDtos +{ + /// + /// Logo dto. + /// + public class LogoDto + { + /// + /// Gets or sets the url. + /// + [JsonPropertyName("URL")] + public string? Url { get; set; } + + /// + /// Gets or sets the height. + /// + [JsonPropertyName("height")] + public int Height { get; set; } + + /// + /// Gets or sets the width. + /// + [JsonPropertyName("width")] + public int Width { get; set; } + + /// + /// Gets or sets the md5. + /// + [JsonPropertyName("md5")] + public string? Md5 { get; set; } + } +} diff --git a/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/MapDto.cs b/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/MapDto.cs new file mode 100644 index 000000000..ea583a1ce --- /dev/null +++ b/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/MapDto.cs @@ -0,0 +1,58 @@ +using System.Text.Json.Serialization; + +namespace Jellyfin.LiveTv.Listings.SchedulesDirectDtos +{ + /// + /// Map dto. + /// + public class MapDto + { + /// + /// Gets or sets the station id. + /// + [JsonPropertyName("stationID")] + public string? StationId { get; set; } + + /// + /// Gets or sets the channel. + /// + [JsonPropertyName("channel")] + public string? Channel { get; set; } + + /// + /// Gets or sets the provider callsign. + /// + [JsonPropertyName("providerCallsign")] + public string? ProvderCallsign { get; set; } + + /// + /// Gets or sets the logical channel number. + /// + [JsonPropertyName("logicalChannelNumber")] + public string? LogicalChannelNumber { get; set; } + + /// + /// Gets or sets the uhfvhf. + /// + [JsonPropertyName("uhfVhf")] + public int UhfVhf { get; set; } + + /// + /// Gets or sets the atsc major. + /// + [JsonPropertyName("atscMajor")] + public int AtscMajor { get; set; } + + /// + /// Gets or sets the atsc minor. + /// + [JsonPropertyName("atscMinor")] + public int AtscMinor { get; set; } + + /// + /// Gets or sets the match type. + /// + [JsonPropertyName("matchType")] + public string? MatchType { get; set; } + } +} diff --git a/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/MetadataDto.cs b/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/MetadataDto.cs new file mode 100644 index 000000000..cafc8e273 --- /dev/null +++ b/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/MetadataDto.cs @@ -0,0 +1,28 @@ +using System.Text.Json.Serialization; + +namespace Jellyfin.LiveTv.Listings.SchedulesDirectDtos +{ + /// + /// Metadata dto. + /// + public class MetadataDto + { + /// + /// Gets or sets the linup. + /// + [JsonPropertyName("lineup")] + public string? Lineup { get; set; } + + /// + /// Gets or sets the modified timestamp. + /// + [JsonPropertyName("modified")] + public string? Modified { get; set; } + + /// + /// Gets or sets the transport. + /// + [JsonPropertyName("transport")] + public string? Transport { get; set; } + } +} diff --git a/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/MetadataProgramsDto.cs b/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/MetadataProgramsDto.cs new file mode 100644 index 000000000..243ccff5c --- /dev/null +++ b/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/MetadataProgramsDto.cs @@ -0,0 +1,16 @@ +using System.Text.Json.Serialization; + +namespace Jellyfin.LiveTv.Listings.SchedulesDirectDtos +{ + /// + /// Metadata programs dto. + /// + public class MetadataProgramsDto + { + /// + /// Gets or sets the gracenote object. + /// + [JsonPropertyName("Gracenote")] + public GracenoteDto? Gracenote { get; set; } + } +} diff --git a/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/MetadataScheduleDto.cs b/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/MetadataScheduleDto.cs new file mode 100644 index 000000000..1c5c5333c --- /dev/null +++ b/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/MetadataScheduleDto.cs @@ -0,0 +1,41 @@ +using System; +using System.Text.Json.Serialization; + +namespace Jellyfin.LiveTv.Listings.SchedulesDirectDtos +{ + /// + /// Metadata schedule dto. + /// + public class MetadataScheduleDto + { + /// + /// Gets or sets the modified timestamp. + /// + [JsonPropertyName("modified")] + public string? Modified { get; set; } + + /// + /// Gets or sets the md5. + /// + [JsonPropertyName("md5")] + public string? Md5 { get; set; } + + /// + /// Gets or sets the start date. + /// + [JsonPropertyName("startDate")] + public DateTime? StartDate { get; set; } + + /// + /// Gets or sets the end date. + /// + [JsonPropertyName("endDate")] + public DateTime? EndDate { get; set; } + + /// + /// Gets or sets the days count. + /// + [JsonPropertyName("days")] + public int Days { get; set; } + } +} diff --git a/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/MovieDto.cs b/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/MovieDto.cs new file mode 100644 index 000000000..aea740833 --- /dev/null +++ b/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/MovieDto.cs @@ -0,0 +1,30 @@ +using System; +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace Jellyfin.LiveTv.Listings.SchedulesDirectDtos +{ + /// + /// Movie dto. + /// + public class MovieDto + { + /// + /// Gets or sets the year. + /// + [JsonPropertyName("year")] + public string? Year { get; set; } + + /// + /// Gets or sets the duration. + /// + [JsonPropertyName("duration")] + public int Duration { get; set; } + + /// + /// Gets or sets the list of quality rating. + /// + [JsonPropertyName("qualityRating")] + public IReadOnlyList QualityRating { get; set; } = Array.Empty(); + } +} diff --git a/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/MultipartDto.cs b/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/MultipartDto.cs new file mode 100644 index 000000000..328cefadc --- /dev/null +++ b/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/MultipartDto.cs @@ -0,0 +1,22 @@ +using System.Text.Json.Serialization; + +namespace Jellyfin.LiveTv.Listings.SchedulesDirectDtos +{ + /// + /// Multipart dto. + /// + public class MultipartDto + { + /// + /// Gets or sets the part number. + /// + [JsonPropertyName("partNumber")] + public int PartNumber { get; set; } + + /// + /// Gets or sets the total parts. + /// + [JsonPropertyName("totalParts")] + public int TotalParts { get; set; } + } +} diff --git a/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/ProgramDetailsDto.cs b/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/ProgramDetailsDto.cs new file mode 100644 index 000000000..8c3906f86 --- /dev/null +++ b/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/ProgramDetailsDto.cs @@ -0,0 +1,156 @@ +using System; +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace Jellyfin.LiveTv.Listings.SchedulesDirectDtos +{ + /// + /// Program details dto. + /// + public class ProgramDetailsDto + { + /// + /// Gets or sets the audience. + /// + [JsonPropertyName("audience")] + public string? Audience { get; set; } + + /// + /// Gets or sets the program id. + /// + [JsonPropertyName("programID")] + public string? ProgramId { get; set; } + + /// + /// Gets or sets the list of titles. + /// + [JsonPropertyName("titles")] + public IReadOnlyList Titles { get; set; } = Array.Empty(); + + /// + /// Gets or sets the event details object. + /// + [JsonPropertyName("eventDetails")] + public EventDetailsDto? EventDetails { get; set; } + + /// + /// Gets or sets the descriptions. + /// + [JsonPropertyName("descriptions")] + public DescriptionsProgramDto? Descriptions { get; set; } + + /// + /// Gets or sets the original air date. + /// + [JsonPropertyName("originalAirDate")] + public DateTime? OriginalAirDate { get; set; } + + /// + /// Gets or sets the list of genres. + /// + [JsonPropertyName("genres")] + public IReadOnlyList Genres { get; set; } = Array.Empty(); + + /// + /// Gets or sets the episode title. + /// + [JsonPropertyName("episodeTitle150")] + public string? EpisodeTitle150 { get; set; } + + /// + /// Gets or sets the list of metadata. + /// + [JsonPropertyName("metadata")] + public IReadOnlyList Metadata { get; set; } = Array.Empty(); + + /// + /// Gets or sets the list of content raitings. + /// + [JsonPropertyName("contentRating")] + public IReadOnlyList ContentRating { get; set; } = Array.Empty(); + + /// + /// Gets or sets the list of cast. + /// + [JsonPropertyName("cast")] + public IReadOnlyList Cast { get; set; } = Array.Empty(); + + /// + /// Gets or sets the list of crew. + /// + [JsonPropertyName("crew")] + public IReadOnlyList Crew { get; set; } = Array.Empty(); + + /// + /// Gets or sets the entity type. + /// + [JsonPropertyName("entityType")] + public string? EntityType { get; set; } + + /// + /// Gets or sets the show type. + /// + [JsonPropertyName("showType")] + public string? ShowType { get; set; } + + /// + /// Gets or sets a value indicating whether there is image artwork. + /// + [JsonPropertyName("hasImageArtwork")] + public bool HasImageArtwork { get; set; } + + /// + /// Gets or sets the primary image. + /// + [JsonPropertyName("primaryImage")] + public string? PrimaryImage { get; set; } + + /// + /// Gets or sets the thumb image. + /// + [JsonPropertyName("thumbImage")] + public string? ThumbImage { get; set; } + + /// + /// Gets or sets the backdrop image. + /// + [JsonPropertyName("backdropImage")] + public string? BackdropImage { get; set; } + + /// + /// Gets or sets the banner image. + /// + [JsonPropertyName("bannerImage")] + public string? BannerImage { get; set; } + + /// + /// Gets or sets the image id. + /// + [JsonPropertyName("imageID")] + public string? ImageId { get; set; } + + /// + /// Gets or sets the md5. + /// + [JsonPropertyName("md5")] + public string? Md5 { get; set; } + + /// + /// Gets or sets the list of content advisory. + /// + [JsonPropertyName("contentAdvisory")] + public IReadOnlyList ContentAdvisory { get; set; } = Array.Empty(); + + /// + /// Gets or sets the movie object. + /// + [JsonPropertyName("movie")] + public MovieDto? Movie { get; set; } + + /// + /// Gets or sets the list of recommendations. + /// + [JsonPropertyName("recommendations")] + public IReadOnlyList Recommendations { get; set; } = Array.Empty(); + } +} diff --git a/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/ProgramDto.cs b/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/ProgramDto.cs new file mode 100644 index 000000000..527a6f8a1 --- /dev/null +++ b/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/ProgramDto.cs @@ -0,0 +1,90 @@ +using System; +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace Jellyfin.LiveTv.Listings.SchedulesDirectDtos +{ + /// + /// Program dto. + /// + public class ProgramDto + { + /// + /// Gets or sets the program id. + /// + [JsonPropertyName("programID")] + public string? ProgramId { get; set; } + + /// + /// Gets or sets the air date time. + /// + [JsonPropertyName("airDateTime")] + public DateTime? AirDateTime { get; set; } + + /// + /// Gets or sets the duration. + /// + [JsonPropertyName("duration")] + public int Duration { get; set; } + + /// + /// Gets or sets the md5. + /// + [JsonPropertyName("md5")] + public string? Md5 { get; set; } + + /// + /// Gets or sets the list of audio properties. + /// + [JsonPropertyName("audioProperties")] + public IReadOnlyList AudioProperties { get; set; } = Array.Empty(); + + /// + /// Gets or sets the list of video properties. + /// + [JsonPropertyName("videoProperties")] + public IReadOnlyList VideoProperties { get; set; } = Array.Empty(); + + /// + /// Gets or sets the list of ratings. + /// + [JsonPropertyName("ratings")] + public IReadOnlyList Ratings { get; set; } = Array.Empty(); + + /// + /// Gets or sets a value indicating whether this program is new. + /// + [JsonPropertyName("new")] + public bool? New { get; set; } + + /// + /// Gets or sets the multipart object. + /// + [JsonPropertyName("multipart")] + public MultipartDto? Multipart { get; set; } + + /// + /// Gets or sets the live tape delay. + /// + [JsonPropertyName("liveTapeDelay")] + public string? LiveTapeDelay { get; set; } + + /// + /// Gets or sets a value indicating whether this is the premiere. + /// + [JsonPropertyName("premiere")] + public bool Premiere { get; set; } + + /// + /// Gets or sets a value indicating whether this is a repeat. + /// + [JsonPropertyName("repeat")] + public bool Repeat { get; set; } + + /// + /// Gets or sets the premiere or finale. + /// + [JsonPropertyName("isPremiereOrFinale")] + public string? IsPremiereOrFinale { get; set; } + } +} diff --git a/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/QualityRatingDto.cs b/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/QualityRatingDto.cs new file mode 100644 index 000000000..61496155a --- /dev/null +++ b/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/QualityRatingDto.cs @@ -0,0 +1,40 @@ +using System.Text.Json.Serialization; + +namespace Jellyfin.LiveTv.Listings.SchedulesDirectDtos +{ + /// + /// Quality rating dto. + /// + public class QualityRatingDto + { + /// + /// Gets or sets the ratings body. + /// + [JsonPropertyName("ratingsBody")] + public string? RatingsBody { get; set; } + + /// + /// Gets or sets the rating. + /// + [JsonPropertyName("rating")] + public string? Rating { get; set; } + + /// + /// Gets or sets the min rating. + /// + [JsonPropertyName("minRating")] + public string? MinRating { get; set; } + + /// + /// Gets or sets the max rating. + /// + [JsonPropertyName("maxRating")] + public string? MaxRating { get; set; } + + /// + /// Gets or sets the increment. + /// + [JsonPropertyName("increment")] + public string? Increment { get; set; } + } +} diff --git a/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/RatingDto.cs b/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/RatingDto.cs new file mode 100644 index 000000000..287cd4ed5 --- /dev/null +++ b/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/RatingDto.cs @@ -0,0 +1,22 @@ +using System.Text.Json.Serialization; + +namespace Jellyfin.LiveTv.Listings.SchedulesDirectDtos +{ + /// + /// Rating dto. + /// + public class RatingDto + { + /// + /// Gets or sets the body. + /// + [JsonPropertyName("body")] + public string? Body { get; set; } + + /// + /// Gets or sets the code. + /// + [JsonPropertyName("code")] + public string? Code { get; set; } + } +} diff --git a/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/RecommendationDto.cs b/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/RecommendationDto.cs new file mode 100644 index 000000000..d380ec7ae --- /dev/null +++ b/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/RecommendationDto.cs @@ -0,0 +1,22 @@ +using System.Text.Json.Serialization; + +namespace Jellyfin.LiveTv.Listings.SchedulesDirectDtos +{ + /// + /// Recommendation dto. + /// + public class RecommendationDto + { + /// + /// Gets or sets the program id. + /// + [JsonPropertyName("programID")] + public string? ProgramId { get; set; } + + /// + /// Gets or sets the title. + /// + [JsonPropertyName("title120")] + public string? Title120 { get; set; } + } +} diff --git a/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/RequestScheduleForChannelDto.cs b/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/RequestScheduleForChannelDto.cs new file mode 100644 index 000000000..6fc695a39 --- /dev/null +++ b/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/RequestScheduleForChannelDto.cs @@ -0,0 +1,24 @@ +using System; +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace Jellyfin.LiveTv.Listings.SchedulesDirectDtos +{ + /// + /// Request schedule for channel dto. + /// + public class RequestScheduleForChannelDto + { + /// + /// Gets or sets the station id. + /// + [JsonPropertyName("stationID")] + public string? StationId { get; set; } + + /// + /// Gets or sets the list of dates. + /// + [JsonPropertyName("date")] + public IReadOnlyList Date { get; set; } = Array.Empty(); + } +} diff --git a/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/ShowImagesDto.cs b/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/ShowImagesDto.cs new file mode 100644 index 000000000..523900a96 --- /dev/null +++ b/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/ShowImagesDto.cs @@ -0,0 +1,24 @@ +using System; +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace Jellyfin.LiveTv.Listings.SchedulesDirectDtos +{ + /// + /// Show image dto. + /// + public class ShowImagesDto + { + /// + /// Gets or sets the program id. + /// + [JsonPropertyName("programID")] + public string? ProgramId { get; set; } + + /// + /// Gets or sets the list of data. + /// + [JsonPropertyName("data")] + public IReadOnlyList Data { get; set; } = Array.Empty(); + } +} diff --git a/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/StationDto.cs b/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/StationDto.cs new file mode 100644 index 000000000..dbde1e117 --- /dev/null +++ b/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/StationDto.cs @@ -0,0 +1,66 @@ +using System; +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace Jellyfin.LiveTv.Listings.SchedulesDirectDtos +{ + /// + /// Station dto. + /// + public class StationDto + { + /// + /// Gets or sets the station id. + /// + [JsonPropertyName("stationID")] + public string? StationId { get; set; } + + /// + /// Gets or sets the name. + /// + [JsonPropertyName("name")] + public string? Name { get; set; } + + /// + /// Gets or sets the callsign. + /// + [JsonPropertyName("callsign")] + public string? Callsign { get; set; } + + /// + /// Gets or sets the broadcast language. + /// + [JsonPropertyName("broadcastLanguage")] + public IReadOnlyList BroadcastLanguage { get; set; } = Array.Empty(); + + /// + /// Gets or sets the description language. + /// + [JsonPropertyName("descriptionLanguage")] + public IReadOnlyList DescriptionLanguage { get; set; } = Array.Empty(); + + /// + /// Gets or sets the broadcaster. + /// + [JsonPropertyName("broadcaster")] + public BroadcasterDto? Broadcaster { get; set; } + + /// + /// Gets or sets the affiliate. + /// + [JsonPropertyName("affiliate")] + public string? Affiliate { get; set; } + + /// + /// Gets or sets the logo. + /// + [JsonPropertyName("logo")] + public LogoDto? Logo { get; set; } + + /// + /// Gets or sets a value indicating whether it is commercial free. + /// + [JsonPropertyName("isCommercialFree")] + public bool? IsCommercialFree { get; set; } + } +} diff --git a/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/TitleDto.cs b/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/TitleDto.cs new file mode 100644 index 000000000..146124f98 --- /dev/null +++ b/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/TitleDto.cs @@ -0,0 +1,16 @@ +using System.Text.Json.Serialization; + +namespace Jellyfin.LiveTv.Listings.SchedulesDirectDtos +{ + /// + /// Title dto. + /// + public class TitleDto + { + /// + /// Gets or sets the title. + /// + [JsonPropertyName("title120")] + public string? Title120 { get; set; } + } +} diff --git a/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/TokenDto.cs b/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/TokenDto.cs new file mode 100644 index 000000000..b3bc61837 --- /dev/null +++ b/src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/TokenDto.cs @@ -0,0 +1,47 @@ +using System; +using System.Text.Json.Serialization; + +namespace Jellyfin.LiveTv.Listings.SchedulesDirectDtos +{ + /// + /// The token dto. + /// + public class TokenDto + { + /// + /// Gets or sets the response code. + /// + [JsonPropertyName("code")] + public int Code { get; set; } + + /// + /// Gets or sets the response message. + /// + [JsonPropertyName("message")] + public string? Message { get; set; } + + /// + /// Gets or sets the server id. + /// + [JsonPropertyName("serverID")] + public string? ServerId { get; set; } + + /// + /// Gets or sets the token. + /// + [JsonPropertyName("token")] + public string? Token { get; set; } + + /// + /// Gets or sets the current datetime. + /// + [JsonPropertyName("datetime")] + public DateTime? TokenTimestamp { get; set; } + + /// + /// Gets or sets the response message. + /// + [JsonPropertyName("response")] + public string? Response { get; set; } + } +} diff --git a/src/Jellyfin.LiveTv/Listings/XmlTvListingsProvider.cs b/src/Jellyfin.LiveTv/Listings/XmlTvListingsProvider.cs new file mode 100644 index 000000000..cecc363f0 --- /dev/null +++ b/src/Jellyfin.LiveTv/Listings/XmlTvListingsProvider.cs @@ -0,0 +1,267 @@ +#nullable disable + +#pragma warning disable CS1591 + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.IO.Compression; +using System.Linq; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Extensions; +using Jellyfin.XmlTv; +using Jellyfin.XmlTv.Entities; +using MediaBrowser.Common.Extensions; +using MediaBrowser.Common.Net; +using MediaBrowser.Controller.Configuration; +using MediaBrowser.Controller.LiveTv; +using MediaBrowser.Model.Dto; +using MediaBrowser.Model.IO; +using MediaBrowser.Model.LiveTv; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.LiveTv.Listings +{ + public class XmlTvListingsProvider : IListingsProvider + { + private static readonly TimeSpan _maxCacheAge = TimeSpan.FromHours(1); + + private readonly IServerConfigurationManager _config; + private readonly IHttpClientFactory _httpClientFactory; + private readonly ILogger _logger; + + public XmlTvListingsProvider( + IServerConfigurationManager config, + IHttpClientFactory httpClientFactory, + ILogger logger) + { + _config = config; + _httpClientFactory = httpClientFactory; + _logger = logger; + } + + public string Name => "XmlTV"; + + public string Type => "xmltv"; + + private string GetLanguage(ListingsProviderInfo info) + { + if (!string.IsNullOrWhiteSpace(info.PreferredLanguage)) + { + return info.PreferredLanguage; + } + + return _config.Configuration.PreferredMetadataLanguage; + } + + private async Task GetXml(ListingsProviderInfo info, CancellationToken cancellationToken) + { + _logger.LogInformation("xmltv path: {Path}", info.Path); + + string cacheFilename = info.Id + ".xml"; + string cacheFile = Path.Combine(_config.ApplicationPaths.CachePath, "xmltv", cacheFilename); + + if (File.Exists(cacheFile) && File.GetLastWriteTimeUtc(cacheFile) >= DateTime.UtcNow.Subtract(_maxCacheAge)) + { + return cacheFile; + } + + // Must check if file exists as parent directory may not exist. + if (File.Exists(cacheFile)) + { + File.Delete(cacheFile); + } + else + { + Directory.CreateDirectory(Path.GetDirectoryName(cacheFile)); + } + + if (info.Path.StartsWith("http", StringComparison.OrdinalIgnoreCase)) + { + _logger.LogInformation("Downloading xmltv listings from {Path}", info.Path); + + using var response = await _httpClientFactory.CreateClient(NamedClient.Default).GetAsync(info.Path, cancellationToken).ConfigureAwait(false); + var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); + await using (stream.ConfigureAwait(false)) + { + return await UnzipIfNeededAndCopy(info.Path, stream, cacheFile, cancellationToken).ConfigureAwait(false); + } + } + else + { + var stream = AsyncFile.OpenRead(info.Path); + await using (stream.ConfigureAwait(false)) + { + return await UnzipIfNeededAndCopy(info.Path, stream, cacheFile, cancellationToken).ConfigureAwait(false); + } + } + } + + private async Task UnzipIfNeededAndCopy(string originalUrl, Stream stream, string file, CancellationToken cancellationToken) + { + var fileStream = new FileStream( + file, + FileMode.CreateNew, + FileAccess.Write, + FileShare.None, + IODefaults.FileStreamBufferSize, + FileOptions.Asynchronous); + + await using (fileStream.ConfigureAwait(false)) + { + if (Path.GetExtension(originalUrl.AsSpan().LeftPart('?')).Equals(".gz", StringComparison.OrdinalIgnoreCase)) + { + try + { + using var reader = new GZipStream(stream, CompressionMode.Decompress); + await reader.CopyToAsync(fileStream, cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error extracting from gz file {File}", originalUrl); + } + } + else + { + await stream.CopyToAsync(fileStream, cancellationToken).ConfigureAwait(false); + } + + return file; + } + } + + public async Task> GetProgramsAsync(ListingsProviderInfo info, string channelId, DateTime startDateUtc, DateTime endDateUtc, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(channelId)) + { + throw new ArgumentNullException(nameof(channelId)); + } + + _logger.LogDebug("Getting xmltv programs for channel {Id}", channelId); + + string path = await GetXml(info, cancellationToken).ConfigureAwait(false); + _logger.LogDebug("Opening XmlTvReader for {Path}", path); + var reader = new XmlTvReader(path, GetLanguage(info)); + + return reader.GetProgrammes(channelId, startDateUtc, endDateUtc, cancellationToken) + .Select(p => GetProgramInfo(p, info)); + } + + private static ProgramInfo GetProgramInfo(XmlTvProgram program, ListingsProviderInfo info) + { + string episodeTitle = program.Episode.Title; + var programCategories = program.Categories.Where(c => !string.IsNullOrWhiteSpace(c)).ToList(); + + var programInfo = new ProgramInfo + { + ChannelId = program.ChannelId, + EndDate = program.EndDate.UtcDateTime, + EpisodeNumber = program.Episode.Episode, + EpisodeTitle = episodeTitle, + Genres = programCategories, + StartDate = program.StartDate.UtcDateTime, + Name = program.Title, + Overview = program.Description, + ProductionYear = program.CopyrightDate?.Year, + SeasonNumber = program.Episode.Series, + IsSeries = program.Episode.Series is not null, + IsRepeat = program.IsPreviouslyShown && !program.IsNew, + IsPremiere = program.Premiere is not null, + IsKids = programCategories.Any(c => info.KidsCategories.Contains(c, StringComparison.OrdinalIgnoreCase)), + IsMovie = programCategories.Any(c => info.MovieCategories.Contains(c, StringComparison.OrdinalIgnoreCase)), + IsNews = programCategories.Any(c => info.NewsCategories.Contains(c, StringComparison.OrdinalIgnoreCase)), + IsSports = programCategories.Any(c => info.SportsCategories.Contains(c, StringComparison.OrdinalIgnoreCase)), + ImageUrl = string.IsNullOrEmpty(program.Icon?.Source) ? null : program.Icon.Source, + HasImage = !string.IsNullOrEmpty(program.Icon?.Source), + OfficialRating = string.IsNullOrEmpty(program.Rating?.Value) ? null : program.Rating.Value, + CommunityRating = program.StarRating, + SeriesId = program.Episode.Episode is null ? null : program.Title?.GetMD5().ToString("N", CultureInfo.InvariantCulture) + }; + + if (string.IsNullOrWhiteSpace(program.ProgramId)) + { + string uniqueString = (program.Title ?? string.Empty) + (episodeTitle ?? string.Empty); + + if (programInfo.SeasonNumber.HasValue) + { + uniqueString = "-" + programInfo.SeasonNumber.Value.ToString(CultureInfo.InvariantCulture); + } + + if (programInfo.EpisodeNumber.HasValue) + { + uniqueString = "-" + programInfo.EpisodeNumber.Value.ToString(CultureInfo.InvariantCulture); + } + + programInfo.ShowId = uniqueString.GetMD5().ToString("N", CultureInfo.InvariantCulture); + + // If we don't have valid episode info, assume it's a unique program, otherwise recordings might be skipped + if (programInfo.IsSeries + && !programInfo.IsRepeat + && (programInfo.EpisodeNumber ?? 0) == 0) + { + programInfo.ShowId += programInfo.StartDate.Ticks.ToString(CultureInfo.InvariantCulture); + } + } + else + { + programInfo.ShowId = program.ProgramId; + } + + // Construct an id from the channel and start date + programInfo.Id = string.Format(CultureInfo.InvariantCulture, "{0}_{1:O}", program.ChannelId, program.StartDate); + + if (programInfo.IsMovie) + { + programInfo.IsSeries = false; + programInfo.EpisodeNumber = null; + programInfo.EpisodeTitle = null; + } + + return programInfo; + } + + public Task Validate(ListingsProviderInfo info, bool validateLogin, bool validateListings) + { + // Assume all urls are valid. check files for existence + if (!info.Path.StartsWith("http", StringComparison.OrdinalIgnoreCase) && !File.Exists(info.Path)) + { + throw new FileNotFoundException("Could not find the XmlTv file specified:", info.Path); + } + + return Task.CompletedTask; + } + + public async Task> GetLineups(ListingsProviderInfo info, string country, string location) + { + // In theory this should never be called because there is always only one lineup + string path = await GetXml(info, CancellationToken.None).ConfigureAwait(false); + _logger.LogDebug("Opening XmlTvReader for {Path}", path); + var reader = new XmlTvReader(path, GetLanguage(info)); + IEnumerable results = reader.GetChannels(); + + // Should this method be async? + return results.Select(c => new NameIdPair() { Id = c.Id, Name = c.DisplayName }).ToList(); + } + + public async Task> GetChannels(ListingsProviderInfo info, CancellationToken cancellationToken) + { + // In theory this should never be called because there is always only one lineup + string path = await GetXml(info, cancellationToken).ConfigureAwait(false); + _logger.LogDebug("Opening XmlTvReader for {Path}", path); + var reader = new XmlTvReader(path, GetLanguage(info)); + var results = reader.GetChannels(); + + // Should this method be async? + return results.Select(c => new ChannelInfo + { + Id = c.Id, + Name = c.DisplayName, + ImageUrl = string.IsNullOrEmpty(c.Icon?.Source) ? null : c.Icon.Source, + Number = string.IsNullOrWhiteSpace(c.Number) ? c.Id : c.Number + }).ToList(); + } + } +} diff --git a/src/Jellyfin.LiveTv/LiveTvConfigurationFactory.cs b/src/Jellyfin.LiveTv/LiveTvConfigurationFactory.cs new file mode 100644 index 000000000..ddbf6345c --- /dev/null +++ b/src/Jellyfin.LiveTv/LiveTvConfigurationFactory.cs @@ -0,0 +1,25 @@ +using System.Collections.Generic; +using MediaBrowser.Common.Configuration; +using MediaBrowser.Model.LiveTv; + +namespace Jellyfin.LiveTv +{ + /// + /// implementation for . + /// + public class LiveTvConfigurationFactory : IConfigurationFactory + { + /// + public IEnumerable GetConfigurations() + { + return new ConfigurationStore[] + { + new ConfigurationStore + { + ConfigurationType = typeof(LiveTvOptions), + Key = "livetv" + } + }; + } + } +} diff --git a/src/Jellyfin.LiveTv/LiveTvDtoService.cs b/src/Jellyfin.LiveTv/LiveTvDtoService.cs new file mode 100644 index 000000000..7c7c26eb4 --- /dev/null +++ b/src/Jellyfin.LiveTv/LiveTvDtoService.cs @@ -0,0 +1,548 @@ +#nullable disable + +#pragma warning disable CS1591 + +using System; +using System.Globalization; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Data.Enums; +using MediaBrowser.Common; +using MediaBrowser.Common.Extensions; +using MediaBrowser.Controller.Drawing; +using MediaBrowser.Controller.Dto; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.LiveTv; +using MediaBrowser.Model.Dto; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.LiveTv; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.LiveTv +{ + public class LiveTvDtoService + { + private const string InternalVersionNumber = "4"; + + private const string ServiceName = "Emby"; + + private readonly ILogger _logger; + private readonly IImageProcessor _imageProcessor; + private readonly IDtoService _dtoService; + private readonly IApplicationHost _appHost; + private readonly ILibraryManager _libraryManager; + + public LiveTvDtoService( + IDtoService dtoService, + IImageProcessor imageProcessor, + ILogger logger, + IApplicationHost appHost, + ILibraryManager libraryManager) + { + _dtoService = dtoService; + _imageProcessor = imageProcessor; + _logger = logger; + _appHost = appHost; + _libraryManager = libraryManager; + } + + public TimerInfoDto GetTimerInfoDto(TimerInfo info, ILiveTvService service, LiveTvProgram program, BaseItem channel) + { + var dto = new TimerInfoDto + { + Id = GetInternalTimerId(info.Id), + Overview = info.Overview, + EndDate = info.EndDate, + Name = info.Name, + StartDate = info.StartDate, + ExternalId = info.Id, + ChannelId = GetInternalChannelId(service.Name, info.ChannelId), + Status = info.Status, + SeriesTimerId = string.IsNullOrEmpty(info.SeriesTimerId) ? null : GetInternalSeriesTimerId(info.SeriesTimerId).ToString("N", CultureInfo.InvariantCulture), + PrePaddingSeconds = info.PrePaddingSeconds, + PostPaddingSeconds = info.PostPaddingSeconds, + IsPostPaddingRequired = info.IsPostPaddingRequired, + IsPrePaddingRequired = info.IsPrePaddingRequired, + KeepUntil = info.KeepUntil, + ExternalChannelId = info.ChannelId, + ExternalSeriesTimerId = info.SeriesTimerId, + ServiceName = service.Name, + ExternalProgramId = info.ProgramId, + Priority = info.Priority, + RunTimeTicks = (info.EndDate - info.StartDate).Ticks, + ServerId = _appHost.SystemId + }; + + if (!string.IsNullOrEmpty(info.ProgramId)) + { + dto.ProgramId = GetInternalProgramId(info.ProgramId).ToString("N", CultureInfo.InvariantCulture); + } + + if (program is not null) + { + dto.ProgramInfo = _dtoService.GetBaseItemDto(program, new DtoOptions()); + + if (info.Status != RecordingStatus.Cancelled && info.Status != RecordingStatus.Error) + { + dto.ProgramInfo.TimerId = dto.Id; + dto.ProgramInfo.Status = info.Status.ToString(); + } + + dto.ProgramInfo.SeriesTimerId = dto.SeriesTimerId; + + if (!string.IsNullOrEmpty(info.SeriesTimerId)) + { + FillImages(dto.ProgramInfo, info.Name, info.SeriesId); + } + } + + if (channel is not null) + { + dto.ChannelName = channel.Name; + + if (channel.HasImage(ImageType.Primary)) + { + dto.ChannelPrimaryImageTag = GetImageTag(channel); + } + } + + return dto; + } + + public SeriesTimerInfoDto GetSeriesTimerInfoDto(SeriesTimerInfo info, ILiveTvService service, string channelName) + { + var dto = new SeriesTimerInfoDto + { + Id = GetInternalSeriesTimerId(info.Id).ToString("N", CultureInfo.InvariantCulture), + Overview = info.Overview, + EndDate = info.EndDate, + Name = info.Name, + StartDate = info.StartDate, + ExternalId = info.Id, + PrePaddingSeconds = info.PrePaddingSeconds, + PostPaddingSeconds = info.PostPaddingSeconds, + IsPostPaddingRequired = info.IsPostPaddingRequired, + IsPrePaddingRequired = info.IsPrePaddingRequired, + Days = info.Days.ToArray(), + Priority = info.Priority, + RecordAnyChannel = info.RecordAnyChannel, + RecordAnyTime = info.RecordAnyTime, + SkipEpisodesInLibrary = info.SkipEpisodesInLibrary, + KeepUpTo = info.KeepUpTo, + KeepUntil = info.KeepUntil, + RecordNewOnly = info.RecordNewOnly, + ExternalChannelId = info.ChannelId, + ExternalProgramId = info.ProgramId, + ServiceName = service.Name, + ChannelName = channelName, + ServerId = _appHost.SystemId + }; + + if (!string.IsNullOrEmpty(info.ChannelId)) + { + dto.ChannelId = GetInternalChannelId(service.Name, info.ChannelId); + } + + if (!string.IsNullOrEmpty(info.ProgramId)) + { + dto.ProgramId = GetInternalProgramId(info.ProgramId).ToString("N", CultureInfo.InvariantCulture); + } + + dto.DayPattern = info.Days is null ? null : GetDayPattern(info.Days.ToArray()); + + FillImages(dto, info.Name, info.SeriesId); + + return dto; + } + + private void FillImages(BaseItemDto dto, string seriesName, string programSeriesId) + { + var librarySeries = _libraryManager.GetItemList(new InternalItemsQuery + { + IncludeItemTypes = new[] { BaseItemKind.Series }, + Name = seriesName, + Limit = 1, + ImageTypes = new ImageType[] { ImageType.Thumb }, + DtoOptions = new DtoOptions(false) + }).FirstOrDefault(); + + if (librarySeries is not null) + { + var image = librarySeries.GetImageInfo(ImageType.Thumb, 0); + if (image is not null) + { + try + { + dto.ParentThumbImageTag = _imageProcessor.GetImageCacheTag(librarySeries, image); + dto.ParentThumbItemId = librarySeries.Id; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error"); + } + } + + image = librarySeries.GetImageInfo(ImageType.Backdrop, 0); + if (image is not null) + { + try + { + dto.ParentBackdropImageTags = new string[] + { + _imageProcessor.GetImageCacheTag(librarySeries, image) + }; + dto.ParentBackdropItemId = librarySeries.Id; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error"); + } + } + } + + var program = _libraryManager.GetItemList(new InternalItemsQuery + { + IncludeItemTypes = new[] { BaseItemKind.LiveTvProgram }, + ExternalSeriesId = programSeriesId, + Limit = 1, + ImageTypes = new ImageType[] { ImageType.Primary }, + DtoOptions = new DtoOptions(false), + Name = string.IsNullOrEmpty(programSeriesId) ? seriesName : null + }).FirstOrDefault(); + + if (program is not null) + { + var image = program.GetImageInfo(ImageType.Primary, 0); + if (image is not null) + { + try + { + dto.ParentPrimaryImageTag = _imageProcessor.GetImageCacheTag(program, image); + dto.ParentPrimaryImageItemId = program.Id.ToString("N", CultureInfo.InvariantCulture); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error"); + } + } + + if (dto.ParentBackdropImageTags is null || dto.ParentBackdropImageTags.Length == 0) + { + image = program.GetImageInfo(ImageType.Backdrop, 0); + if (image is not null) + { + try + { + dto.ParentBackdropImageTags = new string[] + { + _imageProcessor.GetImageCacheTag(program, image) + }; + + dto.ParentBackdropItemId = program.Id; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error"); + } + } + } + } + } + + private void FillImages(SeriesTimerInfoDto dto, string seriesName, string programSeriesId) + { + var librarySeries = _libraryManager.GetItemList(new InternalItemsQuery + { + IncludeItemTypes = new[] { BaseItemKind.Series }, + Name = seriesName, + Limit = 1, + ImageTypes = new ImageType[] { ImageType.Thumb }, + DtoOptions = new DtoOptions(false) + }).FirstOrDefault(); + + if (librarySeries is not null) + { + var image = librarySeries.GetImageInfo(ImageType.Thumb, 0); + if (image is not null) + { + try + { + dto.ParentThumbImageTag = _imageProcessor.GetImageCacheTag(librarySeries, image); + dto.ParentThumbItemId = librarySeries.Id.ToString("N", CultureInfo.InvariantCulture); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error"); + } + } + + image = librarySeries.GetImageInfo(ImageType.Backdrop, 0); + if (image is not null) + { + try + { + dto.ParentBackdropImageTags = new string[] + { + _imageProcessor.GetImageCacheTag(librarySeries, image) + }; + dto.ParentBackdropItemId = librarySeries.Id.ToString("N", CultureInfo.InvariantCulture); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error"); + } + } + } + + var program = _libraryManager.GetItemList(new InternalItemsQuery + { + IncludeItemTypes = new[] { BaseItemKind.Series }, + Name = seriesName, + Limit = 1, + ImageTypes = new ImageType[] { ImageType.Primary }, + DtoOptions = new DtoOptions(false) + }).FirstOrDefault(); + + if (program is null) + { + program = _libraryManager.GetItemList(new InternalItemsQuery + { + IncludeItemTypes = new[] { BaseItemKind.LiveTvProgram }, + ExternalSeriesId = programSeriesId, + Limit = 1, + ImageTypes = new ImageType[] { ImageType.Primary }, + DtoOptions = new DtoOptions(false), + Name = string.IsNullOrEmpty(programSeriesId) ? seriesName : null + }).FirstOrDefault(); + } + + if (program is not null) + { + var image = program.GetImageInfo(ImageType.Primary, 0); + if (image is not null) + { + try + { + dto.ParentPrimaryImageTag = _imageProcessor.GetImageCacheTag(program, image); + dto.ParentPrimaryImageItemId = program.Id.ToString("N", CultureInfo.InvariantCulture); + } + catch (Exception ex) + { + _logger.LogDebug(ex, "GetImageCacheTag raised an exception in LiveTvDtoService.FillImages."); + } + } + + if (dto.ParentBackdropImageTags is null || dto.ParentBackdropImageTags.Length == 0) + { + image = program.GetImageInfo(ImageType.Backdrop, 0); + if (image is not null) + { + try + { + dto.ParentBackdropImageTags = new[] + { + _imageProcessor.GetImageCacheTag(program, image) + }; + dto.ParentBackdropItemId = program.Id.ToString("N", CultureInfo.InvariantCulture); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error"); + } + } + } + } + } + + public DayPattern? GetDayPattern(DayOfWeek[] days) + { + DayPattern? pattern = null; + + if (days.Length > 0) + { + if (days.Length == 7) + { + pattern = DayPattern.Daily; + } + else if (days.Length == 2) + { + if (days.Contains(DayOfWeek.Saturday) && days.Contains(DayOfWeek.Sunday)) + { + pattern = DayPattern.Weekends; + } + } + else if (days.Length == 5) + { + if (days.Contains(DayOfWeek.Monday) && days.Contains(DayOfWeek.Tuesday) && days.Contains(DayOfWeek.Wednesday) && days.Contains(DayOfWeek.Thursday) && days.Contains(DayOfWeek.Friday)) + { + pattern = DayPattern.Weekdays; + } + } + } + + return pattern; + } + + internal string GetImageTag(BaseItem info) + { + try + { + return _imageProcessor.GetImageCacheTag(info, ImageType.Primary); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error getting image info for {Name}", info.Name); + } + + return null; + } + + public Guid GetInternalChannelId(string serviceName, string externalId) + { + var name = serviceName + externalId + InternalVersionNumber; + + return _libraryManager.GetNewItemId(name.ToLowerInvariant(), typeof(LiveTvChannel)); + } + + public string GetInternalTimerId(string externalId) + { + var name = ServiceName + externalId + InternalVersionNumber; + + return name.ToLowerInvariant().GetMD5().ToString("N", CultureInfo.InvariantCulture); + } + + public Guid GetInternalSeriesTimerId(string externalId) + { + var name = ServiceName + externalId + InternalVersionNumber; + + return name.ToLowerInvariant().GetMD5(); + } + + public Guid GetInternalProgramId(string externalId) + { + var name = ServiceName + externalId + InternalVersionNumber; + + return _libraryManager.GetNewItemId(name.ToLowerInvariant(), typeof(LiveTvProgram)); + } + + public async Task GetTimerInfo(TimerInfoDto dto, bool isNew, LiveTvManager liveTv, CancellationToken cancellationToken) + { + var info = new TimerInfo + { + Overview = dto.Overview, + EndDate = dto.EndDate, + Name = dto.Name, + StartDate = dto.StartDate, + Status = dto.Status, + PrePaddingSeconds = dto.PrePaddingSeconds, + PostPaddingSeconds = dto.PostPaddingSeconds, + IsPostPaddingRequired = dto.IsPostPaddingRequired, + IsPrePaddingRequired = dto.IsPrePaddingRequired, + KeepUntil = dto.KeepUntil, + Priority = dto.Priority, + SeriesTimerId = dto.ExternalSeriesTimerId, + ProgramId = dto.ExternalProgramId, + ChannelId = dto.ExternalChannelId, + Id = dto.ExternalId + }; + + // Convert internal server id's to external tv provider id's + if (!isNew && !string.IsNullOrEmpty(dto.Id) && string.IsNullOrEmpty(info.Id)) + { + var timer = await liveTv.GetSeriesTimer(dto.Id, cancellationToken).ConfigureAwait(false); + + info.Id = timer.ExternalId; + } + + if (!dto.ChannelId.Equals(default) && string.IsNullOrEmpty(info.ChannelId)) + { + var channel = _libraryManager.GetItemById(dto.ChannelId); + + if (channel is not null) + { + info.ChannelId = channel.ExternalId; + } + } + + if (!string.IsNullOrEmpty(dto.ProgramId) && string.IsNullOrEmpty(info.ProgramId)) + { + var program = _libraryManager.GetItemById(dto.ProgramId); + + if (program is not null) + { + info.ProgramId = program.ExternalId; + } + } + + if (!string.IsNullOrEmpty(dto.SeriesTimerId) && string.IsNullOrEmpty(info.SeriesTimerId)) + { + var timer = await liveTv.GetSeriesTimer(dto.SeriesTimerId, cancellationToken).ConfigureAwait(false); + + if (timer is not null) + { + info.SeriesTimerId = timer.ExternalId; + } + } + + return info; + } + + public async Task GetSeriesTimerInfo(SeriesTimerInfoDto dto, bool isNew, LiveTvManager liveTv, CancellationToken cancellationToken) + { + var info = new SeriesTimerInfo + { + Overview = dto.Overview, + EndDate = dto.EndDate, + Name = dto.Name, + StartDate = dto.StartDate, + PrePaddingSeconds = dto.PrePaddingSeconds, + PostPaddingSeconds = dto.PostPaddingSeconds, + IsPostPaddingRequired = dto.IsPostPaddingRequired, + IsPrePaddingRequired = dto.IsPrePaddingRequired, + Days = dto.Days.ToList(), + Priority = dto.Priority, + RecordAnyChannel = dto.RecordAnyChannel, + RecordAnyTime = dto.RecordAnyTime, + SkipEpisodesInLibrary = dto.SkipEpisodesInLibrary, + KeepUpTo = dto.KeepUpTo, + KeepUntil = dto.KeepUntil, + RecordNewOnly = dto.RecordNewOnly, + ProgramId = dto.ExternalProgramId, + ChannelId = dto.ExternalChannelId, + Id = dto.ExternalId + }; + + // Convert internal server id's to external tv provider id's + if (!isNew && !string.IsNullOrEmpty(dto.Id) && string.IsNullOrEmpty(info.Id)) + { + var timer = await liveTv.GetSeriesTimer(dto.Id, cancellationToken).ConfigureAwait(false); + + info.Id = timer.ExternalId; + } + + if (!dto.ChannelId.Equals(default) && string.IsNullOrEmpty(info.ChannelId)) + { + var channel = _libraryManager.GetItemById(dto.ChannelId); + + if (channel is not null) + { + info.ChannelId = channel.ExternalId; + } + } + + if (!string.IsNullOrEmpty(dto.ProgramId) && string.IsNullOrEmpty(info.ProgramId)) + { + var program = _libraryManager.GetItemById(dto.ProgramId); + + if (program is not null) + { + info.ProgramId = program.ExternalId; + } + } + + return info; + } + } +} diff --git a/src/Jellyfin.LiveTv/LiveTvManager.cs b/src/Jellyfin.LiveTv/LiveTvManager.cs new file mode 100644 index 000000000..4fc995653 --- /dev/null +++ b/src/Jellyfin.LiveTv/LiveTvManager.cs @@ -0,0 +1,2409 @@ +#nullable disable + +#pragma warning disable CS1591 + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Data.Entities; +using Jellyfin.Data.Enums; +using Jellyfin.Data.Events; +using MediaBrowser.Common.Configuration; +using MediaBrowser.Common.Extensions; +using MediaBrowser.Common.Progress; +using MediaBrowser.Controller.Channels; +using MediaBrowser.Controller.Configuration; +using MediaBrowser.Controller.Dto; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.LiveTv; +using MediaBrowser.Controller.Persistence; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Controller.Sorting; +using MediaBrowser.Model.Dto; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.Globalization; +using MediaBrowser.Model.IO; +using MediaBrowser.Model.LiveTv; +using MediaBrowser.Model.Querying; +using MediaBrowser.Model.Tasks; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.LiveTv +{ + /// + /// Class LiveTvManager. + /// + public class LiveTvManager : ILiveTvManager + { + private const int MaxGuideDays = 14; + private const string ExternalServiceTag = "ExternalServiceId"; + + private const string EtagKey = "ProgramEtag"; + + private readonly IServerConfigurationManager _config; + private readonly ILogger _logger; + private readonly IItemRepository _itemRepo; + private readonly IUserManager _userManager; + private readonly IDtoService _dtoService; + private readonly IUserDataManager _userDataManager; + private readonly ILibraryManager _libraryManager; + private readonly ITaskManager _taskManager; + private readonly ILocalizationManager _localization; + private readonly IFileSystem _fileSystem; + private readonly IChannelManager _channelManager; + private readonly LiveTvDtoService _tvDtoService; + + private ILiveTvService[] _services = Array.Empty(); + private ITunerHost[] _tunerHosts = Array.Empty(); + private IListingsProvider[] _listingProviders = Array.Empty(); + + public LiveTvManager( + IServerConfigurationManager config, + ILogger logger, + IItemRepository itemRepo, + IUserDataManager userDataManager, + IDtoService dtoService, + IUserManager userManager, + ILibraryManager libraryManager, + ITaskManager taskManager, + ILocalizationManager localization, + IFileSystem fileSystem, + IChannelManager channelManager, + LiveTvDtoService liveTvDtoService) + { + _config = config; + _logger = logger; + _itemRepo = itemRepo; + _userManager = userManager; + _libraryManager = libraryManager; + _taskManager = taskManager; + _localization = localization; + _fileSystem = fileSystem; + _dtoService = dtoService; + _userDataManager = userDataManager; + _channelManager = channelManager; + _tvDtoService = liveTvDtoService; + } + + public event EventHandler> SeriesTimerCancelled; + + public event EventHandler> TimerCancelled; + + public event EventHandler> TimerCreated; + + public event EventHandler> SeriesTimerCreated; + + /// + /// Gets the services. + /// + /// The services. + public IReadOnlyList Services => _services; + + public IReadOnlyList TunerHosts => _tunerHosts; + + public IReadOnlyList ListingProviders => _listingProviders; + + private LiveTvOptions GetConfiguration() + { + return _config.GetConfiguration("livetv"); + } + + public string GetEmbyTvActiveRecordingPath(string id) + { + return EmbyTV.EmbyTV.Current.GetActiveRecordingPath(id); + } + + /// + /// Adds the parts. + /// + /// The services. + /// The tuner hosts. + /// The listing providers. + public void AddParts(IEnumerable services, IEnumerable tunerHosts, IEnumerable listingProviders) + { + _services = services.ToArray(); + _tunerHosts = tunerHosts.Where(i => i.IsSupported).ToArray(); + + _listingProviders = listingProviders.ToArray(); + + foreach (var service in _services) + { + if (service is EmbyTV.EmbyTV embyTv) + { + embyTv.TimerCreated += OnEmbyTvTimerCreated; + embyTv.TimerCancelled += OnEmbyTvTimerCancelled; + } + } + } + + private void OnEmbyTvTimerCancelled(object sender, GenericEventArgs e) + { + var timerId = e.Argument; + + TimerCancelled?.Invoke(this, new GenericEventArgs(new TimerEventInfo(timerId))); + } + + private void OnEmbyTvTimerCreated(object sender, GenericEventArgs e) + { + var timer = e.Argument; + + TimerCreated?.Invoke(this, new GenericEventArgs( + new TimerEventInfo(timer.Id) + { + ProgramId = _tvDtoService.GetInternalProgramId(timer.ProgramId) + })); + } + + public List GetTunerHostTypes() + { + return _tunerHosts.OrderBy(i => i.Name).Select(i => new NameIdPair + { + Name = i.Name, + Id = i.Type + }).ToList(); + } + + public Task> DiscoverTuners(bool newDevicesOnly, CancellationToken cancellationToken) + { + return EmbyTV.EmbyTV.Current.DiscoverTuners(newDevicesOnly, cancellationToken); + } + + public QueryResult GetInternalChannels(LiveTvChannelQuery query, DtoOptions dtoOptions, CancellationToken cancellationToken) + { + var user = query.UserId.Equals(default) + ? null + : _userManager.GetUserById(query.UserId); + + var topFolder = GetInternalLiveTvFolder(cancellationToken); + + var internalQuery = new InternalItemsQuery(user) + { + IsMovie = query.IsMovie, + IsNews = query.IsNews, + IsKids = query.IsKids, + IsSports = query.IsSports, + IsSeries = query.IsSeries, + IncludeItemTypes = new[] { BaseItemKind.LiveTvChannel }, + TopParentIds = new[] { topFolder.Id }, + IsFavorite = query.IsFavorite, + IsLiked = query.IsLiked, + StartIndex = query.StartIndex, + Limit = query.Limit, + DtoOptions = dtoOptions + }; + + var orderBy = internalQuery.OrderBy.ToList(); + + orderBy.AddRange(query.SortBy.Select(i => (i, query.SortOrder ?? SortOrder.Ascending))); + + if (query.EnableFavoriteSorting) + { + orderBy.Insert(0, (ItemSortBy.IsFavoriteOrLiked, SortOrder.Descending)); + } + + if (internalQuery.OrderBy.All(i => i.OrderBy != ItemSortBy.SortName)) + { + orderBy.Add((ItemSortBy.SortName, SortOrder.Ascending)); + } + + internalQuery.OrderBy = orderBy.ToArray(); + + return _libraryManager.GetItemsResult(internalQuery); + } + + public async Task> GetChannelStream(string id, string mediaSourceId, List currentLiveStreams, CancellationToken cancellationToken) + { + if (string.Equals(id, mediaSourceId, StringComparison.OrdinalIgnoreCase)) + { + mediaSourceId = null; + } + + var channel = (LiveTvChannel)_libraryManager.GetItemById(id); + + bool isVideo = channel.ChannelType == ChannelType.TV; + var service = GetService(channel); + _logger.LogInformation("Opening channel stream from {0}, external channel Id: {1}", service.Name, channel.ExternalId); + + MediaSourceInfo info; +#pragma warning disable CA1859 // TODO: Analyzer bug? + ILiveStream liveStream; +#pragma warning restore CA1859 + if (service is ISupportsDirectStreamProvider supportsManagedStream) + { + liveStream = await supportsManagedStream.GetChannelStreamWithDirectStreamProvider(channel.ExternalId, mediaSourceId, currentLiveStreams, cancellationToken).ConfigureAwait(false); + info = liveStream.MediaSource; + } + else + { + info = await service.GetChannelStream(channel.ExternalId, mediaSourceId, cancellationToken).ConfigureAwait(false); + var openedId = info.Id; + Func closeFn = () => service.CloseLiveStream(openedId, CancellationToken.None); + + liveStream = new ExclusiveLiveStream(info, closeFn); + + var startTime = DateTime.UtcNow; + await liveStream.Open(cancellationToken).ConfigureAwait(false); + var endTime = DateTime.UtcNow; + _logger.LogInformation("Live stream opened after {0}ms", (endTime - startTime).TotalMilliseconds); + } + + info.RequiresClosing = true; + + var idPrefix = service.GetType().FullName.GetMD5().ToString("N", CultureInfo.InvariantCulture) + "_"; + + info.LiveStreamId = idPrefix + info.Id; + + Normalize(info, service, isVideo); + + return new Tuple(info, liveStream); + } + + public async Task> GetChannelMediaSources(BaseItem item, CancellationToken cancellationToken) + { + var baseItem = (LiveTvChannel)item; + var service = GetService(baseItem); + + var sources = await service.GetChannelStreamMediaSources(baseItem.ExternalId, cancellationToken).ConfigureAwait(false); + + if (sources.Count == 0) + { + throw new NotImplementedException(); + } + + foreach (var source in sources) + { + Normalize(source, service, baseItem.ChannelType == ChannelType.TV); + } + + return sources; + } + + private ILiveTvService GetService(LiveTvChannel item) + { + var name = item.ServiceName; + return GetService(name); + } + + private ILiveTvService GetService(LiveTvProgram item) + { + var channel = _libraryManager.GetItemById(item.ChannelId) as LiveTvChannel; + + return GetService(channel); + } + + private ILiveTvService GetService(string name) + => Array.Find(_services, x => string.Equals(x.Name, name, StringComparison.OrdinalIgnoreCase)) + ?? throw new KeyNotFoundException( + string.Format( + CultureInfo.InvariantCulture, + "No service with the name '{0}' can be found.", + name)); + + private static void Normalize(MediaSourceInfo mediaSource, ILiveTvService service, bool isVideo) + { + // Not all of the plugins are setting this + mediaSource.IsInfiniteStream = true; + + if (mediaSource.MediaStreams.Count == 0) + { + if (isVideo) + { + mediaSource.MediaStreams = new MediaStream[] + { + new MediaStream + { + Type = MediaStreamType.Video, + // Set the index to -1 because we don't know the exact index of the video stream within the container + Index = -1, + + // Set to true if unknown to enable deinterlacing + IsInterlaced = true + }, + new MediaStream + { + Type = MediaStreamType.Audio, + // Set the index to -1 because we don't know the exact index of the audio stream within the container + Index = -1 + } + }; + } + else + { + mediaSource.MediaStreams = new MediaStream[] + { + new MediaStream + { + Type = MediaStreamType.Audio, + // Set the index to -1 because we don't know the exact index of the audio stream within the container + Index = -1 + } + }; + } + } + + // Clean some bad data coming from providers + foreach (var stream in mediaSource.MediaStreams) + { + if (stream.BitRate.HasValue && stream.BitRate <= 0) + { + stream.BitRate = null; + } + + if (stream.Channels.HasValue && stream.Channels <= 0) + { + stream.Channels = null; + } + + if (stream.AverageFrameRate.HasValue && stream.AverageFrameRate <= 0) + { + stream.AverageFrameRate = null; + } + + if (stream.RealFrameRate.HasValue && stream.RealFrameRate <= 0) + { + stream.RealFrameRate = null; + } + + if (stream.Width.HasValue && stream.Width <= 0) + { + stream.Width = null; + } + + if (stream.Height.HasValue && stream.Height <= 0) + { + stream.Height = null; + } + + if (stream.SampleRate.HasValue && stream.SampleRate <= 0) + { + stream.SampleRate = null; + } + + if (stream.Level.HasValue && stream.Level <= 0) + { + stream.Level = null; + } + } + + var indexes = mediaSource.MediaStreams.Select(i => i.Index).Distinct().ToList(); + + // If there are duplicate stream indexes, set them all to unknown + if (indexes.Count != mediaSource.MediaStreams.Count) + { + foreach (var stream in mediaSource.MediaStreams) + { + stream.Index = -1; + } + } + + // Set the total bitrate if not already supplied + mediaSource.InferTotalBitrate(); + + if (service is not EmbyTV.EmbyTV) + { + // We can't trust that we'll be able to direct stream it through emby server, no matter what the provider says + // mediaSource.SupportsDirectPlay = false; + // mediaSource.SupportsDirectStream = false; + mediaSource.SupportsTranscoding = true; + foreach (var stream in mediaSource.MediaStreams) + { + if (stream.Type == MediaStreamType.Video && string.IsNullOrWhiteSpace(stream.NalLengthSize)) + { + stream.NalLengthSize = "0"; + } + + if (stream.Type == MediaStreamType.Video) + { + stream.IsInterlaced = true; + } + } + } + } + + private async Task GetChannelAsync(ChannelInfo channelInfo, string serviceName, BaseItem parentFolder, CancellationToken cancellationToken) + { + var parentFolderId = parentFolder.Id; + var isNew = false; + var forceUpdate = false; + + var id = _tvDtoService.GetInternalChannelId(serviceName, channelInfo.Id); + + var item = _libraryManager.GetItemById(id) as LiveTvChannel; + + if (item is null) + { + item = new LiveTvChannel + { + Name = channelInfo.Name, + Id = id, + DateCreated = DateTime.UtcNow + }; + + isNew = true; + } + + if (channelInfo.Tags is not null) + { + if (!channelInfo.Tags.SequenceEqual(item.Tags, StringComparer.OrdinalIgnoreCase)) + { + isNew = true; + } + + item.Tags = channelInfo.Tags; + } + + if (!item.ParentId.Equals(parentFolderId)) + { + isNew = true; + } + + item.ParentId = parentFolderId; + + item.ChannelType = channelInfo.ChannelType; + item.ServiceName = serviceName; + + if (!string.Equals(item.GetProviderId(ExternalServiceTag), serviceName, StringComparison.OrdinalIgnoreCase)) + { + forceUpdate = true; + } + + item.SetProviderId(ExternalServiceTag, serviceName); + + if (!string.Equals(channelInfo.Id, item.ExternalId, StringComparison.Ordinal)) + { + forceUpdate = true; + } + + item.ExternalId = channelInfo.Id; + + if (!string.Equals(channelInfo.Number, item.Number, StringComparison.Ordinal)) + { + forceUpdate = true; + } + + item.Number = channelInfo.Number; + + if (!string.Equals(channelInfo.Name, item.Name, StringComparison.Ordinal)) + { + forceUpdate = true; + } + + item.Name = channelInfo.Name; + + if (!item.HasImage(ImageType.Primary)) + { + if (!string.IsNullOrWhiteSpace(channelInfo.ImagePath)) + { + item.SetImagePath(ImageType.Primary, channelInfo.ImagePath); + forceUpdate = true; + } + else if (!string.IsNullOrWhiteSpace(channelInfo.ImageUrl)) + { + item.SetImagePath(ImageType.Primary, channelInfo.ImageUrl); + forceUpdate = true; + } + } + + if (isNew) + { + _libraryManager.CreateItem(item, parentFolder); + } + else if (forceUpdate) + { + await _libraryManager.UpdateItemAsync(item, parentFolder, ItemUpdateType.MetadataImport, cancellationToken).ConfigureAwait(false); + } + + return item; + } + + private (LiveTvProgram Item, bool IsNew, bool IsUpdated) GetProgram(ProgramInfo info, Dictionary allExistingPrograms, LiveTvChannel channel) + { + var id = _tvDtoService.GetInternalProgramId(info.Id); + + var isNew = false; + var forceUpdate = false; + + if (!allExistingPrograms.TryGetValue(id, out LiveTvProgram item)) + { + isNew = true; + item = new LiveTvProgram + { + Name = info.Name, + Id = id, + DateCreated = DateTime.UtcNow, + DateModified = DateTime.UtcNow + }; + + if (!string.IsNullOrEmpty(info.Etag)) + { + item.SetProviderId(EtagKey, info.Etag); + } + } + + if (!string.Equals(info.ShowId, item.ShowId, StringComparison.OrdinalIgnoreCase)) + { + item.ShowId = info.ShowId; + forceUpdate = true; + } + + var seriesId = info.SeriesId; + + if (!item.ParentId.Equals(channel.Id)) + { + forceUpdate = true; + } + + item.ParentId = channel.Id; + + item.Audio = info.Audio; + item.ChannelId = channel.Id; + item.CommunityRating ??= info.CommunityRating; + if ((item.CommunityRating ?? 0).Equals(0)) + { + item.CommunityRating = null; + } + + item.EpisodeTitle = info.EpisodeTitle; + item.ExternalId = info.Id; + + if (!string.IsNullOrWhiteSpace(seriesId) && !string.Equals(item.ExternalSeriesId, seriesId, StringComparison.Ordinal)) + { + forceUpdate = true; + } + + item.ExternalSeriesId = seriesId; + + var isSeries = info.IsSeries || !string.IsNullOrEmpty(info.EpisodeTitle); + + if (isSeries || !string.IsNullOrEmpty(info.EpisodeTitle)) + { + item.SeriesName = info.Name; + } + + var tags = new List(); + if (info.IsLive) + { + tags.Add("Live"); + } + + if (info.IsPremiere) + { + tags.Add("Premiere"); + } + + if (info.IsNews) + { + tags.Add("News"); + } + + if (info.IsSports) + { + tags.Add("Sports"); + } + + if (info.IsKids) + { + tags.Add("Kids"); + } + + if (info.IsRepeat) + { + tags.Add("Repeat"); + } + + if (info.IsMovie) + { + tags.Add("Movie"); + } + + if (isSeries) + { + tags.Add("Series"); + } + + item.Tags = tags.ToArray(); + + item.Genres = info.Genres.ToArray(); + + if (info.IsHD ?? false) + { + item.Width = 1280; + item.Height = 720; + } + + item.IsMovie = info.IsMovie; + item.IsRepeat = info.IsRepeat; + + if (item.IsSeries != isSeries) + { + forceUpdate = true; + } + + item.IsSeries = isSeries; + + item.Name = info.Name; + item.OfficialRating ??= info.OfficialRating; + item.Overview ??= info.Overview; + item.RunTimeTicks = (info.EndDate - info.StartDate).Ticks; + item.ProviderIds = info.ProviderIds; + + foreach (var providerId in info.SeriesProviderIds) + { + info.ProviderIds["Series" + providerId.Key] = providerId.Value; + } + + if (item.StartDate != info.StartDate) + { + forceUpdate = true; + } + + item.StartDate = info.StartDate; + + if (item.EndDate != info.EndDate) + { + forceUpdate = true; + } + + item.EndDate = info.EndDate; + + item.ProductionYear = info.ProductionYear; + + if (!isSeries || info.IsRepeat) + { + item.PremiereDate = info.OriginalAirDate; + } + + item.IndexNumber = info.EpisodeNumber; + item.ParentIndexNumber = info.SeasonNumber; + + if (!item.HasImage(ImageType.Primary)) + { + if (!string.IsNullOrWhiteSpace(info.ImagePath)) + { + item.SetImage( + new ItemImageInfo + { + Path = info.ImagePath, + Type = ImageType.Primary + }, + 0); + } + else if (!string.IsNullOrWhiteSpace(info.ImageUrl)) + { + item.SetImage( + new ItemImageInfo + { + Path = info.ImageUrl, + Type = ImageType.Primary + }, + 0); + } + } + + if (!item.HasImage(ImageType.Thumb)) + { + if (!string.IsNullOrWhiteSpace(info.ThumbImageUrl)) + { + item.SetImage( + new ItemImageInfo + { + Path = info.ThumbImageUrl, + Type = ImageType.Thumb + }, + 0); + } + } + + if (!item.HasImage(ImageType.Logo)) + { + if (!string.IsNullOrWhiteSpace(info.LogoImageUrl)) + { + item.SetImage( + new ItemImageInfo + { + Path = info.LogoImageUrl, + Type = ImageType.Logo + }, + 0); + } + } + + if (!item.HasImage(ImageType.Backdrop)) + { + if (!string.IsNullOrWhiteSpace(info.BackdropImageUrl)) + { + item.SetImage( + new ItemImageInfo + { + Path = info.BackdropImageUrl, + Type = ImageType.Backdrop + }, + 0); + } + } + + var isUpdated = false; + if (isNew) + { + } + else if (forceUpdate || string.IsNullOrWhiteSpace(info.Etag)) + { + isUpdated = true; + } + else + { + var etag = info.Etag; + + if (!string.Equals(etag, item.GetProviderId(EtagKey), StringComparison.OrdinalIgnoreCase)) + { + item.SetProviderId(EtagKey, etag); + isUpdated = true; + } + } + + if (isNew || isUpdated) + { + item.OnMetadataChanged(); + } + + return (item, isNew, isUpdated); + } + + public async Task GetProgram(string id, CancellationToken cancellationToken, User user = null) + { + var program = _libraryManager.GetItemById(id); + + var dto = _dtoService.GetBaseItemDto(program, new DtoOptions(), user); + + var list = new List<(BaseItemDto ItemDto, string ExternalId, string ExternalSeriesId)> + { + (dto, program.ExternalId, program.ExternalSeriesId) + }; + + await AddRecordingInfo(list, cancellationToken).ConfigureAwait(false); + + return dto; + } + + public async Task> GetPrograms(InternalItemsQuery query, DtoOptions options, CancellationToken cancellationToken) + { + var user = query.User; + + var topFolder = GetInternalLiveTvFolder(cancellationToken); + + if (query.OrderBy.Count == 0) + { + // Unless something else was specified, order by start date to take advantage of a specialized index + query.OrderBy = new[] + { + (ItemSortBy.StartDate, SortOrder.Ascending) + }; + } + + RemoveFields(options); + + var internalQuery = new InternalItemsQuery(user) + { + IncludeItemTypes = new[] { BaseItemKind.LiveTvProgram }, + MinEndDate = query.MinEndDate, + MinStartDate = query.MinStartDate, + MaxEndDate = query.MaxEndDate, + MaxStartDate = query.MaxStartDate, + ChannelIds = query.ChannelIds, + IsMovie = query.IsMovie, + IsSeries = query.IsSeries, + IsSports = query.IsSports, + IsKids = query.IsKids, + IsNews = query.IsNews, + Genres = query.Genres, + GenreIds = query.GenreIds, + StartIndex = query.StartIndex, + Limit = query.Limit, + OrderBy = query.OrderBy, + EnableTotalRecordCount = query.EnableTotalRecordCount, + TopParentIds = new[] { topFolder.Id }, + Name = query.Name, + DtoOptions = options, + HasAired = query.HasAired, + IsAiring = query.IsAiring + }; + + if (!string.IsNullOrWhiteSpace(query.SeriesTimerId)) + { + var seriesTimers = await GetSeriesTimersInternal(new SeriesTimerQuery(), cancellationToken).ConfigureAwait(false); + var seriesTimer = seriesTimers.Items.FirstOrDefault(i => string.Equals(_tvDtoService.GetInternalSeriesTimerId(i.Id).ToString("N", CultureInfo.InvariantCulture), query.SeriesTimerId, StringComparison.OrdinalIgnoreCase)); + if (seriesTimer is not null) + { + internalQuery.ExternalSeriesId = seriesTimer.SeriesId; + + if (string.IsNullOrWhiteSpace(seriesTimer.SeriesId)) + { + // Better to return nothing than every program in the database + return new QueryResult(); + } + } + else + { + // Better to return nothing than every program in the database + return new QueryResult(); + } + } + + var queryResult = _libraryManager.QueryItems(internalQuery); + + var returnArray = _dtoService.GetBaseItemDtos(queryResult.Items, options, user); + + return new QueryResult( + query.StartIndex, + queryResult.TotalRecordCount, + returnArray); + } + + public QueryResult GetRecommendedProgramsInternal(InternalItemsQuery query, DtoOptions options, CancellationToken cancellationToken) + { + var user = query.User; + + var topFolder = GetInternalLiveTvFolder(cancellationToken); + + var internalQuery = new InternalItemsQuery(user) + { + IncludeItemTypes = new[] { BaseItemKind.LiveTvProgram }, + IsAiring = query.IsAiring, + HasAired = query.HasAired, + IsNews = query.IsNews, + IsMovie = query.IsMovie, + IsSeries = query.IsSeries, + IsSports = query.IsSports, + IsKids = query.IsKids, + EnableTotalRecordCount = query.EnableTotalRecordCount, + OrderBy = new[] { (ItemSortBy.StartDate, SortOrder.Ascending) }, + TopParentIds = new[] { topFolder.Id }, + DtoOptions = options, + GenreIds = query.GenreIds + }; + + if (query.Limit.HasValue) + { + internalQuery.Limit = Math.Max(query.Limit.Value * 4, 200); + } + + var programList = _libraryManager.QueryItems(internalQuery).Items; + var totalCount = programList.Count; + + var orderedPrograms = programList.Cast().OrderBy(i => i.StartDate.Date); + + if (query.IsAiring ?? false) + { + orderedPrograms = orderedPrograms + .ThenByDescending(i => GetRecommendationScore(i, user, true)); + } + + IEnumerable programs = orderedPrograms; + + if (query.Limit.HasValue) + { + programs = programs.Take(query.Limit.Value); + } + + return new QueryResult( + query.StartIndex, + totalCount, + programs.ToArray()); + } + + public Task> GetRecommendedProgramsAsync(InternalItemsQuery query, DtoOptions options, CancellationToken cancellationToken) + { + if (!(query.IsAiring ?? false)) + { + return GetPrograms(query, options, cancellationToken); + } + + RemoveFields(options); + + var internalResult = GetRecommendedProgramsInternal(query, options, cancellationToken); + + return Task.FromResult(new QueryResult( + query.StartIndex, + internalResult.TotalRecordCount, + _dtoService.GetBaseItemDtos(internalResult.Items, options, query.User))); + } + + private int GetRecommendationScore(LiveTvProgram program, User user, bool factorChannelWatchCount) + { + var score = 0; + + if (program.IsLive) + { + score++; + } + + if (program.IsSeries && !program.IsRepeat) + { + score++; + } + + var channel = _libraryManager.GetItemById(program.ChannelId); + + if (channel is null) + { + return score; + } + + var channelUserdata = _userDataManager.GetUserData(user, channel); + + if (channelUserdata.Likes.HasValue) + { + score += channelUserdata.Likes.Value ? 2 : -2; + } + + if (channelUserdata.IsFavorite) + { + score += 3; + } + + if (factorChannelWatchCount) + { + score += channelUserdata.PlayCount; + } + + return score; + } + + private async Task AddRecordingInfo(IEnumerable<(BaseItemDto ItemDto, string ExternalId, string ExternalSeriesId)> programs, CancellationToken cancellationToken) + { + IReadOnlyList timerList = null; + IReadOnlyList seriesTimerList = null; + + foreach (var programTuple in programs) + { + var program = programTuple.ItemDto; + var externalProgramId = programTuple.ExternalId; + string externalSeriesId = programTuple.ExternalSeriesId; + + timerList ??= (await GetTimersInternal(new TimerQuery(), cancellationToken).ConfigureAwait(false)).Items; + + var timer = timerList.FirstOrDefault(i => string.Equals(i.ProgramId, externalProgramId, StringComparison.OrdinalIgnoreCase)); + var foundSeriesTimer = false; + + if (timer is not null) + { + if (timer.Status != RecordingStatus.Cancelled && timer.Status != RecordingStatus.Error) + { + program.TimerId = _tvDtoService.GetInternalTimerId(timer.Id); + + program.Status = timer.Status.ToString(); + } + + if (!string.IsNullOrEmpty(timer.SeriesTimerId)) + { + program.SeriesTimerId = _tvDtoService.GetInternalSeriesTimerId(timer.SeriesTimerId) + .ToString("N", CultureInfo.InvariantCulture); + + foundSeriesTimer = true; + } + } + + if (foundSeriesTimer || string.IsNullOrWhiteSpace(externalSeriesId)) + { + continue; + } + + seriesTimerList ??= (await GetSeriesTimersInternal(new SeriesTimerQuery(), cancellationToken).ConfigureAwait(false)).Items; + + var seriesTimer = seriesTimerList.FirstOrDefault(i => string.Equals(i.SeriesId, externalSeriesId, StringComparison.OrdinalIgnoreCase)); + + if (seriesTimer is not null) + { + program.SeriesTimerId = _tvDtoService.GetInternalSeriesTimerId(seriesTimer.Id) + .ToString("N", CultureInfo.InvariantCulture); + } + } + } + + internal Task RefreshChannels(IProgress progress, CancellationToken cancellationToken) + { + return RefreshChannelsInternal(progress, cancellationToken); + } + + private async Task RefreshChannelsInternal(IProgress progress, CancellationToken cancellationToken) + { + await EmbyTV.EmbyTV.Current.CreateRecordingFolders().ConfigureAwait(false); + + await EmbyTV.EmbyTV.Current.ScanForTunerDeviceChanges(cancellationToken).ConfigureAwait(false); + + var numComplete = 0; + double progressPerService = _services.Length == 0 + ? 0 + : 1.0 / _services.Length; + + var newChannelIdList = new List(); + var newProgramIdList = new List(); + + var cleanDatabase = true; + + foreach (var service in _services) + { + cancellationToken.ThrowIfCancellationRequested(); + + _logger.LogDebug("Refreshing guide from {Name}", service.Name); + + try + { + var innerProgress = new ActionableProgress(); + innerProgress.RegisterAction(p => progress.Report(p * progressPerService)); + + var idList = await RefreshChannelsInternal(service, innerProgress, cancellationToken).ConfigureAwait(false); + + newChannelIdList.AddRange(idList.Item1); + newProgramIdList.AddRange(idList.Item2); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + cleanDatabase = false; + _logger.LogError(ex, "Error refreshing channels for service"); + } + + numComplete++; + double percent = numComplete; + percent /= _services.Length; + + progress.Report(100 * percent); + } + + if (cleanDatabase) + { + CleanDatabaseInternal(newChannelIdList.ToArray(), new[] { BaseItemKind.LiveTvChannel }, progress, cancellationToken); + CleanDatabaseInternal(newProgramIdList.ToArray(), new[] { BaseItemKind.LiveTvProgram }, progress, cancellationToken); + } + + var coreService = _services.OfType().FirstOrDefault(); + + if (coreService is not null) + { + await coreService.RefreshSeriesTimers(cancellationToken).ConfigureAwait(false); + await coreService.RefreshTimers(cancellationToken).ConfigureAwait(false); + } + + // Load these now which will prefetch metadata + var dtoOptions = new DtoOptions(); + var fields = dtoOptions.Fields.ToList(); + dtoOptions.Fields = fields.ToArray(); + + progress.Report(100); + } + + private async Task, List>> RefreshChannelsInternal(ILiveTvService service, ActionableProgress progress, CancellationToken cancellationToken) + { + progress.Report(10); + + var allChannelsList = (await service.GetChannelsAsync(cancellationToken).ConfigureAwait(false)) + .Select(i => new Tuple(service.Name, i)) + .ToList(); + + var list = new List(); + + var numComplete = 0; + var parentFolder = GetInternalLiveTvFolder(cancellationToken); + + foreach (var channelInfo in allChannelsList) + { + cancellationToken.ThrowIfCancellationRequested(); + + try + { + var item = await GetChannelAsync(channelInfo.Item2, channelInfo.Item1, parentFolder, cancellationToken).ConfigureAwait(false); + + list.Add(item); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error getting channel information for {Name}", channelInfo.Item2.Name); + } + + numComplete++; + double percent = numComplete; + percent /= allChannelsList.Count; + + progress.Report((5 * percent) + 10); + } + + progress.Report(15); + + numComplete = 0; + var programs = new List(); + var channels = new List(); + + var guideDays = GetGuideDays(); + + _logger.LogInformation("Refreshing guide with {0} days of guide data", guideDays); + + cancellationToken.ThrowIfCancellationRequested(); + + foreach (var currentChannel in list) + { + channels.Add(currentChannel.Id); + cancellationToken.ThrowIfCancellationRequested(); + + try + { + var start = DateTime.UtcNow.AddHours(-1); + var end = start.AddDays(guideDays); + + var isMovie = false; + var isSports = false; + var isNews = false; + var isKids = false; + var iSSeries = false; + + var channelPrograms = (await service.GetProgramsAsync(currentChannel.ExternalId, start, end, cancellationToken).ConfigureAwait(false)).ToList(); + + var existingPrograms = _libraryManager.GetItemList(new InternalItemsQuery + { + IncludeItemTypes = new[] { BaseItemKind.LiveTvProgram }, + ChannelIds = new Guid[] { currentChannel.Id }, + DtoOptions = new DtoOptions(true) + }).Cast().ToDictionary(i => i.Id); + + var newPrograms = new List(); + var updatedPrograms = new List(); + + foreach (var program in channelPrograms) + { + var programTuple = GetProgram(program, existingPrograms, currentChannel); + var programItem = programTuple.Item; + + if (programTuple.IsNew) + { + newPrograms.Add(programItem); + } + else if (programTuple.IsUpdated) + { + updatedPrograms.Add(programItem); + } + + programs.Add(programItem.Id); + + isMovie |= program.IsMovie; + iSSeries |= program.IsSeries; + isSports |= program.IsSports; + isNews |= program.IsNews; + isKids |= program.IsKids; + } + + _logger.LogDebug("Channel {0} has {1} new programs and {2} updated programs", currentChannel.Name, newPrograms.Count, updatedPrograms.Count); + + if (newPrograms.Count > 0) + { + _libraryManager.CreateItems(newPrograms, null, cancellationToken); + } + + if (updatedPrograms.Count > 0) + { + await _libraryManager.UpdateItemsAsync( + updatedPrograms, + currentChannel, + ItemUpdateType.MetadataImport, + cancellationToken).ConfigureAwait(false); + } + + currentChannel.IsMovie = isMovie; + currentChannel.IsNews = isNews; + currentChannel.IsSports = isSports; + currentChannel.IsSeries = iSSeries; + + if (isKids) + { + currentChannel.AddTag("Kids"); + } + + await currentChannel.UpdateToRepositoryAsync(ItemUpdateType.MetadataImport, cancellationToken).ConfigureAwait(false); + await currentChannel.RefreshMetadata( + new MetadataRefreshOptions(new DirectoryService(_fileSystem)) + { + ForceSave = true + }, + cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error getting programs for channel {Name}", currentChannel.Name); + } + + numComplete++; + double percent = numComplete / (double)allChannelsList.Count; + + progress.Report((85 * percent) + 15); + } + + progress.Report(100); + return new Tuple, List>(channels, programs); + } + + private void CleanDatabaseInternal(Guid[] currentIdList, BaseItemKind[] validTypes, IProgress progress, CancellationToken cancellationToken) + { + var list = _itemRepo.GetItemIdsList(new InternalItemsQuery + { + IncludeItemTypes = validTypes, + DtoOptions = new DtoOptions(false) + }); + + var numComplete = 0; + + foreach (var itemId in list) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (itemId.Equals(default)) + { + // Somehow some invalid data got into the db. It probably predates the boundary checking + continue; + } + + if (!currentIdList.Contains(itemId)) + { + var item = _libraryManager.GetItemById(itemId); + + if (item is not null) + { + _libraryManager.DeleteItem( + item, + new DeleteOptions + { + DeleteFileLocation = false, + DeleteFromExternalProvider = false + }, + false); + } + } + + numComplete++; + double percent = numComplete / (double)list.Count; + + progress.Report(100 * percent); + } + } + + private double GetGuideDays() + { + var config = GetConfiguration(); + + if (config.GuideDays.HasValue) + { + return Math.Max(1, Math.Min(config.GuideDays.Value, MaxGuideDays)); + } + + return 7; + } + + private async Task> GetEmbyRecordingsAsync(RecordingQuery query, DtoOptions dtoOptions, User user) + { + if (user is null) + { + return new QueryResult(); + } + + var folders = await GetRecordingFoldersAsync(user, true).ConfigureAwait(false); + var folderIds = Array.ConvertAll(folders, x => x.Id); + + var excludeItemTypes = new List(); + + if (folderIds.Length == 0) + { + return new QueryResult(); + } + + var includeItemTypes = new List(); + var genres = new List(); + + if (query.IsMovie.HasValue) + { + if (query.IsMovie.Value) + { + includeItemTypes.Add(BaseItemKind.Movie); + } + else + { + excludeItemTypes.Add(BaseItemKind.Movie); + } + } + + if (query.IsSeries.HasValue) + { + if (query.IsSeries.Value) + { + includeItemTypes.Add(BaseItemKind.Episode); + } + else + { + excludeItemTypes.Add(BaseItemKind.Episode); + } + } + + if (query.IsSports ?? false) + { + genres.Add("Sports"); + } + + if (query.IsKids ?? false) + { + genres.Add("Kids"); + genres.Add("Children"); + genres.Add("Family"); + } + + var limit = query.Limit; + + if (query.IsInProgress ?? false) + { + // limit = (query.Limit ?? 10) * 2; + limit = null; + + // var allActivePaths = EmbyTV.EmbyTV.Current.GetAllActiveRecordings().Select(i => i.Path).ToArray(); + // var items = allActivePaths.Select(i => _libraryManager.FindByPath(i, false)).Where(i => i is not null).ToArray(); + + // return new QueryResult + // { + // Items = items, + // TotalRecordCount = items.Length + // }; + + dtoOptions.Fields = dtoOptions.Fields.Concat(new[] { ItemFields.Tags }).Distinct().ToArray(); + } + + var result = _libraryManager.GetItemsResult(new InternalItemsQuery(user) + { + MediaTypes = new[] { MediaType.Video }, + Recursive = true, + AncestorIds = folderIds, + IsFolder = false, + IsVirtualItem = false, + Limit = limit, + StartIndex = query.StartIndex, + OrderBy = new[] { (ItemSortBy.DateCreated, SortOrder.Descending) }, + EnableTotalRecordCount = query.EnableTotalRecordCount, + IncludeItemTypes = includeItemTypes.ToArray(), + ExcludeItemTypes = excludeItemTypes.ToArray(), + Genres = genres.ToArray(), + DtoOptions = dtoOptions + }); + + if (query.IsInProgress ?? false) + { + // TODO: Fix The co-variant conversion between Video[] and BaseItem[], this can generate runtime issues. + result.Items = result + .Items + .OfType