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 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