aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/Jellyfin.LiveTv/EmbyTV/DirectRecorder.cs118
-rw-r--r--src/Jellyfin.LiveTv/EmbyTV/EmbyTV.cs2621
-rw-r--r--src/Jellyfin.LiveTv/EmbyTV/EncodedRecorder.cs362
-rw-r--r--src/Jellyfin.LiveTv/EmbyTV/EntryPoint.cs21
-rw-r--r--src/Jellyfin.LiveTv/EmbyTV/EpgChannelData.cs54
-rw-r--r--src/Jellyfin.LiveTv/EmbyTV/IRecorder.cs27
-rw-r--r--src/Jellyfin.LiveTv/EmbyTV/ItemDataProvider.cs163
-rw-r--r--src/Jellyfin.LiveTv/EmbyTV/NfoConfigurationExtensions.cs19
-rw-r--r--src/Jellyfin.LiveTv/EmbyTV/RecordingHelper.cs83
-rw-r--r--src/Jellyfin.LiveTv/EmbyTV/SeriesTimerManager.cs24
-rw-r--r--src/Jellyfin.LiveTv/EmbyTV/TimerManager.cs181
-rw-r--r--src/Jellyfin.LiveTv/ExclusiveLiveStream.cs61
-rw-r--r--src/Jellyfin.LiveTv/Jellyfin.LiveTv.csproj22
-rw-r--r--src/Jellyfin.LiveTv/Listings/SchedulesDirect.cs810
-rw-r--r--src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/BroadcasterDto.cs34
-rw-r--r--src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/CaptionDto.cs22
-rw-r--r--src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/CastDto.cs46
-rw-r--r--src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/ChannelDto.cs30
-rw-r--r--src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/ContentRatingDto.cs22
-rw-r--r--src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/CrewDto.cs40
-rw-r--r--src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/DayDto.cs30
-rw-r--r--src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/Description1000Dto.cs22
-rw-r--r--src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/Description100Dto.cs22
-rw-r--r--src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/DescriptionsProgramDto.cs24
-rw-r--r--src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/EventDetailsDto.cs16
-rw-r--r--src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/GracenoteDto.cs22
-rw-r--r--src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/HeadendsDto.cs36
-rw-r--r--src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/ImageDataDto.cs70
-rw-r--r--src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/LineupDto.cs46
-rw-r--r--src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/LineupsDto.cs36
-rw-r--r--src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/LogoDto.cs34
-rw-r--r--src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/MapDto.cs58
-rw-r--r--src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/MetadataDto.cs28
-rw-r--r--src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/MetadataProgramsDto.cs16
-rw-r--r--src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/MetadataScheduleDto.cs41
-rw-r--r--src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/MovieDto.cs30
-rw-r--r--src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/MultipartDto.cs22
-rw-r--r--src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/ProgramDetailsDto.cs156
-rw-r--r--src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/ProgramDto.cs90
-rw-r--r--src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/QualityRatingDto.cs40
-rw-r--r--src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/RatingDto.cs22
-rw-r--r--src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/RecommendationDto.cs22
-rw-r--r--src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/RequestScheduleForChannelDto.cs24
-rw-r--r--src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/ShowImagesDto.cs24
-rw-r--r--src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/StationDto.cs66
-rw-r--r--src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/TitleDto.cs16
-rw-r--r--src/Jellyfin.LiveTv/Listings/SchedulesDirectDtos/TokenDto.cs47
-rw-r--r--src/Jellyfin.LiveTv/Listings/XmlTvListingsProvider.cs267
-rw-r--r--src/Jellyfin.LiveTv/LiveTvConfigurationFactory.cs25
-rw-r--r--src/Jellyfin.LiveTv/LiveTvDtoService.cs548
-rw-r--r--src/Jellyfin.LiveTv/LiveTvManager.cs2409
-rw-r--r--src/Jellyfin.LiveTv/LiveTvMediaSourceProvider.cs128
-rw-r--r--src/Jellyfin.LiveTv/RefreshGuideScheduledTask.cs75
-rw-r--r--src/Jellyfin.LiveTv/TunerHosts/BaseTunerHost.cs237
-rw-r--r--src/Jellyfin.LiveTv/TunerHosts/HdHomerun/Channels.cs23
-rw-r--r--src/Jellyfin.LiveTv/TunerHosts/HdHomerun/DiscoverResponse.cs42
-rw-r--r--src/Jellyfin.LiveTv/TunerHosts/HdHomerun/HdHomerunChannelCommands.cs35
-rw-r--r--src/Jellyfin.LiveTv/TunerHosts/HdHomerun/HdHomerunHost.cs718
-rw-r--r--src/Jellyfin.LiveTv/TunerHosts/HdHomerun/HdHomerunManager.cs351
-rw-r--r--src/Jellyfin.LiveTv/TunerHosts/HdHomerun/HdHomerunUdpStream.cs219
-rw-r--r--src/Jellyfin.LiveTv/TunerHosts/HdHomerun/IHdHomerunChannelCommands.cs11
-rw-r--r--src/Jellyfin.LiveTv/TunerHosts/HdHomerun/LegacyHdHomerunChannelCommands.cs40
-rw-r--r--src/Jellyfin.LiveTv/TunerHosts/LiveStream.cs176
-rw-r--r--src/Jellyfin.LiveTv/TunerHosts/M3UTunerHost.cs220
-rw-r--r--src/Jellyfin.LiveTv/TunerHosts/M3uParser.cs326
-rw-r--r--src/Jellyfin.LiveTv/TunerHosts/SharedHttpStream.cs135
66 files changed, 11805 insertions, 0 deletions
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);
+ }
+ }
+
+ /// <inheritdoc />
+ 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<EmbyTV> _logger;
+ private readonly IHttpClientFactory _httpClientFactory;
+ private readonly IServerConfigurationManager _config;
+
+ private readonly ItemDataProvider<SeriesTimerInfo> _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<string, ActiveRecordingInfo> _activeRecordings =
+ new ConcurrentDictionary<string, ActiveRecordingInfo>(StringComparer.OrdinalIgnoreCase);
+
+ private readonly ConcurrentDictionary<string, EpgChannelData> _epgChannels =
+ new ConcurrentDictionary<string, EpgChannelData>(StringComparer.OrdinalIgnoreCase);
+
+ private readonly SemaphoreSlim _recordingDeleteSemaphore = new SemaphoreSlim(1, 1);
+
+ private bool _disposed;
+
+ public EmbyTV(
+ IStreamHelper streamHelper,
+ IMediaSourceManager mediaSourceManager,
+ ILogger<EmbyTV> 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<GenericEventArgs<TimerInfo>> TimerCreated;
+
+ public event EventHandler<GenericEventArgs<string>> TimerCancelled;
+
+ public static EmbyTV Current { get; private set; }
+
+ /// <inheritdoc />
+ public string Name => "Emby";
+
+ public string DataPath => Path.Combine(_config.CommonApplicationPaths.DataPath, "livetv");
+
+ /// <inheritdoc />
+ 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<string>();
+
+ 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<double>(), 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<Guid, LiveTvChannel>();
+
+ 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<IEnumerable<ChannelInfo>> GetChannelsAsync(bool enableCache, CancellationToken cancellationToken)
+ {
+ var list = new List<ChannelInfo>();
+
+ 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<ChannelInfo> 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<EpgChannelData> 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<ChannelInfo> 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<ChannelInfo> 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<List<ChannelInfo>> GetChannelsForListingsProvider(ListingsProviderInfo listingsProvider, CancellationToken cancellationToken)
+ {
+ var list = new List<ChannelInfo>();
+
+ 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<IEnumerable<ChannelInfo>> 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<string>(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<string> 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<TimerInfo>(info));
+
+ return Task.FromResult(info.Id);
+ }
+
+ public async Task<string> 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<IEnumerable<TimerInfo>> GetTimersAsync(CancellationToken cancellationToken)
+ {
+ var excludeStatues = new List<RecordingStatus>
+ {
+ RecordingStatus.Completed
+ };
+
+ var timers = _timerProvider.GetAll()
+ .Where(i => !excludeStatues.Contains(i.Status));
+
+ return Task.FromResult(timers);
+ }
+
+ public Task<SeriesTimerInfo> 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>
+ {
+ 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<IEnumerable<SeriesTimerInfo>> GetSeriesTimersAsync(CancellationToken cancellationToken)
+ {
+ return Task.FromResult((IEnumerable<SeriesTimerInfo>)_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<IEnumerable<ProgramInfo>> 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<ProgramInfo> 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<ProgramInfo>();
+ }
+
+ private List<Tuple<IListingsProvider, ListingsProviderInfo>> 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<IListingsProvider, ListingsProviderInfo>(provider, i);
+ })
+ .Where(i => i is not null)
+ .ToList();
+ }
+
+ public Task<MediaSourceInfo> GetChannelStream(string channelId, string streamId, CancellationToken cancellationToken)
+ {
+ throw new NotImplementedException();
+ }
+
+ public async Task<ILiveStream> GetChannelStreamWithDirectStreamProvider(string channelId, string streamId, List<ILiveStream> 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<List<MediaSourceInfo>> 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<TimerInfo> 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<RemoteSearchResult> FetchInternetMetadata(TimerInfo timer, CancellationToken cancellationToken)
+ {
+ if (timer.IsSeries)
+ {
+ if (timer.SeriesProviderIds.Count == 0)
+ {
+ return null;
+ }
+
+ var query = new RemoteSearchQuery<SeriesInfo>()
+ {
+ SearchInfo = new SeriesInfo
+ {
+ ProviderIds = timer.SeriesProviderIds,
+ Name = timer.Name,
+ MetadataCountryCode = _config.Configuration.MetadataCountryCode,
+ MetadataLanguage = _config.Configuration.PreferredMetadataLanguage
+ }
+ };
+
+ var results = await _providerManager.GetRemoteSearchResults<Series, SeriesInfo>(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<TimerInfo> 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("&quot;", "'", 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<PersonInfo>() : _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<LiveTvProgram>().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<LiveTvProgram>().FirstOrDefault();
+ }
+
+ private LiveTvOptions GetConfiguration()
+ {
+ return _config.GetConfiguration<LiveTvOptions>("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<TimerInfo> 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<TimerInfo> 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<TimerInfo>();
+ 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<TimerInfo>(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<TimerInfo> 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<Guid, LiveTvChannel>();
+
+ return _libraryManager.GetItemList(query).Cast<LiveTvProgram>().Select(i => CreateTimer(i, seriesTimer, tempChannelCache));
+ }
+
+ private TimerInfo CreateTimer(LiveTvProgram parent, SeriesTimerInfo seriesTimer, Dictionary<Guid, LiveTvChannel> 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<Guid, LiveTvChannel>();
+ CopyProgramInfoToTimerInfo(programInfo, timerInfo, tempChannelCache);
+ }
+
+ private void CopyProgramInfoToTimerInfo(LiveTvProgram programInfo, TimerInfo timerInfo, Dictionary<Guid, LiveTvChannel> 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<string, string>(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;
+ }
+
+ /// <inheritdoc />
+ public void Dispose()
+ {
+ if (_disposed)
+ {
+ return;
+ }
+
+ _recordingDeleteSemaphore.Dispose();
+
+ foreach (var pair in _activeRecordings.ToList())
+ {
+ pair.Value.CancellationTokenSource.Cancel();
+ }
+
+ _disposed = true;
+ }
+
+ public IEnumerable<VirtualFolderInfo> 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<List<TunerHostInfo>> DiscoverTuners(bool newDevicesOnly, CancellationToken cancellationToken)
+ {
+ var list = new List<TunerHostInfo>();
+
+ 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<List<TunerHostInfo>> 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<TunerHostInfo>();
+ }
+ }
+ }
+}
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<bool> _taskCompletionSource = new TaskCompletionSource<bool>(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<string>();
+ 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);
+ }
+ }
+ }
+
+ /// <summary>
+ /// Processes the exited.
+ /// </summary>
+ 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");
+ }
+ }
+
+ /// <inheritdoc />
+ public void Dispose()
+ {
+ Dispose(true);
+ GC.SuppressFinalize(this);
+ }
+
+ /// <summary>
+ /// Releases unmanaged and optionally managed resources.
+ /// </summary>
+ /// <param name="disposing"><c>true</c> to release both managed and unmanaged resources; <c>false</c> to release only unmanaged resources.</param>
+ 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
+ {
+ /// <inheritdoc />
+ public Task RunAsync()
+ {
+ return EmbyTV.Current.Start();
+ }
+
+ /// <inheritdoc />
+ 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<string, ChannelInfo> _channelsById;
+
+ private readonly Dictionary<string, ChannelInfo> _channelsByNumber;
+
+ private readonly Dictionary<string, ChannelInfo> _channelsByName;
+
+ public EpgChannelData(IEnumerable<ChannelInfo> channels)
+ {
+ _channelsById = new Dictionary<string, ChannelInfo>(StringComparer.OrdinalIgnoreCase);
+ _channelsByNumber = new Dictionary<string, ChannelInfo>(StringComparer.OrdinalIgnoreCase);
+ _channelsByName = new Dictionary<string, ChannelInfo>(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
+ {
+ /// <summary>
+ /// Records the specified media source.
+ /// </summary>
+ /// <param name="directStreamProvider">The direct stream provider, or <c>null</c>.</param>
+ /// <param name="mediaSource">The media source.</param>
+ /// <param name="targetFile">The target file.</param>
+ /// <param name="duration">The duration to record.</param>
+ /// <param name="onStarted">An action to perform when recording starts.</param>
+ /// <param name="cancellationToken">The cancellation token.</param>
+ /// <returns>A <see cref="Task"/> that represents the recording operation.</returns>
+ 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<T>
+ 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<T, T, bool> equalityComparer)
+ {
+ Logger = logger;
+ _dataPath = dataPath;
+ EqualityComparer = equalityComparer;
+ }
+
+ protected ILogger Logger { get; }
+
+ protected Func<T, T, bool> 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<T[]>(bytes, _jsonOptions);
+ if (_items is null)
+ {
+ Logger.LogError("Error deserializing {Path}, data was null", _dataPath);
+ _items = Array.Empty<T>();
+ }
+
+ return;
+ }
+ catch (JsonException ex)
+ {
+ Logger.LogError(ex, "Error deserializing {Path}", _dataPath);
+ }
+ }
+
+ _items = Array.Empty<T>();
+ }
+
+ 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<T> 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
+{
+ /// <summary>
+ /// Class containing extension methods for working with the nfo configuration.
+ /// </summary>
+ public static class NfoConfigurationExtensions
+ {
+ /// <summary>
+ /// Gets the nfo configuration.
+ /// </summary>
+ /// <param name="configurationManager">The configuration manager.</param>
+ /// <returns>The nfo configuration.</returns>
+ public static XbmcMetadataOptions GetNfoConfiguration(this IConfigurationManager configurationManager)
+ => configurationManager.GetConfiguration<XbmcMetadataOptions>("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<SeriesTimerInfo>
+ {
+ public SeriesTimerManager(ILogger logger, string dataPath)
+ : base(logger, dataPath, (r1, r2) => string.Equals(r1.Id, r2.Id, StringComparison.OrdinalIgnoreCase))
+ {
+ }
+
+ /// <inheritdoc />
+ 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<TimerInfo>
+ {
+ private readonly ConcurrentDictionary<string, Timer> _timers = new ConcurrentDictionary<string, Timer>(StringComparer.OrdinalIgnoreCase);
+
+ public TimerManager(ILogger logger, string dataPath)
+ : base(logger, dataPath, (r1, r2) => string.Equals(r1.Id, r2.Id, StringComparison.OrdinalIgnoreCase))
+ {
+ }
+
+ public event EventHandler<GenericEventArgs<TimerInfo>>? 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<TimerInfo>(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<TimerInfo>(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<Task> _closeFn;
+
+ public ExclusiveLiveStream(MediaSourceInfo mediaSource, Func<Task> 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;
+ }
+
+ /// <inheritdoc />
+ 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 @@
+<Project Sdk="Microsoft.NET.Sdk">
+ <PropertyGroup>
+ <TargetFramework>net8.0</TargetFramework>
+ <GenerateDocumentationFile>true</GenerateDocumentationFile>
+ </PropertyGroup>
+
+ <ItemGroup>
+ <AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleToAttribute">
+ <_Parameter1>Jellyfin.LiveTv.Tests</_Parameter1>
+ </AssemblyAttribute>
+ </ItemGroup>
+
+ <ItemGroup>
+ <PackageReference Include="Jellyfin.XmlTv" />
+ </ItemGroup>
+
+ <ItemGroup>
+ <ProjectReference Include="..\..\MediaBrowser.Model\MediaBrowser.Model.csproj" />
+ <ProjectReference Include="..\..\MediaBrowser.Controller\MediaBrowser.Controller.csproj" />
+ <ProjectReference Include="..\..\MediaBrowser.Common\MediaBrowser.Common.csproj" />
+ </ItemGroup>
+</Project>
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<SchedulesDirect> _logger;
+ private readonly IHttpClientFactory _httpClientFactory;
+ private readonly SemaphoreSlim _tokenSemaphore = new SemaphoreSlim(1, 1);
+
+ private readonly ConcurrentDictionary<string, NameValuePair> _tokens = new ConcurrentDictionary<string, NameValuePair>();
+ private readonly JsonSerializerOptions _jsonOptions = JsonDefaults.Options;
+ private DateTime _lastErrorResponse;
+ private bool _disposed = false;
+
+ public SchedulesDirect(
+ ILogger<SchedulesDirect> logger,
+ IHttpClientFactory httpClientFactory)
+ {
+ _logger = logger;
+ _httpClientFactory = httpClientFactory;
+ }
+
+ /// <inheritdoc />
+ public string Name => "Schedules Direct";
+
+ /// <inheritdoc />
+ public string Type => nameof(SchedulesDirect);
+
+ private static List<string> GetScheduleRequestDates(DateTime startDateUtc, DateTime endDateUtc)
+ {
+ var dates = new List<string>();
+
+ 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<IEnumerable<ProgramInfo>> 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<ProgramInfo>();
+ }
+
+ var dates = GetScheduleRequestDates(startDateUtc, endDateUtc);
+
+ _logger.LogInformation("Channel Station ID is: {ChannelID}", channelId);
+ var requestList = new List<RequestScheduleForChannelDto>()
+ {
+ 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<IReadOnlyList<DayDto>>(_jsonOptions, cancellationToken).ConfigureAwait(false);
+ if (dailySchedules is null)
+ {
+ return Array.Empty<ProgramInfo>();
+ }
+
+ _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<IReadOnlyList<ProgramDetailsDto>>(_jsonOptions, cancellationToken).ConfigureAwait(false);
+ if (programDetails is null)
+ {
+ return Array.Empty<ProgramInfo>();
+ }
+
+ 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<ProgramInfo>();
+ 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<ImageDataDto> 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<IReadOnlyList<ShowImagesDto>> GetImageForPrograms(
+ ListingsProviderInfo info,
+ IReadOnlyList<string> programIds,
+ CancellationToken cancellationToken)
+ {
+ var token = await GetToken(info, cancellationToken).ConfigureAwait(false);
+
+ if (programIds.Count == 0)
+ {
+ return Array.Empty<ShowImagesDto>();
+ }
+
+ 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<IReadOnlyList<ShowImagesDto>>(_jsonOptions, cancellationToken).ConfigureAwait(false);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error getting image info from schedules direct");
+
+ return Array.Empty<ShowImagesDto>();
+ }
+ }
+
+ public async Task<List<NameIdPair>> GetHeadends(ListingsProviderInfo info, string country, string location, CancellationToken cancellationToken)
+ {
+ var token = await GetToken(info, cancellationToken).ConfigureAwait(false);
+
+ var lineups = new List<NameIdPair>();
+
+ 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<IReadOnlyList<HeadendsDto>>(_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<string> 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<HttpResponseMessage> 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<string> 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<TokenDto>(_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<bool> 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<LineupsDto>(_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<List<NameIdPair>> GetLineups(ListingsProviderInfo info, string country, string location)
+ {
+ return GetHeadends(info, country, location, CancellationToken.None);
+ }
+
+ public async Task<List<ChannelInfo>> 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<ChannelDto>(_jsonOptions, cancellationToken).ConfigureAwait(false);
+ if (root is null)
+ {
+ return new List<ChannelInfo>();
+ }
+
+ _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<ChannelInfo>(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;
+ }
+
+ /// <inheritdoc />
+ public void Dispose()
+ {
+ Dispose(true);
+ GC.SuppressFinalize(this);
+ }
+
+ /// <summary>
+ /// Releases unmanaged and optionally managed resources.
+ /// </summary>
+ /// <param name="disposing"><c>true</c> to release both managed and unmanaged resources; <c>false</c> to release only unmanaged resources.</param>
+ 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
+{
+ /// <summary>
+ /// Broadcaster dto.
+ /// </summary>
+ public class BroadcasterDto
+ {
+ /// <summary>
+ /// Gets or sets the city.
+ /// </summary>
+ [JsonPropertyName("city")]
+ public string? City { get; set; }
+
+ /// <summary>
+ /// Gets or sets the state.
+ /// </summary>
+ [JsonPropertyName("state")]
+ public string? State { get; set; }
+
+ /// <summary>
+ /// Gets or sets the postal code.
+ /// </summary>
+ [JsonPropertyName("postalCode")]
+ public string? Postalcode { get; set; }
+
+ /// <summary>
+ /// Gets or sets the country.
+ /// </summary>
+ [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
+{
+ /// <summary>
+ /// Caption dto.
+ /// </summary>
+ public class CaptionDto
+ {
+ /// <summary>
+ /// Gets or sets the content.
+ /// </summary>
+ [JsonPropertyName("content")]
+ public string? Content { get; set; }
+
+ /// <summary>
+ /// Gets or sets the lang.
+ /// </summary>
+ [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
+{
+ /// <summary>
+ /// Cast dto.
+ /// </summary>
+ public class CastDto
+ {
+ /// <summary>
+ /// Gets or sets the billing order.
+ /// </summary>
+ [JsonPropertyName("billingOrder")]
+ public string? BillingOrder { get; set; }
+
+ /// <summary>
+ /// Gets or sets the role.
+ /// </summary>
+ [JsonPropertyName("role")]
+ public string? Role { get; set; }
+
+ /// <summary>
+ /// Gets or sets the name id.
+ /// </summary>
+ [JsonPropertyName("nameId")]
+ public string? NameId { get; set; }
+
+ /// <summary>
+ /// Gets or sets the person id.
+ /// </summary>
+ [JsonPropertyName("personId")]
+ public string? PersonId { get; set; }
+
+ /// <summary>
+ /// Gets or sets the name.
+ /// </summary>
+ [JsonPropertyName("name")]
+ public string? Name { get; set; }
+
+ /// <summary>
+ /// Gets or sets the character name.
+ /// </summary>
+ [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
+{
+ /// <summary>
+ /// Channel dto.
+ /// </summary>
+ public class ChannelDto
+ {
+ /// <summary>
+ /// Gets or sets the list of maps.
+ /// </summary>
+ [JsonPropertyName("map")]
+ public IReadOnlyList<MapDto> Map { get; set; } = Array.Empty<MapDto>();
+
+ /// <summary>
+ /// Gets or sets the list of stations.
+ /// </summary>
+ [JsonPropertyName("stations")]
+ public IReadOnlyList<StationDto> Stations { get; set; } = Array.Empty<StationDto>();
+
+ /// <summary>
+ /// Gets or sets the metadata.
+ /// </summary>
+ [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
+{
+ /// <summary>
+ /// Content rating dto.
+ /// </summary>
+ public class ContentRatingDto
+ {
+ /// <summary>
+ /// Gets or sets the body.
+ /// </summary>
+ [JsonPropertyName("body")]
+ public string? Body { get; set; }
+
+ /// <summary>
+ /// Gets or sets the code.
+ /// </summary>
+ [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
+{
+ /// <summary>
+ /// Crew dto.
+ /// </summary>
+ public class CrewDto
+ {
+ /// <summary>
+ /// Gets or sets the billing order.
+ /// </summary>
+ [JsonPropertyName("billingOrder")]
+ public string? BillingOrder { get; set; }
+
+ /// <summary>
+ /// Gets or sets the role.
+ /// </summary>
+ [JsonPropertyName("role")]
+ public string? Role { get; set; }
+
+ /// <summary>
+ /// Gets or sets the name id.
+ /// </summary>
+ [JsonPropertyName("nameId")]
+ public string? NameId { get; set; }
+
+ /// <summary>
+ /// Gets or sets the person id.
+ /// </summary>
+ [JsonPropertyName("personId")]
+ public string? PersonId { get; set; }
+
+ /// <summary>
+ /// Gets or sets the name.
+ /// </summary>
+ [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
+{
+ /// <summary>
+ /// Day dto.
+ /// </summary>
+ public class DayDto
+ {
+ /// <summary>
+ /// Gets or sets the station id.
+ /// </summary>
+ [JsonPropertyName("stationID")]
+ public string? StationId { get; set; }
+
+ /// <summary>
+ /// Gets or sets the list of programs.
+ /// </summary>
+ [JsonPropertyName("programs")]
+ public IReadOnlyList<ProgramDto> Programs { get; set; } = Array.Empty<ProgramDto>();
+
+ /// <summary>
+ /// Gets or sets the metadata schedule.
+ /// </summary>
+ [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
+{
+ /// <summary>
+ /// Description 1_000 dto.
+ /// </summary>
+ public class Description1000Dto
+ {
+ /// <summary>
+ /// Gets or sets the description language.
+ /// </summary>
+ [JsonPropertyName("descriptionLanguage")]
+ public string? DescriptionLanguage { get; set; }
+
+ /// <summary>
+ /// Gets or sets the description.
+ /// </summary>
+ [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
+{
+ /// <summary>
+ /// Description 100 dto.
+ /// </summary>
+ public class Description100Dto
+ {
+ /// <summary>
+ /// Gets or sets the description language.
+ /// </summary>
+ [JsonPropertyName("descriptionLanguage")]
+ public string? DescriptionLanguage { get; set; }
+
+ /// <summary>
+ /// Gets or sets the description.
+ /// </summary>
+ [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
+{
+ /// <summary>
+ /// Descriptions program dto.
+ /// </summary>
+ public class DescriptionsProgramDto
+ {
+ /// <summary>
+ /// Gets or sets the list of description 100.
+ /// </summary>
+ [JsonPropertyName("description100")]
+ public IReadOnlyList<Description100Dto> Description100 { get; set; } = Array.Empty<Description100Dto>();
+
+ /// <summary>
+ /// Gets or sets the list of description1000.
+ /// </summary>
+ [JsonPropertyName("description1000")]
+ public IReadOnlyList<Description1000Dto> Description1000 { get; set; } = Array.Empty<Description1000Dto>();
+ }
+}
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
+{
+ /// <summary>
+ /// Event details dto.
+ /// </summary>
+ public class EventDetailsDto
+ {
+ /// <summary>
+ /// Gets or sets the sub type.
+ /// </summary>
+ [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
+{
+ /// <summary>
+ /// Gracenote dto.
+ /// </summary>
+ public class GracenoteDto
+ {
+ /// <summary>
+ /// Gets or sets the season.
+ /// </summary>
+ [JsonPropertyName("season")]
+ public int Season { get; set; }
+
+ /// <summary>
+ /// Gets or sets the episode.
+ /// </summary>
+ [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
+{
+ /// <summary>
+ /// Headends dto.
+ /// </summary>
+ public class HeadendsDto
+ {
+ /// <summary>
+ /// Gets or sets the headend.
+ /// </summary>
+ [JsonPropertyName("headend")]
+ public string? Headend { get; set; }
+
+ /// <summary>
+ /// Gets or sets the transport.
+ /// </summary>
+ [JsonPropertyName("transport")]
+ public string? Transport { get; set; }
+
+ /// <summary>
+ /// Gets or sets the location.
+ /// </summary>
+ [JsonPropertyName("location")]
+ public string? Location { get; set; }
+
+ /// <summary>
+ /// Gets or sets the list of lineups.
+ /// </summary>
+ [JsonPropertyName("lineups")]
+ public IReadOnlyList<LineupDto> Lineups { get; set; } = Array.Empty<LineupDto>();
+ }
+}
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
+{
+ /// <summary>
+ /// Image data dto.
+ /// </summary>
+ public class ImageDataDto
+ {
+ /// <summary>
+ /// Gets or sets the width.
+ /// </summary>
+ [JsonPropertyName("width")]
+ public string? Width { get; set; }
+
+ /// <summary>
+ /// Gets or sets the height.
+ /// </summary>
+ [JsonPropertyName("height")]
+ public string? Height { get; set; }
+
+ /// <summary>
+ /// Gets or sets the uri.
+ /// </summary>
+ [JsonPropertyName("uri")]
+ public string? Uri { get; set; }
+
+ /// <summary>
+ /// Gets or sets the size.
+ /// </summary>
+ [JsonPropertyName("size")]
+ public string? Size { get; set; }
+
+ /// <summary>
+ /// Gets or sets the aspect.
+ /// </summary>
+ [JsonPropertyName("aspect")]
+ public string? Aspect { get; set; }
+
+ /// <summary>
+ /// Gets or sets the category.
+ /// </summary>
+ [JsonPropertyName("category")]
+ public string? Category { get; set; }
+
+ /// <summary>
+ /// Gets or sets the text.
+ /// </summary>
+ [JsonPropertyName("text")]
+ public string? Text { get; set; }
+
+ /// <summary>
+ /// Gets or sets the primary.
+ /// </summary>
+ [JsonPropertyName("primary")]
+ public string? Primary { get; set; }
+
+ /// <summary>
+ /// Gets or sets the tier.
+ /// </summary>
+ [JsonPropertyName("tier")]
+ public string? Tier { get; set; }
+
+ /// <summary>
+ /// Gets or sets the caption.
+ /// </summary>
+ [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
+{
+ /// <summary>
+ /// The lineup dto.
+ /// </summary>
+ public class LineupDto
+ {
+ /// <summary>
+ /// Gets or sets the linup.
+ /// </summary>
+ [JsonPropertyName("lineup")]
+ public string? Lineup { get; set; }
+
+ /// <summary>
+ /// Gets or sets the lineup name.
+ /// </summary>
+ [JsonPropertyName("name")]
+ public string? Name { get; set; }
+
+ /// <summary>
+ /// Gets or sets the transport.
+ /// </summary>
+ [JsonPropertyName("transport")]
+ public string? Transport { get; set; }
+
+ /// <summary>
+ /// Gets or sets the location.
+ /// </summary>
+ [JsonPropertyName("location")]
+ public string? Location { get; set; }
+
+ /// <summary>
+ /// Gets or sets the uri.
+ /// </summary>
+ [JsonPropertyName("uri")]
+ public string? Uri { get; set; }
+
+ /// <summary>
+ /// Gets or sets a value indicating whether this lineup was deleted.
+ /// </summary>
+ [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
+{
+ /// <summary>
+ /// Lineups dto.
+ /// </summary>
+ public class LineupsDto
+ {
+ /// <summary>
+ /// Gets or sets the response code.
+ /// </summary>
+ [JsonPropertyName("code")]
+ public int Code { get; set; }
+
+ /// <summary>
+ /// Gets or sets the server id.
+ /// </summary>
+ [JsonPropertyName("serverID")]
+ public string? ServerId { get; set; }
+
+ /// <summary>
+ /// Gets or sets the datetime.
+ /// </summary>
+ [JsonPropertyName("datetime")]
+ public DateTime? LineupTimestamp { get; set; }
+
+ /// <summary>
+ /// Gets or sets the list of lineups.
+ /// </summary>
+ [JsonPropertyName("lineups")]
+ public IReadOnlyList<LineupDto> Lineups { get; set; } = Array.Empty<LineupDto>();
+ }
+}
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
+{
+ /// <summary>
+ /// Logo dto.
+ /// </summary>
+ public class LogoDto
+ {
+ /// <summary>
+ /// Gets or sets the url.
+ /// </summary>
+ [JsonPropertyName("URL")]
+ public string? Url { get; set; }
+
+ /// <summary>
+ /// Gets or sets the height.
+ /// </summary>
+ [JsonPropertyName("height")]
+ public int Height { get; set; }
+
+ /// <summary>
+ /// Gets or sets the width.
+ /// </summary>
+ [JsonPropertyName("width")]
+ public int Width { get; set; }
+
+ /// <summary>
+ /// Gets or sets the md5.
+ /// </summary>
+ [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
+{
+ /// <summary>
+ /// Map dto.
+ /// </summary>
+ public class MapDto
+ {
+ /// <summary>
+ /// Gets or sets the station id.
+ /// </summary>
+ [JsonPropertyName("stationID")]
+ public string? StationId { get; set; }
+
+ /// <summary>
+ /// Gets or sets the channel.
+ /// </summary>
+ [JsonPropertyName("channel")]
+ public string? Channel { get; set; }
+
+ /// <summary>
+ /// Gets or sets the provider callsign.
+ /// </summary>
+ [JsonPropertyName("providerCallsign")]
+ public string? ProvderCallsign { get; set; }
+
+ /// <summary>
+ /// Gets or sets the logical channel number.
+ /// </summary>
+ [JsonPropertyName("logicalChannelNumber")]
+ public string? LogicalChannelNumber { get; set; }
+
+ /// <summary>
+ /// Gets or sets the uhfvhf.
+ /// </summary>
+ [JsonPropertyName("uhfVhf")]
+ public int UhfVhf { get; set; }
+
+ /// <summary>
+ /// Gets or sets the atsc major.
+ /// </summary>
+ [JsonPropertyName("atscMajor")]
+ public int AtscMajor { get; set; }
+
+ /// <summary>
+ /// Gets or sets the atsc minor.
+ /// </summary>
+ [JsonPropertyName("atscMinor")]
+ public int AtscMinor { get; set; }
+
+ /// <summary>
+ /// Gets or sets the match type.
+ /// </summary>
+ [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
+{
+ /// <summary>
+ /// Metadata dto.
+ /// </summary>
+ public class MetadataDto
+ {
+ /// <summary>
+ /// Gets or sets the linup.
+ /// </summary>
+ [JsonPropertyName("lineup")]
+ public string? Lineup { get; set; }
+
+ /// <summary>
+ /// Gets or sets the modified timestamp.
+ /// </summary>
+ [JsonPropertyName("modified")]
+ public string? Modified { get; set; }
+
+ /// <summary>
+ /// Gets or sets the transport.
+ /// </summary>
+ [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
+{
+ /// <summary>
+ /// Metadata programs dto.
+ /// </summary>
+ public class MetadataProgramsDto
+ {
+ /// <summary>
+ /// Gets or sets the gracenote object.
+ /// </summary>
+ [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
+{
+ /// <summary>
+ /// Metadata schedule dto.
+ /// </summary>
+ public class MetadataScheduleDto
+ {
+ /// <summary>
+ /// Gets or sets the modified timestamp.
+ /// </summary>
+ [JsonPropertyName("modified")]
+ public string? Modified { get; set; }
+
+ /// <summary>
+ /// Gets or sets the md5.
+ /// </summary>
+ [JsonPropertyName("md5")]
+ public string? Md5 { get; set; }
+
+ /// <summary>
+ /// Gets or sets the start date.
+ /// </summary>
+ [JsonPropertyName("startDate")]
+ public DateTime? StartDate { get; set; }
+
+ /// <summary>
+ /// Gets or sets the end date.
+ /// </summary>
+ [JsonPropertyName("endDate")]
+ public DateTime? EndDate { get; set; }
+
+ /// <summary>
+ /// Gets or sets the days count.
+ /// </summary>
+ [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
+{
+ /// <summary>
+ /// Movie dto.
+ /// </summary>
+ public class MovieDto
+ {
+ /// <summary>
+ /// Gets or sets the year.
+ /// </summary>
+ [JsonPropertyName("year")]
+ public string? Year { get; set; }
+
+ /// <summary>
+ /// Gets or sets the duration.
+ /// </summary>
+ [JsonPropertyName("duration")]
+ public int Duration { get; set; }
+
+ /// <summary>
+ /// Gets or sets the list of quality rating.
+ /// </summary>
+ [JsonPropertyName("qualityRating")]
+ public IReadOnlyList<QualityRatingDto> QualityRating { get; set; } = Array.Empty<QualityRatingDto>();
+ }
+}
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
+{
+ /// <summary>
+ /// Multipart dto.
+ /// </summary>
+ public class MultipartDto
+ {
+ /// <summary>
+ /// Gets or sets the part number.
+ /// </summary>
+ [JsonPropertyName("partNumber")]
+ public int PartNumber { get; set; }
+
+ /// <summary>
+ /// Gets or sets the total parts.
+ /// </summary>
+ [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
+{
+ /// <summary>
+ /// Program details dto.
+ /// </summary>
+ public class ProgramDetailsDto
+ {
+ /// <summary>
+ /// Gets or sets the audience.
+ /// </summary>
+ [JsonPropertyName("audience")]
+ public string? Audience { get; set; }
+
+ /// <summary>
+ /// Gets or sets the program id.
+ /// </summary>
+ [JsonPropertyName("programID")]
+ public string? ProgramId { get; set; }
+
+ /// <summary>
+ /// Gets or sets the list of titles.
+ /// </summary>
+ [JsonPropertyName("titles")]
+ public IReadOnlyList<TitleDto> Titles { get; set; } = Array.Empty<TitleDto>();
+
+ /// <summary>
+ /// Gets or sets the event details object.
+ /// </summary>
+ [JsonPropertyName("eventDetails")]
+ public EventDetailsDto? EventDetails { get; set; }
+
+ /// <summary>
+ /// Gets or sets the descriptions.
+ /// </summary>
+ [JsonPropertyName("descriptions")]
+ public DescriptionsProgramDto? Descriptions { get; set; }
+
+ /// <summary>
+ /// Gets or sets the original air date.
+ /// </summary>
+ [JsonPropertyName("originalAirDate")]
+ public DateTime? OriginalAirDate { get; set; }
+
+ /// <summary>
+ /// Gets or sets the list of genres.
+ /// </summary>
+ [JsonPropertyName("genres")]
+ public IReadOnlyList<string> Genres { get; set; } = Array.Empty<string>();
+
+ /// <summary>
+ /// Gets or sets the episode title.
+ /// </summary>
+ [JsonPropertyName("episodeTitle150")]
+ public string? EpisodeTitle150 { get; set; }
+
+ /// <summary>
+ /// Gets or sets the list of metadata.
+ /// </summary>
+ [JsonPropertyName("metadata")]
+ public IReadOnlyList<MetadataProgramsDto> Metadata { get; set; } = Array.Empty<MetadataProgramsDto>();
+
+ /// <summary>
+ /// Gets or sets the list of content raitings.
+ /// </summary>
+ [JsonPropertyName("contentRating")]
+ public IReadOnlyList<ContentRatingDto> ContentRating { get; set; } = Array.Empty<ContentRatingDto>();
+
+ /// <summary>
+ /// Gets or sets the list of cast.
+ /// </summary>
+ [JsonPropertyName("cast")]
+ public IReadOnlyList<CastDto> Cast { get; set; } = Array.Empty<CastDto>();
+
+ /// <summary>
+ /// Gets or sets the list of crew.
+ /// </summary>
+ [JsonPropertyName("crew")]
+ public IReadOnlyList<CrewDto> Crew { get; set; } = Array.Empty<CrewDto>();
+
+ /// <summary>
+ /// Gets or sets the entity type.
+ /// </summary>
+ [JsonPropertyName("entityType")]
+ public string? EntityType { get; set; }
+
+ /// <summary>
+ /// Gets or sets the show type.
+ /// </summary>
+ [JsonPropertyName("showType")]
+ public string? ShowType { get; set; }
+
+ /// <summary>
+ /// Gets or sets a value indicating whether there is image artwork.
+ /// </summary>
+ [JsonPropertyName("hasImageArtwork")]
+ public bool HasImageArtwork { get; set; }
+
+ /// <summary>
+ /// Gets or sets the primary image.
+ /// </summary>
+ [JsonPropertyName("primaryImage")]
+ public string? PrimaryImage { get; set; }
+
+ /// <summary>
+ /// Gets or sets the thumb image.
+ /// </summary>
+ [JsonPropertyName("thumbImage")]
+ public string? ThumbImage { get; set; }
+
+ /// <summary>
+ /// Gets or sets the backdrop image.
+ /// </summary>
+ [JsonPropertyName("backdropImage")]
+ public string? BackdropImage { get; set; }
+
+ /// <summary>
+ /// Gets or sets the banner image.
+ /// </summary>
+ [JsonPropertyName("bannerImage")]
+ public string? BannerImage { get; set; }
+
+ /// <summary>
+ /// Gets or sets the image id.
+ /// </summary>
+ [JsonPropertyName("imageID")]
+ public string? ImageId { get; set; }
+
+ /// <summary>
+ /// Gets or sets the md5.
+ /// </summary>
+ [JsonPropertyName("md5")]
+ public string? Md5 { get; set; }
+
+ /// <summary>
+ /// Gets or sets the list of content advisory.
+ /// </summary>
+ [JsonPropertyName("contentAdvisory")]
+ public IReadOnlyList<string> ContentAdvisory { get; set; } = Array.Empty<string>();
+
+ /// <summary>
+ /// Gets or sets the movie object.
+ /// </summary>
+ [JsonPropertyName("movie")]
+ public MovieDto? Movie { get; set; }
+
+ /// <summary>
+ /// Gets or sets the list of recommendations.
+ /// </summary>
+ [JsonPropertyName("recommendations")]
+ public IReadOnlyList<RecommendationDto> Recommendations { get; set; } = Array.Empty<RecommendationDto>();
+ }
+}
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
+{
+ /// <summary>
+ /// Program dto.
+ /// </summary>
+ public class ProgramDto
+ {
+ /// <summary>
+ /// Gets or sets the program id.
+ /// </summary>
+ [JsonPropertyName("programID")]
+ public string? ProgramId { get; set; }
+
+ /// <summary>
+ /// Gets or sets the air date time.
+ /// </summary>
+ [JsonPropertyName("airDateTime")]
+ public DateTime? AirDateTime { get; set; }
+
+ /// <summary>
+ /// Gets or sets the duration.
+ /// </summary>
+ [JsonPropertyName("duration")]
+ public int Duration { get; set; }
+
+ /// <summary>
+ /// Gets or sets the md5.
+ /// </summary>
+ [JsonPropertyName("md5")]
+ public string? Md5 { get; set; }
+
+ /// <summary>
+ /// Gets or sets the list of audio properties.
+ /// </summary>
+ [JsonPropertyName("audioProperties")]
+ public IReadOnlyList<string> AudioProperties { get; set; } = Array.Empty<string>();
+
+ /// <summary>
+ /// Gets or sets the list of video properties.
+ /// </summary>
+ [JsonPropertyName("videoProperties")]
+ public IReadOnlyList<string> VideoProperties { get; set; } = Array.Empty<string>();
+
+ /// <summary>
+ /// Gets or sets the list of ratings.
+ /// </summary>
+ [JsonPropertyName("ratings")]
+ public IReadOnlyList<RatingDto> Ratings { get; set; } = Array.Empty<RatingDto>();
+
+ /// <summary>
+ /// Gets or sets a value indicating whether this program is new.
+ /// </summary>
+ [JsonPropertyName("new")]
+ public bool? New { get; set; }
+
+ /// <summary>
+ /// Gets or sets the multipart object.
+ /// </summary>
+ [JsonPropertyName("multipart")]
+ public MultipartDto? Multipart { get; set; }
+
+ /// <summary>
+ /// Gets or sets the live tape delay.
+ /// </summary>
+ [JsonPropertyName("liveTapeDelay")]
+ public string? LiveTapeDelay { get; set; }
+
+ /// <summary>
+ /// Gets or sets a value indicating whether this is the premiere.
+ /// </summary>
+ [JsonPropertyName("premiere")]
+ public bool Premiere { get; set; }
+
+ /// <summary>
+ /// Gets or sets a value indicating whether this is a repeat.
+ /// </summary>
+ [JsonPropertyName("repeat")]
+ public bool Repeat { get; set; }
+
+ /// <summary>
+ /// Gets or sets the premiere or finale.
+ /// </summary>
+ [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
+{
+ /// <summary>
+ /// Quality rating dto.
+ /// </summary>
+ public class QualityRatingDto
+ {
+ /// <summary>
+ /// Gets or sets the ratings body.
+ /// </summary>
+ [JsonPropertyName("ratingsBody")]
+ public string? RatingsBody { get; set; }
+
+ /// <summary>
+ /// Gets or sets the rating.
+ /// </summary>
+ [JsonPropertyName("rating")]
+ public string? Rating { get; set; }
+
+ /// <summary>
+ /// Gets or sets the min rating.
+ /// </summary>
+ [JsonPropertyName("minRating")]
+ public string? MinRating { get; set; }
+
+ /// <summary>
+ /// Gets or sets the max rating.
+ /// </summary>
+ [JsonPropertyName("maxRating")]
+ public string? MaxRating { get; set; }
+
+ /// <summary>
+ /// Gets or sets the increment.
+ /// </summary>
+ [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
+{
+ /// <summary>
+ /// Rating dto.
+ /// </summary>
+ public class RatingDto
+ {
+ /// <summary>
+ /// Gets or sets the body.
+ /// </summary>
+ [JsonPropertyName("body")]
+ public string? Body { get; set; }
+
+ /// <summary>
+ /// Gets or sets the code.
+ /// </summary>
+ [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
+{
+ /// <summary>
+ /// Recommendation dto.
+ /// </summary>
+ public class RecommendationDto
+ {
+ /// <summary>
+ /// Gets or sets the program id.
+ /// </summary>
+ [JsonPropertyName("programID")]
+ public string? ProgramId { get; set; }
+
+ /// <summary>
+ /// Gets or sets the title.
+ /// </summary>
+ [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
+{
+ /// <summary>
+ /// Request schedule for channel dto.
+ /// </summary>
+ public class RequestScheduleForChannelDto
+ {
+ /// <summary>
+ /// Gets or sets the station id.
+ /// </summary>
+ [JsonPropertyName("stationID")]
+ public string? StationId { get; set; }
+
+ /// <summary>
+ /// Gets or sets the list of dates.
+ /// </summary>
+ [JsonPropertyName("date")]
+ public IReadOnlyList<string> Date { get; set; } = Array.Empty<string>();
+ }
+}
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
+{
+ /// <summary>
+ /// Show image dto.
+ /// </summary>
+ public class ShowImagesDto
+ {
+ /// <summary>
+ /// Gets or sets the program id.
+ /// </summary>
+ [JsonPropertyName("programID")]
+ public string? ProgramId { get; set; }
+
+ /// <summary>
+ /// Gets or sets the list of data.
+ /// </summary>
+ [JsonPropertyName("data")]
+ public IReadOnlyList<ImageDataDto> Data { get; set; } = Array.Empty<ImageDataDto>();
+ }
+}
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
+{
+ /// <summary>
+ /// Station dto.
+ /// </summary>
+ public class StationDto
+ {
+ /// <summary>
+ /// Gets or sets the station id.
+ /// </summary>
+ [JsonPropertyName("stationID")]
+ public string? StationId { get; set; }
+
+ /// <summary>
+ /// Gets or sets the name.
+ /// </summary>
+ [JsonPropertyName("name")]
+ public string? Name { get; set; }
+
+ /// <summary>
+ /// Gets or sets the callsign.
+ /// </summary>
+ [JsonPropertyName("callsign")]
+ public string? Callsign { get; set; }
+
+ /// <summary>
+ /// Gets or sets the broadcast language.
+ /// </summary>
+ [JsonPropertyName("broadcastLanguage")]
+ public IReadOnlyList<string> BroadcastLanguage { get; set; } = Array.Empty<string>();
+
+ /// <summary>
+ /// Gets or sets the description language.
+ /// </summary>
+ [JsonPropertyName("descriptionLanguage")]
+ public IReadOnlyList<string> DescriptionLanguage { get; set; } = Array.Empty<string>();
+
+ /// <summary>
+ /// Gets or sets the broadcaster.
+ /// </summary>
+ [JsonPropertyName("broadcaster")]
+ public BroadcasterDto? Broadcaster { get; set; }
+
+ /// <summary>
+ /// Gets or sets the affiliate.
+ /// </summary>
+ [JsonPropertyName("affiliate")]
+ public string? Affiliate { get; set; }
+
+ /// <summary>
+ /// Gets or sets the logo.
+ /// </summary>
+ [JsonPropertyName("logo")]
+ public LogoDto? Logo { get; set; }
+
+ /// <summary>
+ /// Gets or sets a value indicating whether it is commercial free.
+ /// </summary>
+ [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
+{
+ /// <summary>
+ /// Title dto.
+ /// </summary>
+ public class TitleDto
+ {
+ /// <summary>
+ /// Gets or sets the title.
+ /// </summary>
+ [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
+{
+ /// <summary>
+ /// The token dto.
+ /// </summary>
+ public class TokenDto
+ {
+ /// <summary>
+ /// Gets or sets the response code.
+ /// </summary>
+ [JsonPropertyName("code")]
+ public int Code { get; set; }
+
+ /// <summary>
+ /// Gets or sets the response message.
+ /// </summary>
+ [JsonPropertyName("message")]
+ public string? Message { get; set; }
+
+ /// <summary>
+ /// Gets or sets the server id.
+ /// </summary>
+ [JsonPropertyName("serverID")]
+ public string? ServerId { get; set; }
+
+ /// <summary>
+ /// Gets or sets the token.
+ /// </summary>
+ [JsonPropertyName("token")]
+ public string? Token { get; set; }
+
+ /// <summary>
+ /// Gets or sets the current datetime.
+ /// </summary>
+ [JsonPropertyName("datetime")]
+ public DateTime? TokenTimestamp { get; set; }
+
+ /// <summary>
+ /// Gets or sets the response message.
+ /// </summary>
+ [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<XmlTvListingsProvider> _logger;
+
+ public XmlTvListingsProvider(
+ IServerConfigurationManager config,
+ IHttpClientFactory httpClientFactory,
+ ILogger<XmlTvListingsProvider> 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<string> 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<string> 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<IEnumerable<ProgramInfo>> 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<List<NameIdPair>> 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<XmlTvChannel> results = reader.GetChannels();
+
+ // Should this method be async?
+ return results.Select(c => new NameIdPair() { Id = c.Id, Name = c.DisplayName }).ToList();
+ }
+
+ public async Task<List<ChannelInfo>> 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
+{
+ /// <summary>
+ /// <see cref="IConfigurationFactory" /> implementation for <see cref="LiveTvOptions" />.
+ /// </summary>
+ public class LiveTvConfigurationFactory : IConfigurationFactory
+ {
+ /// <inheritdoc />
+ public IEnumerable<ConfigurationStore> 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<LiveTvDtoService> _logger;
+ private readonly IImageProcessor _imageProcessor;
+ private readonly IDtoService _dtoService;
+ private readonly IApplicationHost _appHost;
+ private readonly ILibraryManager _libraryManager;
+
+ public LiveTvDtoService(
+ IDtoService dtoService,
+ IImageProcessor imageProcessor,
+ ILogger<LiveTvDtoService> 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<TimerInfo> 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<SeriesTimerInfo> 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
+{
+ /// <summary>
+ /// Class LiveTvManager.
+ /// </summary>
+ 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<LiveTvManager> _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<ILiveTvService>();
+ private ITunerHost[] _tunerHosts = Array.Empty<ITunerHost>();
+ private IListingsProvider[] _listingProviders = Array.Empty<IListingsProvider>();
+
+ public LiveTvManager(
+ IServerConfigurationManager config,
+ ILogger<LiveTvManager> 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<GenericEventArgs<TimerEventInfo>> SeriesTimerCancelled;
+
+ public event EventHandler<GenericEventArgs<TimerEventInfo>> TimerCancelled;
+
+ public event EventHandler<GenericEventArgs<TimerEventInfo>> TimerCreated;
+
+ public event EventHandler<GenericEventArgs<TimerEventInfo>> SeriesTimerCreated;
+
+ /// <summary>
+ /// Gets the services.
+ /// </summary>
+ /// <value>The services.</value>
+ public IReadOnlyList<ILiveTvService> Services => _services;
+
+ public IReadOnlyList<ITunerHost> TunerHosts => _tunerHosts;
+
+ public IReadOnlyList<IListingsProvider> ListingProviders => _listingProviders;
+
+ private LiveTvOptions GetConfiguration()
+ {
+ return _config.GetConfiguration<LiveTvOptions>("livetv");
+ }
+
+ public string GetEmbyTvActiveRecordingPath(string id)
+ {
+ return EmbyTV.EmbyTV.Current.GetActiveRecordingPath(id);
+ }
+
+ /// <summary>
+ /// Adds the parts.
+ /// </summary>
+ /// <param name="services">The services.</param>
+ /// <param name="tunerHosts">The tuner hosts.</param>
+ /// <param name="listingProviders">The listing providers.</param>
+ public void AddParts(IEnumerable<ILiveTvService> services, IEnumerable<ITunerHost> tunerHosts, IEnumerable<IListingsProvider> 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<string> e)
+ {
+ var timerId = e.Argument;
+
+ TimerCancelled?.Invoke(this, new GenericEventArgs<TimerEventInfo>(new TimerEventInfo(timerId)));
+ }
+
+ private void OnEmbyTvTimerCreated(object sender, GenericEventArgs<TimerInfo> e)
+ {
+ var timer = e.Argument;
+
+ TimerCreated?.Invoke(this, new GenericEventArgs<TimerEventInfo>(
+ new TimerEventInfo(timer.Id)
+ {
+ ProgramId = _tvDtoService.GetInternalProgramId(timer.ProgramId)
+ }));
+ }
+
+ public List<NameIdPair> GetTunerHostTypes()
+ {
+ return _tunerHosts.OrderBy(i => i.Name).Select(i => new NameIdPair
+ {
+ Name = i.Name,
+ Id = i.Type
+ }).ToList();
+ }
+
+ public Task<List<TunerHostInfo>> DiscoverTuners(bool newDevicesOnly, CancellationToken cancellationToken)
+ {
+ return EmbyTV.EmbyTV.Current.DiscoverTuners(newDevicesOnly, cancellationToken);
+ }
+
+ public QueryResult<BaseItem> 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<Tuple<MediaSourceInfo, ILiveStream>> GetChannelStream(string id, string mediaSourceId, List<ILiveStream> 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<Task> 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<MediaSourceInfo, ILiveStream>(info, liveStream);
+ }
+
+ public async Task<IEnumerable<MediaSourceInfo>> 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<LiveTvChannel> 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<Guid, LiveTvProgram> 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<string>();
+ 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<BaseItemDto> 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<QueryResult<BaseItemDto>> 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<BaseItemDto>();
+ }
+ }
+ else
+ {
+ // Better to return nothing than every program in the database
+ return new QueryResult<BaseItemDto>();
+ }
+ }
+
+ var queryResult = _libraryManager.QueryItems(internalQuery);
+
+ var returnArray = _dtoService.GetBaseItemDtos(queryResult.Items, options, user);
+
+ return new QueryResult<BaseItemDto>(
+ query.StartIndex,
+ queryResult.TotalRecordCount,
+ returnArray);
+ }
+
+ public QueryResult<BaseItem> 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<LiveTvProgram>().OrderBy(i => i.StartDate.Date);
+
+ if (query.IsAiring ?? false)
+ {
+ orderedPrograms = orderedPrograms
+ .ThenByDescending(i => GetRecommendationScore(i, user, true));
+ }
+
+ IEnumerable<BaseItem> programs = orderedPrograms;
+
+ if (query.Limit.HasValue)
+ {
+ programs = programs.Take(query.Limit.Value);
+ }
+
+ return new QueryResult<BaseItem>(
+ query.StartIndex,
+ totalCount,
+ programs.ToArray());
+ }
+
+ public Task<QueryResult<BaseItemDto>> 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<BaseItemDto>(
+ 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<TimerInfo> timerList = null;
+ IReadOnlyList<SeriesTimerInfo> 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<double> progress, CancellationToken cancellationToken)
+ {
+ return RefreshChannelsInternal(progress, cancellationToken);
+ }
+
+ private async Task RefreshChannelsInternal(IProgress<double> 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<Guid>();
+ var newProgramIdList = new List<Guid>();
+
+ var cleanDatabase = true;
+
+ foreach (var service in _services)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ _logger.LogDebug("Refreshing guide from {Name}", service.Name);
+
+ try
+ {
+ var innerProgress = new ActionableProgress<double>();
+ 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<EmbyTV.EmbyTV>().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<Tuple<List<Guid>, List<Guid>>> RefreshChannelsInternal(ILiveTvService service, ActionableProgress<double> progress, CancellationToken cancellationToken)
+ {
+ progress.Report(10);
+
+ var allChannelsList = (await service.GetChannelsAsync(cancellationToken).ConfigureAwait(false))
+ .Select(i => new Tuple<string, ChannelInfo>(service.Name, i))
+ .ToList();
+
+ var list = new List<LiveTvChannel>();
+
+ 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<Guid>();
+ var channels = new List<Guid>();
+
+ 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<LiveTvProgram>().ToDictionary(i => i.Id);
+
+ var newPrograms = new List<LiveTvProgram>();
+ var updatedPrograms = new List<BaseItem>();
+
+ 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<Guid>, List<Guid>>(channels, programs);
+ }
+
+ private void CleanDatabaseInternal(Guid[] currentIdList, BaseItemKind[] validTypes, IProgress<double> 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<QueryResult<BaseItem>> GetEmbyRecordingsAsync(RecordingQuery query, DtoOptions dtoOptions, User user)
+ {
+ if (user is null)
+ {
+ return new QueryResult<BaseItem>();
+ }
+
+ var folders = await GetRecordingFoldersAsync(user, true).ConfigureAwait(false);
+ var folderIds = Array.ConvertAll(folders, x => x.Id);
+
+ var excludeItemTypes = new List<BaseItemKind>();
+
+ if (folderIds.Length == 0)
+ {
+ return new QueryResult<BaseItem>();
+ }
+
+ var includeItemTypes = new List<BaseItemKind>();
+ var genres = new List<string>();
+
+ 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<BaseItem>
+ // {
+ // 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<Video>()
+ .Where(i => !i.IsCompleteMedia)
+ .ToArray();
+
+ result.TotalRecordCount = result.Items.Count;
+ }
+
+ return result;
+ }
+
+ public Task AddInfoToProgramDto(IReadOnlyCollection<(BaseItem Item, BaseItemDto ItemDto)> programs, IReadOnlyList<ItemFields> fields, User user = null)
+ {
+ var programTuples = new List<(BaseItemDto Dto, string ExternalId, string ExternalSeriesId)>();
+ var hasChannelImage = fields.Contains(ItemFields.ChannelImage);
+ var hasChannelInfo = fields.Contains(ItemFields.ChannelInfo);
+
+ foreach (var (item, dto) in programs)
+ {
+ var program = (LiveTvProgram)item;
+
+ dto.StartDate = program.StartDate;
+ dto.EpisodeTitle = program.EpisodeTitle;
+ dto.IsRepeat |= program.IsRepeat;
+ dto.IsMovie |= program.IsMovie;
+ dto.IsSeries |= program.IsSeries;
+ dto.IsSports |= program.IsSports;
+ dto.IsLive |= program.IsLive;
+ dto.IsNews |= program.IsNews;
+ dto.IsKids |= program.IsKids;
+ dto.IsPremiere |= program.IsPremiere;
+
+ if (hasChannelInfo || hasChannelImage)
+ {
+ var channel = _libraryManager.GetItemById(program.ChannelId);
+
+ if (channel is LiveTvChannel liveChannel)
+ {
+ dto.ChannelName = liveChannel.Name;
+ dto.MediaType = liveChannel.MediaType;
+ dto.ChannelNumber = liveChannel.Number;
+
+ if (hasChannelImage && liveChannel.HasImage(ImageType.Primary))
+ {
+ dto.ChannelPrimaryImageTag = _tvDtoService.GetImageTag(liveChannel);
+ }
+ }
+ }
+
+ programTuples.Add((dto, program.ExternalId, program.ExternalSeriesId));
+ }
+
+ return AddRecordingInfo(programTuples, CancellationToken.None);
+ }
+
+ public ActiveRecordingInfo GetActiveRecordingInfo(string path)
+ {
+ return EmbyTV.EmbyTV.Current.GetActiveRecordingInfo(path);
+ }
+
+ public void AddInfoToRecordingDto(BaseItem item, BaseItemDto dto, ActiveRecordingInfo activeRecordingInfo, User user = null)
+ {
+ var service = EmbyTV.EmbyTV.Current;
+
+ var info = activeRecordingInfo.Timer;
+
+ var channel = string.IsNullOrWhiteSpace(info.ChannelId) ? null : _libraryManager.GetItemById(_tvDtoService.GetInternalChannelId(service.Name, info.ChannelId));
+
+ dto.SeriesTimerId = string.IsNullOrEmpty(info.SeriesTimerId)
+ ? null
+ : _tvDtoService.GetInternalSeriesTimerId(info.SeriesTimerId).ToString("N", CultureInfo.InvariantCulture);
+
+ dto.TimerId = string.IsNullOrEmpty(info.Id)
+ ? null
+ : _tvDtoService.GetInternalTimerId(info.Id);
+
+ var startDate = info.StartDate;
+ var endDate = info.EndDate;
+
+ dto.StartDate = startDate;
+ dto.EndDate = endDate;
+ dto.Status = info.Status.ToString();
+ dto.IsRepeat = info.IsRepeat;
+ dto.EpisodeTitle = info.EpisodeTitle;
+ dto.IsMovie = info.IsMovie;
+ dto.IsSeries = info.IsSeries;
+ dto.IsSports = info.IsSports;
+ dto.IsLive = info.IsLive;
+ dto.IsNews = info.IsNews;
+ dto.IsKids = info.IsKids;
+ dto.IsPremiere = info.IsPremiere;
+
+ if (info.Status == RecordingStatus.InProgress)
+ {
+ startDate = info.StartDate.AddSeconds(0 - info.PrePaddingSeconds);
+ endDate = info.EndDate.AddSeconds(info.PostPaddingSeconds);
+
+ var now = DateTime.UtcNow.Ticks;
+ var start = startDate.Ticks;
+ var end = endDate.Ticks;
+
+ var pct = now - start;
+
+ pct /= end;
+ pct *= 100;
+ dto.CompletionPercentage = pct;
+ }
+
+ if (channel is not null)
+ {
+ dto.ChannelName = channel.Name;
+
+ if (channel.HasImage(ImageType.Primary))
+ {
+ dto.ChannelPrimaryImageTag = _tvDtoService.GetImageTag(channel);
+ }
+ }
+ }
+
+ public async Task<QueryResult<BaseItemDto>> GetRecordingsAsync(RecordingQuery query, DtoOptions options)
+ {
+ var user = query.UserId.Equals(default)
+ ? null
+ : _userManager.GetUserById(query.UserId);
+
+ RemoveFields(options);
+
+ var internalResult = await GetEmbyRecordingsAsync(query, options, user).ConfigureAwait(false);
+
+ var returnArray = _dtoService.GetBaseItemDtos(internalResult.Items, options, user);
+
+ return new QueryResult<BaseItemDto>(
+ query.StartIndex,
+ internalResult.TotalRecordCount,
+ returnArray);
+ }
+
+ private async Task<QueryResult<TimerInfo>> GetTimersInternal(TimerQuery query, CancellationToken cancellationToken)
+ {
+ var tasks = _services.Select(async i =>
+ {
+ try
+ {
+ var recs = await i.GetTimersAsync(cancellationToken).ConfigureAwait(false);
+ return recs.Select(r => new Tuple<TimerInfo, ILiveTvService>(r, i));
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error getting recordings");
+ return new List<Tuple<TimerInfo, ILiveTvService>>();
+ }
+ });
+ var results = await Task.WhenAll(tasks).ConfigureAwait(false);
+ var timers = results.SelectMany(i => i.ToList());
+
+ if (query.IsActive.HasValue)
+ {
+ if (query.IsActive.Value)
+ {
+ timers = timers.Where(i => i.Item1.Status == RecordingStatus.InProgress);
+ }
+ else
+ {
+ timers = timers.Where(i => i.Item1.Status != RecordingStatus.InProgress);
+ }
+ }
+
+ if (query.IsScheduled.HasValue)
+ {
+ if (query.IsScheduled.Value)
+ {
+ timers = timers.Where(i => i.Item1.Status == RecordingStatus.New);
+ }
+ else
+ {
+ timers = timers.Where(i => i.Item1.Status != RecordingStatus.New);
+ }
+ }
+
+ if (!string.IsNullOrEmpty(query.ChannelId))
+ {
+ var guid = new Guid(query.ChannelId);
+ timers = timers.Where(i => _tvDtoService.GetInternalChannelId(i.Item2.Name, i.Item1.ChannelId).Equals(guid));
+ }
+
+ if (!string.IsNullOrEmpty(query.SeriesTimerId))
+ {
+ var guid = new Guid(query.SeriesTimerId);
+
+ timers = timers
+ .Where(i => _tvDtoService.GetInternalSeriesTimerId(i.Item1.SeriesTimerId).Equals(guid));
+ }
+
+ if (!string.IsNullOrEmpty(query.Id))
+ {
+ timers = timers
+ .Where(i => string.Equals(_tvDtoService.GetInternalTimerId(i.Item1.Id), query.Id, StringComparison.OrdinalIgnoreCase));
+ }
+
+ var returnArray = timers
+ .Select(i => i.Item1)
+ .OrderBy(i => i.StartDate)
+ .ToArray();
+
+ return new QueryResult<TimerInfo>(returnArray);
+ }
+
+ public async Task<QueryResult<TimerInfoDto>> GetTimers(TimerQuery query, CancellationToken cancellationToken)
+ {
+ var tasks = _services.Select(async i =>
+ {
+ try
+ {
+ var recs = await i.GetTimersAsync(cancellationToken).ConfigureAwait(false);
+ return recs.Select(r => new Tuple<TimerInfo, ILiveTvService>(r, i));
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error getting recordings");
+ return new List<Tuple<TimerInfo, ILiveTvService>>();
+ }
+ });
+ var results = await Task.WhenAll(tasks).ConfigureAwait(false);
+ var timers = results.SelectMany(i => i.ToList());
+
+ if (query.IsActive.HasValue)
+ {
+ if (query.IsActive.Value)
+ {
+ timers = timers.Where(i => i.Item1.Status == RecordingStatus.InProgress);
+ }
+ else
+ {
+ timers = timers.Where(i => i.Item1.Status != RecordingStatus.InProgress);
+ }
+ }
+
+ if (query.IsScheduled.HasValue)
+ {
+ if (query.IsScheduled.Value)
+ {
+ timers = timers.Where(i => i.Item1.Status == RecordingStatus.New);
+ }
+ else
+ {
+ timers = timers.Where(i => i.Item1.Status != RecordingStatus.New);
+ }
+ }
+
+ if (!string.IsNullOrEmpty(query.ChannelId))
+ {
+ var guid = new Guid(query.ChannelId);
+ timers = timers.Where(i => _tvDtoService.GetInternalChannelId(i.Item2.Name, i.Item1.ChannelId).Equals(guid));
+ }
+
+ if (!string.IsNullOrEmpty(query.SeriesTimerId))
+ {
+ var guid = new Guid(query.SeriesTimerId);
+
+ timers = timers
+ .Where(i => _tvDtoService.GetInternalSeriesTimerId(i.Item1.SeriesTimerId).Equals(guid));
+ }
+
+ if (!string.IsNullOrEmpty(query.Id))
+ {
+ timers = timers
+ .Where(i => string.Equals(_tvDtoService.GetInternalTimerId(i.Item1.Id), query.Id, StringComparison.OrdinalIgnoreCase));
+ }
+
+ var returnList = new List<TimerInfoDto>();
+
+ foreach (var i in timers)
+ {
+ var program = string.IsNullOrEmpty(i.Item1.ProgramId) ?
+ null :
+ _libraryManager.GetItemById(_tvDtoService.GetInternalProgramId(i.Item1.ProgramId)) as LiveTvProgram;
+
+ var channel = string.IsNullOrEmpty(i.Item1.ChannelId) ? null : _libraryManager.GetItemById(_tvDtoService.GetInternalChannelId(i.Item2.Name, i.Item1.ChannelId));
+
+ returnList.Add(_tvDtoService.GetTimerInfoDto(i.Item1, i.Item2, program, channel));
+ }
+
+ var returnArray = returnList
+ .OrderBy(i => i.StartDate)
+ .ToArray();
+
+ return new QueryResult<TimerInfoDto>(returnArray);
+ }
+
+ public async Task CancelTimer(string id)
+ {
+ var timer = await GetTimer(id, CancellationToken.None).ConfigureAwait(false);
+
+ if (timer is null)
+ {
+ throw new ResourceNotFoundException(string.Format(CultureInfo.InvariantCulture, "Timer with Id {0} not found", id));
+ }
+
+ var service = GetService(timer.ServiceName);
+
+ await service.CancelTimerAsync(timer.ExternalId, CancellationToken.None).ConfigureAwait(false);
+
+ if (service is not EmbyTV.EmbyTV)
+ {
+ TimerCancelled?.Invoke(this, new GenericEventArgs<TimerEventInfo>(new TimerEventInfo(id)));
+ }
+ }
+
+ public async Task CancelSeriesTimer(string id)
+ {
+ var timer = await GetSeriesTimer(id, CancellationToken.None).ConfigureAwait(false);
+
+ if (timer is null)
+ {
+ throw new ResourceNotFoundException(string.Format(CultureInfo.InvariantCulture, "SeriesTimer with Id {0} not found", id));
+ }
+
+ var service = GetService(timer.ServiceName);
+
+ await service.CancelSeriesTimerAsync(timer.ExternalId, CancellationToken.None).ConfigureAwait(false);
+
+ SeriesTimerCancelled?.Invoke(this, new GenericEventArgs<TimerEventInfo>(new TimerEventInfo(id)));
+ }
+
+ public async Task<TimerInfoDto> GetTimer(string id, CancellationToken cancellationToken)
+ {
+ var results = await GetTimers(
+ new TimerQuery
+ {
+ Id = id
+ },
+ cancellationToken).ConfigureAwait(false);
+
+ return results.Items.FirstOrDefault(i => string.Equals(i.Id, id, StringComparison.OrdinalIgnoreCase));
+ }
+
+ public async Task<SeriesTimerInfoDto> GetSeriesTimer(string id, CancellationToken cancellationToken)
+ {
+ var results = await GetSeriesTimers(new SeriesTimerQuery(), cancellationToken).ConfigureAwait(false);
+
+ return results.Items.FirstOrDefault(i => string.Equals(i.Id, id, StringComparison.OrdinalIgnoreCase));
+ }
+
+ private async Task<QueryResult<SeriesTimerInfo>> GetSeriesTimersInternal(SeriesTimerQuery query, CancellationToken cancellationToken)
+ {
+ var tasks = _services.Select(async i =>
+ {
+ try
+ {
+ var recs = await i.GetSeriesTimersAsync(cancellationToken).ConfigureAwait(false);
+ return recs.Select(r =>
+ {
+ r.ServiceName = i.Name;
+ return new Tuple<SeriesTimerInfo, ILiveTvService>(r, i);
+ });
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error getting recordings");
+ return new List<Tuple<SeriesTimerInfo, ILiveTvService>>();
+ }
+ });
+ var results = await Task.WhenAll(tasks).ConfigureAwait(false);
+ var timers = results.SelectMany(i => i.ToList());
+
+ if (string.Equals(query.SortBy, "Priority", StringComparison.OrdinalIgnoreCase))
+ {
+ timers = query.SortOrder == SortOrder.Descending ?
+ timers.OrderBy(i => i.Item1.Priority).ThenByStringDescending(i => i.Item1.Name) :
+ timers.OrderByDescending(i => i.Item1.Priority).ThenByString(i => i.Item1.Name);
+ }
+ else
+ {
+ timers = query.SortOrder == SortOrder.Descending ?
+ timers.OrderByStringDescending(i => i.Item1.Name) :
+ timers.OrderByString(i => i.Item1.Name);
+ }
+
+ var returnArray = timers
+ .Select(i => i.Item1)
+ .ToArray();
+
+ return new QueryResult<SeriesTimerInfo>(returnArray);
+ }
+
+ public async Task<QueryResult<SeriesTimerInfoDto>> GetSeriesTimers(SeriesTimerQuery query, CancellationToken cancellationToken)
+ {
+ var tasks = _services.Select(async i =>
+ {
+ try
+ {
+ var recs = await i.GetSeriesTimersAsync(cancellationToken).ConfigureAwait(false);
+ return recs.Select(r => new Tuple<SeriesTimerInfo, ILiveTvService>(r, i));
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error getting recordings");
+ return new List<Tuple<SeriesTimerInfo, ILiveTvService>>();
+ }
+ });
+ var results = await Task.WhenAll(tasks).ConfigureAwait(false);
+ var timers = results.SelectMany(i => i.ToList());
+
+ if (string.Equals(query.SortBy, "Priority", StringComparison.OrdinalIgnoreCase))
+ {
+ timers = query.SortOrder == SortOrder.Descending ?
+ timers.OrderBy(i => i.Item1.Priority).ThenByStringDescending(i => i.Item1.Name) :
+ timers.OrderByDescending(i => i.Item1.Priority).ThenByString(i => i.Item1.Name);
+ }
+ else
+ {
+ timers = query.SortOrder == SortOrder.Descending ?
+ timers.OrderByStringDescending(i => i.Item1.Name) :
+ timers.OrderByString(i => i.Item1.Name);
+ }
+
+ var returnArray = timers
+ .Select(i =>
+ {
+ string channelName = null;
+
+ if (!string.IsNullOrEmpty(i.Item1.ChannelId))
+ {
+ var internalChannelId = _tvDtoService.GetInternalChannelId(i.Item2.Name, i.Item1.ChannelId);
+ var channel = _libraryManager.GetItemById(internalChannelId);
+ channelName = channel is null ? null : channel.Name;
+ }
+
+ return _tvDtoService.GetSeriesTimerInfoDto(i.Item1, i.Item2, channelName);
+ })
+ .ToArray();
+
+ return new QueryResult<SeriesTimerInfoDto>(returnArray);
+ }
+
+ public BaseItem GetLiveTvChannel(TimerInfo timer, ILiveTvService service)
+ {
+ var internalChannelId = _tvDtoService.GetInternalChannelId(service.Name, timer.ChannelId);
+ return _libraryManager.GetItemById(internalChannelId);
+ }
+
+ public void AddChannelInfo(IReadOnlyCollection<(BaseItemDto ItemDto, LiveTvChannel Channel)> items, DtoOptions options, User user)
+ {
+ var now = DateTime.UtcNow;
+
+ var channelIds = items.Select(i => i.Channel.Id).Distinct().ToArray();
+
+ var programs = options.AddCurrentProgram ? _libraryManager.GetItemList(new InternalItemsQuery(user)
+ {
+ IncludeItemTypes = new[] { BaseItemKind.LiveTvProgram },
+ ChannelIds = channelIds,
+ MaxStartDate = now,
+ MinEndDate = now,
+ Limit = channelIds.Length,
+ OrderBy = new[] { (ItemSortBy.StartDate, SortOrder.Ascending) },
+ TopParentIds = new[] { GetInternalLiveTvFolder(CancellationToken.None).Id },
+ DtoOptions = options
+ }) : new List<BaseItem>();
+
+ RemoveFields(options);
+
+ var currentProgramsList = new List<BaseItem>();
+ var currentChannelsDict = new Dictionary<Guid, BaseItemDto>();
+
+ var addCurrentProgram = options.AddCurrentProgram;
+
+ foreach (var (dto, channel) in items)
+ {
+ dto.Number = channel.Number;
+ dto.ChannelNumber = channel.Number;
+ dto.ChannelType = channel.ChannelType;
+
+ currentChannelsDict[dto.Id] = dto;
+
+ if (addCurrentProgram)
+ {
+ var currentProgram = programs.FirstOrDefault(i => channel.Id.Equals(i.ChannelId));
+
+ if (currentProgram is not null)
+ {
+ currentProgramsList.Add(currentProgram);
+ }
+ }
+ }
+
+ if (addCurrentProgram)
+ {
+ var currentProgramDtos = _dtoService.GetBaseItemDtos(currentProgramsList, options, user);
+
+ foreach (var programDto in currentProgramDtos)
+ {
+ if (programDto.ChannelId.HasValue && currentChannelsDict.TryGetValue(programDto.ChannelId.Value, out BaseItemDto channelDto))
+ {
+ channelDto.CurrentProgram = programDto;
+ }
+ }
+ }
+ }
+
+ private async Task<Tuple<SeriesTimerInfo, ILiveTvService>> GetNewTimerDefaultsInternal(CancellationToken cancellationToken, LiveTvProgram program = null)
+ {
+ ILiveTvService service = null;
+ ProgramInfo programInfo = null;
+
+ if (program is not null)
+ {
+ service = GetService(program);
+
+ var channel = _libraryManager.GetItemById(program.ChannelId);
+
+ programInfo = new ProgramInfo
+ {
+ Audio = program.Audio,
+ ChannelId = channel.ExternalId,
+ CommunityRating = program.CommunityRating,
+ EndDate = program.EndDate ?? DateTime.MinValue,
+ EpisodeTitle = program.EpisodeTitle,
+ Genres = program.Genres.ToList(),
+ Id = program.ExternalId,
+ IsHD = program.IsHD,
+ IsKids = program.IsKids,
+ IsLive = program.IsLive,
+ IsMovie = program.IsMovie,
+ IsNews = program.IsNews,
+ IsPremiere = program.IsPremiere,
+ IsRepeat = program.IsRepeat,
+ IsSeries = program.IsSeries,
+ IsSports = program.IsSports,
+ OriginalAirDate = program.PremiereDate,
+ Overview = program.Overview,
+ StartDate = program.StartDate,
+ // ImagePath = program.ExternalImagePath,
+ Name = program.Name,
+ OfficialRating = program.OfficialRating
+ };
+ }
+
+ service ??= _services[0];
+
+ var info = await service.GetNewTimerDefaultsAsync(cancellationToken, programInfo).ConfigureAwait(false);
+
+ info.RecordAnyTime = true;
+ info.Days = new List<DayOfWeek>
+ {
+ DayOfWeek.Sunday,
+ DayOfWeek.Monday,
+ DayOfWeek.Tuesday,
+ DayOfWeek.Wednesday,
+ DayOfWeek.Thursday,
+ DayOfWeek.Friday,
+ DayOfWeek.Saturday
+ };
+
+ info.Id = null;
+
+ return new Tuple<SeriesTimerInfo, ILiveTvService>(info, service);
+ }
+
+ public async Task<SeriesTimerInfoDto> GetNewTimerDefaults(CancellationToken cancellationToken)
+ {
+ var info = await GetNewTimerDefaultsInternal(cancellationToken).ConfigureAwait(false);
+
+ return _tvDtoService.GetSeriesTimerInfoDto(info.Item1, info.Item2, null);
+ }
+
+ public async Task<SeriesTimerInfoDto> GetNewTimerDefaults(string programId, CancellationToken cancellationToken)
+ {
+ var program = (LiveTvProgram)_libraryManager.GetItemById(programId);
+ var programDto = await GetProgram(programId, cancellationToken).ConfigureAwait(false);
+
+ var defaults = await GetNewTimerDefaultsInternal(cancellationToken, program).ConfigureAwait(false);
+ var info = _tvDtoService.GetSeriesTimerInfoDto(defaults.Item1, defaults.Item2, null);
+
+ info.Days = defaults.Item1.Days.ToArray();
+
+ info.DayPattern = _tvDtoService.GetDayPattern(info.Days);
+
+ info.Name = program.Name;
+ info.ChannelId = programDto.ChannelId ?? Guid.Empty;
+ info.ChannelName = programDto.ChannelName;
+ info.StartDate = program.StartDate;
+ info.Name = program.Name;
+ info.Overview = program.Overview;
+ info.ProgramId = programDto.Id.ToString("N", CultureInfo.InvariantCulture);
+ info.ExternalProgramId = program.ExternalId;
+
+ if (program.EndDate.HasValue)
+ {
+ info.EndDate = program.EndDate.Value;
+ }
+
+ return info;
+ }
+
+ public async Task CreateTimer(TimerInfoDto timer, CancellationToken cancellationToken)
+ {
+ var service = GetService(timer.ServiceName);
+
+ var info = await _tvDtoService.GetTimerInfo(timer, true, this, cancellationToken).ConfigureAwait(false);
+
+ // Set priority from default values
+ var defaultValues = await service.GetNewTimerDefaultsAsync(cancellationToken).ConfigureAwait(false);
+ info.Priority = defaultValues.Priority;
+
+ string newTimerId = null;
+ if (service is ISupportsNewTimerIds supportsNewTimerIds)
+ {
+ newTimerId = await supportsNewTimerIds.CreateTimer(info, cancellationToken).ConfigureAwait(false);
+ newTimerId = _tvDtoService.GetInternalTimerId(newTimerId);
+ }
+ else
+ {
+ await service.CreateTimerAsync(info, cancellationToken).ConfigureAwait(false);
+ }
+
+ _logger.LogInformation("New recording scheduled");
+
+ if (service is not EmbyTV.EmbyTV)
+ {
+ TimerCreated?.Invoke(this, new GenericEventArgs<TimerEventInfo>(
+ new TimerEventInfo(newTimerId)
+ {
+ ProgramId = _tvDtoService.GetInternalProgramId(info.ProgramId)
+ }));
+ }
+ }
+
+ public async Task CreateSeriesTimer(SeriesTimerInfoDto timer, CancellationToken cancellationToken)
+ {
+ var service = GetService(timer.ServiceName);
+
+ var info = await _tvDtoService.GetSeriesTimerInfo(timer, true, this, cancellationToken).ConfigureAwait(false);
+
+ // Set priority from default values
+ var defaultValues = await service.GetNewTimerDefaultsAsync(cancellationToken).ConfigureAwait(false);
+ info.Priority = defaultValues.Priority;
+
+ string newTimerId = null;
+ if (service is ISupportsNewTimerIds supportsNewTimerIds)
+ {
+ newTimerId = await supportsNewTimerIds.CreateSeriesTimer(info, cancellationToken).ConfigureAwait(false);
+ newTimerId = _tvDtoService.GetInternalSeriesTimerId(newTimerId).ToString("N", CultureInfo.InvariantCulture);
+ }
+ else
+ {
+ await service.CreateSeriesTimerAsync(info, cancellationToken).ConfigureAwait(false);
+ }
+
+ SeriesTimerCreated?.Invoke(this, new GenericEventArgs<TimerEventInfo>(
+ new TimerEventInfo(newTimerId)
+ {
+ ProgramId = _tvDtoService.GetInternalProgramId(info.ProgramId)
+ }));
+ }
+
+ public async Task UpdateTimer(TimerInfoDto timer, CancellationToken cancellationToken)
+ {
+ var info = await _tvDtoService.GetTimerInfo(timer, false, this, cancellationToken).ConfigureAwait(false);
+
+ var service = GetService(timer.ServiceName);
+
+ await service.UpdateTimerAsync(info, cancellationToken).ConfigureAwait(false);
+ }
+
+ public async Task UpdateSeriesTimer(SeriesTimerInfoDto timer, CancellationToken cancellationToken)
+ {
+ var info = await _tvDtoService.GetSeriesTimerInfo(timer, false, this, cancellationToken).ConfigureAwait(false);
+
+ var service = GetService(timer.ServiceName);
+
+ await service.UpdateSeriesTimerAsync(info, cancellationToken).ConfigureAwait(false);
+ }
+
+ public GuideInfo GetGuideInfo()
+ {
+ var startDate = DateTime.UtcNow;
+ var endDate = startDate.AddDays(GetGuideDays());
+
+ return new GuideInfo
+ {
+ StartDate = startDate,
+ EndDate = endDate
+ };
+ }
+
+ private LiveTvServiceInfo[] GetServiceInfos()
+ {
+ return Services.Select(GetServiceInfo).ToArray();
+ }
+
+ private static LiveTvServiceInfo GetServiceInfo(ILiveTvService service)
+ {
+ return new LiveTvServiceInfo
+ {
+ Name = service.Name
+ };
+ }
+
+ public LiveTvInfo GetLiveTvInfo(CancellationToken cancellationToken)
+ {
+ var services = GetServiceInfos();
+
+ var info = new LiveTvInfo
+ {
+ Services = services,
+ IsEnabled = services.Length > 0,
+ EnabledUsers = _userManager.Users
+ .Where(IsLiveTvEnabled)
+ .Select(i => i.Id.ToString("N", CultureInfo.InvariantCulture))
+ .ToArray()
+ };
+
+ return info;
+ }
+
+ private bool IsLiveTvEnabled(User user)
+ {
+ return user.HasPermission(PermissionKind.EnableLiveTvAccess) && (Services.Count > 1 || GetConfiguration().TunerHosts.Length > 0);
+ }
+
+ public IEnumerable<User> GetEnabledUsers()
+ {
+ return _userManager.Users
+ .Where(IsLiveTvEnabled);
+ }
+
+ /// <summary>
+ /// Resets the tuner.
+ /// </summary>
+ /// <param name="id">The identifier.</param>
+ /// <param name="cancellationToken">The cancellation token.</param>
+ /// <returns>Task.</returns>
+ public Task ResetTuner(string id, CancellationToken cancellationToken)
+ {
+ var parts = id.Split('_', 2);
+
+ var service = _services.FirstOrDefault(i => string.Equals(i.GetType().FullName.GetMD5().ToString("N", CultureInfo.InvariantCulture), parts[0], StringComparison.OrdinalIgnoreCase));
+
+ if (service is null)
+ {
+ throw new ArgumentException("Service not found.");
+ }
+
+ return service.ResetTuner(parts[1], cancellationToken);
+ }
+
+ private static void RemoveFields(DtoOptions options)
+ {
+ var fields = options.Fields.ToList();
+
+ fields.Remove(ItemFields.CanDelete);
+ fields.Remove(ItemFields.CanDownload);
+ fields.Remove(ItemFields.DisplayPreferencesId);
+ fields.Remove(ItemFields.Etag);
+ options.Fields = fields.ToArray();
+ }
+
+ public Folder GetInternalLiveTvFolder(CancellationToken cancellationToken)
+ {
+ var name = _localization.GetLocalizedString("HeaderLiveTV");
+ return _libraryManager.GetNamedView(name, CollectionType.livetv, name);
+ }
+
+ public async Task<TunerHostInfo> SaveTunerHost(TunerHostInfo info, bool dataSourceChanged = true)
+ {
+ info = JsonSerializer.Deserialize<TunerHostInfo>(JsonSerializer.SerializeToUtf8Bytes(info));
+
+ var provider = _tunerHosts.FirstOrDefault(i => string.Equals(info.Type, i.Type, StringComparison.OrdinalIgnoreCase));
+
+ if (provider is null)
+ {
+ throw new ResourceNotFoundException();
+ }
+
+ if (provider is IConfigurableTunerHost configurable)
+ {
+ await configurable.Validate(info).ConfigureAwait(false);
+ }
+
+ var config = GetConfiguration();
+
+ var list = config.TunerHosts.ToList();
+ var index = list.FindIndex(i => string.Equals(i.Id, info.Id, StringComparison.OrdinalIgnoreCase));
+
+ if (index == -1 || string.IsNullOrWhiteSpace(info.Id))
+ {
+ info.Id = Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture);
+ list.Add(info);
+ config.TunerHosts = list.ToArray();
+ }
+ else
+ {
+ config.TunerHosts[index] = info;
+ }
+
+ _config.SaveConfiguration("livetv", config);
+
+ if (dataSourceChanged)
+ {
+ _taskManager.CancelIfRunningAndQueue<RefreshGuideScheduledTask>();
+ }
+
+ return info;
+ }
+
+ public async Task<ListingsProviderInfo> SaveListingProvider(ListingsProviderInfo info, bool validateLogin, bool validateListings)
+ {
+ // Hack to make the object a pure ListingsProviderInfo instead of an AddListingProvider
+ // ServerConfiguration.SaveConfiguration crashes during xml serialization for AddListingProvider
+ info = JsonSerializer.Deserialize<ListingsProviderInfo>(JsonSerializer.SerializeToUtf8Bytes(info));
+
+ var provider = _listingProviders.FirstOrDefault(i => string.Equals(info.Type, i.Type, StringComparison.OrdinalIgnoreCase));
+
+ if (provider is null)
+ {
+ throw new ResourceNotFoundException(
+ string.Format(
+ CultureInfo.InvariantCulture,
+ "Couldn't find provider of type: '{0}'",
+ info.Type));
+ }
+
+ await provider.Validate(info, validateLogin, validateListings).ConfigureAwait(false);
+
+ LiveTvOptions config = GetConfiguration();
+
+ var list = config.ListingProviders.ToList();
+ int index = list.FindIndex(i => string.Equals(i.Id, info.Id, StringComparison.OrdinalIgnoreCase));
+
+ if (index == -1 || string.IsNullOrWhiteSpace(info.Id))
+ {
+ info.Id = Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture);
+ list.Add(info);
+ config.ListingProviders = list.ToArray();
+ }
+ else
+ {
+ config.ListingProviders[index] = info;
+ }
+
+ _config.SaveConfiguration("livetv", config);
+
+ _taskManager.CancelIfRunningAndQueue<RefreshGuideScheduledTask>();
+
+ return info;
+ }
+
+ public void DeleteListingsProvider(string id)
+ {
+ var config = GetConfiguration();
+
+ config.ListingProviders = config.ListingProviders.Where(i => !string.Equals(id, i.Id, StringComparison.OrdinalIgnoreCase)).ToArray();
+
+ _config.SaveConfiguration("livetv", config);
+ _taskManager.CancelIfRunningAndQueue<RefreshGuideScheduledTask>();
+ }
+
+ public async Task<TunerChannelMapping> SetChannelMapping(string providerId, string tunerChannelNumber, string providerChannelNumber)
+ {
+ var config = GetConfiguration();
+
+ var listingsProviderInfo = config.ListingProviders.First(i => string.Equals(providerId, i.Id, StringComparison.OrdinalIgnoreCase));
+ listingsProviderInfo.ChannelMappings = listingsProviderInfo.ChannelMappings.Where(i => !string.Equals(i.Name, tunerChannelNumber, StringComparison.OrdinalIgnoreCase)).ToArray();
+
+ if (!string.Equals(tunerChannelNumber, providerChannelNumber, StringComparison.OrdinalIgnoreCase))
+ {
+ var list = listingsProviderInfo.ChannelMappings.ToList();
+ list.Add(new NameValuePair
+ {
+ Name = tunerChannelNumber,
+ Value = providerChannelNumber
+ });
+ listingsProviderInfo.ChannelMappings = list.ToArray();
+ }
+
+ _config.SaveConfiguration("livetv", config);
+
+ var tunerChannels = await GetChannelsForListingsProvider(providerId, CancellationToken.None)
+ .ConfigureAwait(false);
+
+ var providerChannels = await GetChannelsFromListingsProviderData(providerId, CancellationToken.None)
+ .ConfigureAwait(false);
+
+ var mappings = listingsProviderInfo.ChannelMappings;
+
+ var tunerChannelMappings =
+ tunerChannels.Select(i => GetTunerChannelMapping(i, mappings, providerChannels)).ToList();
+
+ _taskManager.CancelIfRunningAndQueue<RefreshGuideScheduledTask>();
+
+ return tunerChannelMappings.First(i => string.Equals(i.Id, tunerChannelNumber, StringComparison.OrdinalIgnoreCase));
+ }
+
+ public TunerChannelMapping GetTunerChannelMapping(ChannelInfo tunerChannel, NameValuePair[] mappings, List<ChannelInfo> providerChannels)
+ {
+ var result = new TunerChannelMapping
+ {
+ Name = tunerChannel.Name,
+ Id = tunerChannel.Id
+ };
+
+ if (!string.IsNullOrWhiteSpace(tunerChannel.Number))
+ {
+ result.Name = tunerChannel.Number + " " + result.Name;
+ }
+
+ var providerChannel = EmbyTV.EmbyTV.Current.GetEpgChannelFromTunerChannel(mappings, tunerChannel, providerChannels);
+
+ if (providerChannel is not null)
+ {
+ result.ProviderChannelName = providerChannel.Name;
+ result.ProviderChannelId = providerChannel.Id;
+ }
+
+ return result;
+ }
+
+ public Task<List<NameIdPair>> GetLineups(string providerType, string providerId, string country, string location)
+ {
+ var config = GetConfiguration();
+
+ if (string.IsNullOrWhiteSpace(providerId))
+ {
+ var provider = _listingProviders.FirstOrDefault(i => string.Equals(providerType, i.Type, StringComparison.OrdinalIgnoreCase));
+
+ if (provider is null)
+ {
+ throw new ResourceNotFoundException();
+ }
+
+ return provider.GetLineups(null, country, location);
+ }
+ else
+ {
+ var info = config.ListingProviders.FirstOrDefault(i => string.Equals(i.Id, providerId, StringComparison.OrdinalIgnoreCase));
+
+ var provider = _listingProviders.FirstOrDefault(i => string.Equals(info.Type, i.Type, StringComparison.OrdinalIgnoreCase));
+
+ if (provider is null)
+ {
+ throw new ResourceNotFoundException();
+ }
+
+ return provider.GetLineups(info, country, location);
+ }
+ }
+
+ public Task<List<ChannelInfo>> GetChannelsForListingsProvider(string id, CancellationToken cancellationToken)
+ {
+ var info = GetConfiguration().ListingProviders.First(i => string.Equals(i.Id, id, StringComparison.OrdinalIgnoreCase));
+ return EmbyTV.EmbyTV.Current.GetChannelsForListingsProvider(info, cancellationToken);
+ }
+
+ public Task<List<ChannelInfo>> GetChannelsFromListingsProviderData(string id, CancellationToken cancellationToken)
+ {
+ var info = GetConfiguration().ListingProviders.First(i => string.Equals(i.Id, id, StringComparison.OrdinalIgnoreCase));
+ var provider = _listingProviders.First(i => string.Equals(i.Type, info.Type, StringComparison.OrdinalIgnoreCase));
+ return provider.GetChannels(info, cancellationToken);
+ }
+
+ public Guid GetInternalChannelId(string serviceName, string externalId)
+ {
+ return _tvDtoService.GetInternalChannelId(serviceName, externalId);
+ }
+
+ public Guid GetInternalProgramId(string externalId)
+ {
+ return _tvDtoService.GetInternalProgramId(externalId);
+ }
+
+ /// <inheritdoc />
+ public Task<BaseItem[]> GetRecordingFoldersAsync(User user)
+ => GetRecordingFoldersAsync(user, false);
+
+ private async Task<BaseItem[]> GetRecordingFoldersAsync(User user, bool refreshChannels)
+ {
+ var folders = EmbyTV.EmbyTV.Current.GetRecordingFolders()
+ .SelectMany(i => i.Locations)
+ .Distinct(StringComparer.OrdinalIgnoreCase)
+ .Select(i => _libraryManager.FindByPath(i, true))
+ .Where(i => i is not null && i.IsVisibleStandalone(user))
+ .SelectMany(i => _libraryManager.GetCollectionFolders(i))
+ .DistinctBy(x => x.Id)
+ .OrderBy(i => i.SortName)
+ .ToList();
+
+ var channels = await _channelManager.GetChannelsInternalAsync(new MediaBrowser.Model.Channels.ChannelQuery
+ {
+ UserId = user.Id,
+ IsRecordingsFolder = true,
+ RefreshLatestChannelItems = refreshChannels
+ }).ConfigureAwait(false);
+
+ folders.AddRange(channels.Items);
+
+ return folders.Cast<BaseItem>().ToArray();
+ }
+ }
+}
diff --git a/src/Jellyfin.LiveTv/LiveTvMediaSourceProvider.cs b/src/Jellyfin.LiveTv/LiveTvMediaSourceProvider.cs
new file mode 100644
index 000000000..ce9361089
--- /dev/null
+++ b/src/Jellyfin.LiveTv/LiveTvMediaSourceProvider.cs
@@ -0,0 +1,128 @@
+#nullable disable
+
+#pragma warning disable CS1591
+
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using MediaBrowser.Controller;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.LiveTv;
+using MediaBrowser.Model.Dto;
+using MediaBrowser.Model.MediaInfo;
+using Microsoft.Extensions.Logging;
+
+namespace Jellyfin.LiveTv
+{
+ public class LiveTvMediaSourceProvider : IMediaSourceProvider
+ {
+ // Do not use a pipe here because Roku http requests to the server will fail, without any explicit error message.
+ private const char StreamIdDelimiter = '_';
+
+ private readonly ILiveTvManager _liveTvManager;
+ private readonly ILogger<LiveTvMediaSourceProvider> _logger;
+ private readonly IMediaSourceManager _mediaSourceManager;
+ private readonly IServerApplicationHost _appHost;
+
+ public LiveTvMediaSourceProvider(ILiveTvManager liveTvManager, ILogger<LiveTvMediaSourceProvider> logger, IMediaSourceManager mediaSourceManager, IServerApplicationHost appHost)
+ {
+ _liveTvManager = liveTvManager;
+ _logger = logger;
+ _mediaSourceManager = mediaSourceManager;
+ _appHost = appHost;
+ }
+
+ public Task<IEnumerable<MediaSourceInfo>> GetMediaSources(BaseItem item, CancellationToken cancellationToken)
+ {
+ if (item.SourceType == SourceType.LiveTV)
+ {
+ var activeRecordingInfo = _liveTvManager.GetActiveRecordingInfo(item.Path);
+
+ if (string.IsNullOrEmpty(item.Path) || activeRecordingInfo is not null)
+ {
+ return GetMediaSourcesInternal(item, activeRecordingInfo, cancellationToken);
+ }
+ }
+
+ return Task.FromResult(Enumerable.Empty<MediaSourceInfo>());
+ }
+
+ private async Task<IEnumerable<MediaSourceInfo>> GetMediaSourcesInternal(BaseItem item, ActiveRecordingInfo activeRecordingInfo, CancellationToken cancellationToken)
+ {
+ IEnumerable<MediaSourceInfo> sources;
+
+ var forceRequireOpening = false;
+
+ try
+ {
+ if (activeRecordingInfo is not null)
+ {
+ sources = await _mediaSourceManager.GetRecordingStreamMediaSources(activeRecordingInfo, cancellationToken)
+ .ConfigureAwait(false);
+ }
+ else
+ {
+ sources = await _liveTvManager.GetChannelMediaSources(item, cancellationToken)
+ .ConfigureAwait(false);
+ }
+ }
+ catch (NotImplementedException)
+ {
+ sources = _mediaSourceManager.GetStaticMediaSources(item, false);
+
+ forceRequireOpening = true;
+ }
+
+ var list = sources.ToList();
+
+ foreach (var source in list)
+ {
+ source.Type = MediaSourceType.Default;
+ source.BufferMs ??= 1500;
+
+ if (source.RequiresOpening || forceRequireOpening)
+ {
+ source.RequiresOpening = true;
+ }
+
+ if (source.RequiresOpening)
+ {
+ var openKeys = new List<string>
+ {
+ item.GetType().Name,
+ item.Id.ToString("N", CultureInfo.InvariantCulture),
+ source.Id ?? string.Empty
+ };
+
+ source.OpenToken = string.Join(StreamIdDelimiter, openKeys);
+ }
+
+ // Dummy this up so that direct play checks can still run
+ if (string.IsNullOrEmpty(source.Path) && source.Protocol == MediaProtocol.Http)
+ {
+ source.Path = _appHost.GetApiUrlForLocalAccess();
+ }
+ }
+
+ _logger.LogDebug("MediaSources: {@MediaSources}", list);
+
+ return list;
+ }
+
+ /// <inheritdoc />
+ public async Task<ILiveStream> OpenMediaSource(string openToken, List<ILiveStream> currentLiveStreams, CancellationToken cancellationToken)
+ {
+ var keys = openToken.Split(StreamIdDelimiter, 3);
+ var mediaSourceId = keys.Length >= 3 ? keys[2] : null;
+
+ var info = await _liveTvManager.GetChannelStream(keys[1], mediaSourceId, currentLiveStreams, cancellationToken).ConfigureAwait(false);
+ var liveStream = info.Item2;
+
+ return liveStream;
+ }
+ }
+}
diff --git a/src/Jellyfin.LiveTv/RefreshGuideScheduledTask.cs b/src/Jellyfin.LiveTv/RefreshGuideScheduledTask.cs
new file mode 100644
index 000000000..e58296a70
--- /dev/null
+++ b/src/Jellyfin.LiveTv/RefreshGuideScheduledTask.cs
@@ -0,0 +1,75 @@
+using System;
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+using MediaBrowser.Common.Configuration;
+using MediaBrowser.Controller.LiveTv;
+using MediaBrowser.Model.LiveTv;
+using MediaBrowser.Model.Tasks;
+
+namespace Jellyfin.LiveTv
+{
+ /// <summary>
+ /// The "Refresh Guide" scheduled task.
+ /// </summary>
+ public class RefreshGuideScheduledTask : IScheduledTask, IConfigurableScheduledTask
+ {
+ private readonly ILiveTvManager _liveTvManager;
+ private readonly IConfigurationManager _config;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="RefreshGuideScheduledTask"/> class.
+ /// </summary>
+ /// <param name="liveTvManager">The live tv manager.</param>
+ /// <param name="config">The configuration manager.</param>
+ public RefreshGuideScheduledTask(ILiveTvManager liveTvManager, IConfigurationManager config)
+ {
+ _liveTvManager = liveTvManager;
+ _config = config;
+ }
+
+ /// <inheritdoc />
+ public string Name => "Refresh Guide";
+
+ /// <inheritdoc />
+ public string Description => "Downloads channel information from live tv services.";
+
+ /// <inheritdoc />
+ public string Category => "Live TV";
+
+ /// <inheritdoc />
+ public bool IsHidden => _liveTvManager.Services.Count == 1 && GetConfiguration().TunerHosts.Length == 0;
+
+ /// <inheritdoc />
+ public bool IsEnabled => true;
+
+ /// <inheritdoc />
+ public bool IsLogged => true;
+
+ /// <inheritdoc />
+ public string Key => "RefreshGuide";
+
+ /// <inheritdoc />
+ public Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken)
+ {
+ var manager = (LiveTvManager)_liveTvManager;
+
+ return manager.RefreshChannels(progress, cancellationToken);
+ }
+
+ /// <inheritdoc />
+ public IEnumerable<TaskTriggerInfo> GetDefaultTriggers()
+ {
+ return new[]
+ {
+ // Every so often
+ new TaskTriggerInfo { Type = TaskTriggerInfo.TriggerInterval, IntervalTicks = TimeSpan.FromHours(24).Ticks }
+ };
+ }
+
+ private LiveTvOptions GetConfiguration()
+ {
+ return _config.GetConfiguration<LiveTvOptions>("livetv");
+ }
+ }
+}
diff --git a/src/Jellyfin.LiveTv/TunerHosts/BaseTunerHost.cs b/src/Jellyfin.LiveTv/TunerHosts/BaseTunerHost.cs
new file mode 100644
index 000000000..769f196bd
--- /dev/null
+++ b/src/Jellyfin.LiveTv/TunerHosts/BaseTunerHost.cs
@@ -0,0 +1,237 @@
+#nullable disable
+
+#pragma warning disable CS1591
+
+using System;
+using System.Collections.Concurrent;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Text.Json;
+using System.Threading;
+using System.Threading.Tasks;
+using MediaBrowser.Common.Configuration;
+using MediaBrowser.Controller.Configuration;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.LiveTv;
+using MediaBrowser.Model.Dto;
+using MediaBrowser.Model.IO;
+using MediaBrowser.Model.LiveTv;
+using Microsoft.Extensions.Logging;
+
+namespace Jellyfin.LiveTv.TunerHosts
+{
+ public abstract class BaseTunerHost
+ {
+ private readonly ConcurrentDictionary<string, List<ChannelInfo>> _cache;
+
+ protected BaseTunerHost(IServerConfigurationManager config, ILogger<BaseTunerHost> logger, IFileSystem fileSystem)
+ {
+ Config = config;
+ Logger = logger;
+ FileSystem = fileSystem;
+ _cache = new ConcurrentDictionary<string, List<ChannelInfo>>();
+ }
+
+ protected IServerConfigurationManager Config { get; }
+
+ protected ILogger<BaseTunerHost> Logger { get; }
+
+ protected IFileSystem FileSystem { get; }
+
+ public virtual bool IsSupported => true;
+
+ public abstract string Type { get; }
+
+ protected virtual string ChannelIdPrefix => Type + "_";
+
+ protected abstract Task<List<ChannelInfo>> GetChannelsInternal(TunerHostInfo tuner, CancellationToken cancellationToken);
+
+ public async Task<List<ChannelInfo>> GetChannels(TunerHostInfo tuner, bool enableCache, CancellationToken cancellationToken)
+ {
+ var key = tuner.Id;
+
+ if (enableCache && !string.IsNullOrEmpty(key) && _cache.TryGetValue(key, out List<ChannelInfo> cache))
+ {
+ return cache;
+ }
+
+ var list = await GetChannelsInternal(tuner, cancellationToken).ConfigureAwait(false);
+ // logger.LogInformation("Channels from {0}: {1}", tuner.Url, JsonSerializer.SerializeToString(list));
+
+ if (!string.IsNullOrEmpty(key) && list.Count > 0)
+ {
+ _cache[key] = list;
+ }
+
+ return list;
+ }
+
+ protected virtual IList<TunerHostInfo> GetTunerHosts()
+ {
+ return GetConfiguration().TunerHosts
+ .Where(i => string.Equals(i.Type, Type, StringComparison.OrdinalIgnoreCase))
+ .ToList();
+ }
+
+ public async Task<List<ChannelInfo>> GetChannels(bool enableCache, CancellationToken cancellationToken)
+ {
+ var list = new List<ChannelInfo>();
+
+ var hosts = GetTunerHosts();
+
+ foreach (var host in hosts)
+ {
+ var channelCacheFile = Path.Combine(Config.ApplicationPaths.CachePath, host.Id + "_channels");
+
+ try
+ {
+ var channels = await GetChannels(host, enableCache, cancellationToken).ConfigureAwait(false);
+ var newChannels = channels.Where(i => !list.Any(l => string.Equals(i.Id, l.Id, StringComparison.OrdinalIgnoreCase))).ToList();
+
+ list.AddRange(newChannels);
+
+ if (!enableCache)
+ {
+ try
+ {
+ Directory.CreateDirectory(Path.GetDirectoryName(channelCacheFile));
+ var writeStream = AsyncFile.OpenWrite(channelCacheFile);
+ await using (writeStream.ConfigureAwait(false))
+ {
+ await JsonSerializer.SerializeAsync(writeStream, channels, cancellationToken: cancellationToken).ConfigureAwait(false);
+ }
+ }
+ catch (IOException)
+ {
+ }
+ }
+ }
+ catch (Exception ex)
+ {
+ Logger.LogError(ex, "Error getting channel list");
+
+ if (enableCache)
+ {
+ try
+ {
+ var readStream = AsyncFile.OpenRead(channelCacheFile);
+ await using (readStream.ConfigureAwait(false))
+ {
+ var channels = await JsonSerializer
+ .DeserializeAsync<List<ChannelInfo>>(readStream, cancellationToken: cancellationToken)
+ .ConfigureAwait(false);
+ list.AddRange(channels);
+ }
+ }
+ catch (IOException)
+ {
+ }
+ }
+ }
+ }
+
+ return list;
+ }
+
+ protected abstract Task<List<MediaSourceInfo>> GetChannelStreamMediaSources(TunerHostInfo tuner, ChannelInfo channel, CancellationToken cancellationToken);
+
+ public async Task<List<MediaSourceInfo>> GetChannelStreamMediaSources(string channelId, CancellationToken cancellationToken)
+ {
+ ArgumentException.ThrowIfNullOrEmpty(channelId);
+
+ if (IsValidChannelId(channelId))
+ {
+ var hosts = GetTunerHosts();
+
+ foreach (var host in hosts)
+ {
+ try
+ {
+ var channels = await GetChannels(host, true, cancellationToken).ConfigureAwait(false);
+ var channelInfo = channels.FirstOrDefault(i => string.Equals(i.Id, channelId, StringComparison.OrdinalIgnoreCase));
+
+ if (channelInfo is not null)
+ {
+ return await GetChannelStreamMediaSources(host, channelInfo, cancellationToken).ConfigureAwait(false);
+ }
+ }
+ catch (Exception ex)
+ {
+ Logger.LogError(ex, "Error getting channels");
+ }
+ }
+ }
+
+ return new List<MediaSourceInfo>();
+ }
+
+ protected abstract Task<ILiveStream> GetChannelStream(TunerHostInfo tunerHost, ChannelInfo channel, string streamId, IList<ILiveStream> currentLiveStreams, CancellationToken cancellationToken);
+
+ public async Task<ILiveStream> GetChannelStream(string channelId, string streamId, IList<ILiveStream> currentLiveStreams, CancellationToken cancellationToken)
+ {
+ ArgumentException.ThrowIfNullOrEmpty(channelId);
+
+ if (!IsValidChannelId(channelId))
+ {
+ throw new FileNotFoundException();
+ }
+
+ var hosts = GetTunerHosts();
+
+ var hostsWithChannel = new List<Tuple<TunerHostInfo, ChannelInfo>>();
+
+ foreach (var host in hosts)
+ {
+ try
+ {
+ var channels = await GetChannels(host, true, cancellationToken).ConfigureAwait(false);
+ var channelInfo = channels.FirstOrDefault(i => string.Equals(i.Id, channelId, StringComparison.OrdinalIgnoreCase));
+
+ if (channelInfo is not null)
+ {
+ hostsWithChannel.Add(new Tuple<TunerHostInfo, ChannelInfo>(host, channelInfo));
+ }
+ }
+ catch (Exception ex)
+ {
+ Logger.LogError(ex, "Error getting channels");
+ }
+ }
+
+ foreach (var hostTuple in hostsWithChannel)
+ {
+ var host = hostTuple.Item1;
+ var channelInfo = hostTuple.Item2;
+
+ try
+ {
+ var liveStream = await GetChannelStream(host, channelInfo, streamId, currentLiveStreams, cancellationToken).ConfigureAwait(false);
+ 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);
+ return liveStream;
+ }
+ catch (Exception ex)
+ {
+ Logger.LogError(ex, "Error opening tuner");
+ }
+ }
+
+ throw new LiveTvConflictException();
+ }
+
+ protected virtual bool IsValidChannelId(string channelId)
+ {
+ ArgumentException.ThrowIfNullOrEmpty(channelId);
+
+ return channelId.StartsWith(ChannelIdPrefix, StringComparison.OrdinalIgnoreCase);
+ }
+
+ protected LiveTvOptions GetConfiguration()
+ {
+ return Config.GetConfiguration<LiveTvOptions>("livetv");
+ }
+ }
+}
diff --git a/src/Jellyfin.LiveTv/TunerHosts/HdHomerun/Channels.cs b/src/Jellyfin.LiveTv/TunerHosts/HdHomerun/Channels.cs
new file mode 100644
index 000000000..311a71d13
--- /dev/null
+++ b/src/Jellyfin.LiveTv/TunerHosts/HdHomerun/Channels.cs
@@ -0,0 +1,23 @@
+#nullable disable
+
+namespace Jellyfin.LiveTv.TunerHosts.HdHomerun
+{
+ internal class Channels
+ {
+ public string GuideNumber { get; set; }
+
+ public string GuideName { get; set; }
+
+ public string VideoCodec { get; set; }
+
+ public string AudioCodec { get; set; }
+
+ public string URL { get; set; }
+
+ public bool Favorite { get; set; }
+
+ public bool DRM { get; set; }
+
+ public bool HD { get; set; }
+ }
+}
diff --git a/src/Jellyfin.LiveTv/TunerHosts/HdHomerun/DiscoverResponse.cs b/src/Jellyfin.LiveTv/TunerHosts/HdHomerun/DiscoverResponse.cs
new file mode 100644
index 000000000..3ece181f2
--- /dev/null
+++ b/src/Jellyfin.LiveTv/TunerHosts/HdHomerun/DiscoverResponse.cs
@@ -0,0 +1,42 @@
+#nullable disable
+
+using System;
+
+namespace Jellyfin.LiveTv.TunerHosts.HdHomerun
+{
+ internal class DiscoverResponse
+ {
+ public string FriendlyName { get; set; }
+
+ public string ModelNumber { get; set; }
+
+ public string FirmwareName { get; set; }
+
+ public string FirmwareVersion { get; set; }
+
+ public string DeviceID { get; set; }
+
+ public string DeviceAuth { get; set; }
+
+ public string BaseURL { get; set; }
+
+ public string LineupURL { get; set; }
+
+ public int TunerCount { get; set; }
+
+ public bool SupportsTranscoding
+ {
+ get
+ {
+ var model = ModelNumber ?? string.Empty;
+
+ if (model.Contains("hdtc", StringComparison.OrdinalIgnoreCase))
+ {
+ return true;
+ }
+
+ return false;
+ }
+ }
+ }
+}
diff --git a/src/Jellyfin.LiveTv/TunerHosts/HdHomerun/HdHomerunChannelCommands.cs b/src/Jellyfin.LiveTv/TunerHosts/HdHomerun/HdHomerunChannelCommands.cs
new file mode 100644
index 000000000..50a887826
--- /dev/null
+++ b/src/Jellyfin.LiveTv/TunerHosts/HdHomerun/HdHomerunChannelCommands.cs
@@ -0,0 +1,35 @@
+#pragma warning disable CS1591
+
+using System;
+using System.Collections.Generic;
+
+namespace Jellyfin.LiveTv.TunerHosts.HdHomerun
+{
+ public class HdHomerunChannelCommands : IHdHomerunChannelCommands
+ {
+ private string? _channel;
+ private string? _profile;
+
+ public HdHomerunChannelCommands(string? channel, string? profile)
+ {
+ _channel = channel;
+ _profile = profile;
+ }
+
+ public IEnumerable<(string CommandName, string CommandValue)> GetCommands()
+ {
+ if (!string.IsNullOrEmpty(_channel))
+ {
+ if (!string.IsNullOrEmpty(_profile)
+ && !string.Equals(_profile, "native", StringComparison.OrdinalIgnoreCase))
+ {
+ yield return ("vchannel", $"{_channel} transcode={_profile}");
+ }
+ else
+ {
+ yield return ("vchannel", _channel);
+ }
+ }
+ }
+ }
+}
diff --git a/src/Jellyfin.LiveTv/TunerHosts/HdHomerun/HdHomerunHost.cs b/src/Jellyfin.LiveTv/TunerHosts/HdHomerun/HdHomerunHost.cs
new file mode 100644
index 000000000..b1b08e992
--- /dev/null
+++ b/src/Jellyfin.LiveTv/TunerHosts/HdHomerun/HdHomerunHost.cs
@@ -0,0 +1,718 @@
+#nullable disable
+
+#pragma warning disable CS1591
+
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.IO;
+using System.Linq;
+using System.Net;
+using System.Net.Http;
+using System.Net.Http.Json;
+using System.Text.Json;
+using System.Threading;
+using System.Threading.Tasks;
+using Jellyfin.Extensions;
+using Jellyfin.Extensions.Json;
+using Jellyfin.Extensions.Json.Converters;
+using MediaBrowser.Common.Extensions;
+using MediaBrowser.Common.Net;
+using MediaBrowser.Controller;
+using MediaBrowser.Controller.Configuration;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.LiveTv;
+using MediaBrowser.Model.Dto;
+using MediaBrowser.Model.Entities;
+using MediaBrowser.Model.IO;
+using MediaBrowser.Model.LiveTv;
+using MediaBrowser.Model.MediaInfo;
+using MediaBrowser.Model.Net;
+using Microsoft.Extensions.Logging;
+
+namespace Jellyfin.LiveTv.TunerHosts.HdHomerun
+{
+ public class HdHomerunHost : BaseTunerHost, ITunerHost, IConfigurableTunerHost
+ {
+ private readonly IHttpClientFactory _httpClientFactory;
+ private readonly IServerApplicationHost _appHost;
+ private readonly ISocketFactory _socketFactory;
+ private readonly IStreamHelper _streamHelper;
+
+ private readonly JsonSerializerOptions _jsonOptions;
+
+ private readonly Dictionary<string, DiscoverResponse> _modelCache = new Dictionary<string, DiscoverResponse>();
+
+ public HdHomerunHost(
+ IServerConfigurationManager config,
+ ILogger<HdHomerunHost> logger,
+ IFileSystem fileSystem,
+ IHttpClientFactory httpClientFactory,
+ IServerApplicationHost appHost,
+ ISocketFactory socketFactory,
+ IStreamHelper streamHelper)
+ : base(config, logger, fileSystem)
+ {
+ _httpClientFactory = httpClientFactory;
+ _appHost = appHost;
+ _socketFactory = socketFactory;
+ _streamHelper = streamHelper;
+
+ _jsonOptions = new JsonSerializerOptions(JsonDefaults.Options);
+ _jsonOptions.Converters.Add(new JsonBoolNumberConverter());
+ }
+
+ public string Name => "HD Homerun";
+
+ public override string Type => "hdhomerun";
+
+ protected override string ChannelIdPrefix => "hdhr_";
+
+ private string GetChannelId(Channels i)
+ => ChannelIdPrefix + i.GuideNumber;
+
+ internal async Task<List<Channels>> GetLineup(TunerHostInfo info, CancellationToken cancellationToken)
+ {
+ var model = await GetModelInfo(info, false, cancellationToken).ConfigureAwait(false);
+
+ using var response = await _httpClientFactory.CreateClient(NamedClient.Default).GetAsync(model.LineupURL ?? model.BaseURL + "/lineup.json", HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false);
+ var lineup = await response.Content.ReadFromJsonAsync<IEnumerable<Channels>>(_jsonOptions, cancellationToken).ConfigureAwait(false) ?? Enumerable.Empty<Channels>();
+ if (info.ImportFavoritesOnly)
+ {
+ lineup = lineup.Where(i => i.Favorite);
+ }
+
+ return lineup.Where(i => !i.DRM).ToList();
+ }
+
+ protected override async Task<List<ChannelInfo>> GetChannelsInternal(TunerHostInfo tuner, CancellationToken cancellationToken)
+ {
+ var lineup = await GetLineup(tuner, cancellationToken).ConfigureAwait(false);
+
+ return lineup.Select(i => new HdHomerunChannelInfo
+ {
+ Name = i.GuideName,
+ Number = i.GuideNumber,
+ Id = GetChannelId(i),
+ IsFavorite = i.Favorite,
+ TunerHostId = tuner.Id,
+ IsHD = i.HD,
+ AudioCodec = i.AudioCodec,
+ VideoCodec = i.VideoCodec,
+ ChannelType = ChannelType.TV,
+ IsLegacyTuner = (i.URL ?? string.Empty).StartsWith("hdhomerun", StringComparison.OrdinalIgnoreCase),
+ Path = i.URL
+ }).Cast<ChannelInfo>().ToList();
+ }
+
+ internal async Task<DiscoverResponse> GetModelInfo(TunerHostInfo info, bool throwAllExceptions, CancellationToken cancellationToken)
+ {
+ var cacheKey = info.Id;
+
+ lock (_modelCache)
+ {
+ if (!string.IsNullOrEmpty(cacheKey))
+ {
+ if (_modelCache.TryGetValue(cacheKey, out DiscoverResponse response))
+ {
+ return response;
+ }
+ }
+ }
+
+ try
+ {
+ using var response = await _httpClientFactory.CreateClient(NamedClient.Default)
+ .GetAsync(GetApiUrl(info) + "/discover.json", HttpCompletionOption.ResponseHeadersRead, cancellationToken)
+ .ConfigureAwait(false);
+ response.EnsureSuccessStatusCode();
+ var discoverResponse = await response.Content.ReadFromJsonAsync<DiscoverResponse>(_jsonOptions, cancellationToken).ConfigureAwait(false);
+
+ if (!string.IsNullOrEmpty(cacheKey))
+ {
+ lock (_modelCache)
+ {
+ _modelCache[cacheKey] = discoverResponse;
+ }
+ }
+
+ return discoverResponse;
+ }
+ catch (HttpRequestException ex)
+ {
+ if (!throwAllExceptions && ex.StatusCode.HasValue && ex.StatusCode.Value == HttpStatusCode.NotFound)
+ {
+ const string DefaultValue = "HDHR";
+ var discoverResponse = new DiscoverResponse
+ {
+ ModelNumber = DefaultValue
+ };
+ if (!string.IsNullOrEmpty(cacheKey))
+ {
+ // HDHR4 doesn't have this api
+ lock (_modelCache)
+ {
+ _modelCache[cacheKey] = discoverResponse;
+ }
+ }
+
+ return discoverResponse;
+ }
+
+ throw;
+ }
+ }
+
+ private async Task<List<LiveTvTunerInfo>> GetTunerInfosHttp(TunerHostInfo info, CancellationToken cancellationToken)
+ {
+ var model = await GetModelInfo(info, false, cancellationToken).ConfigureAwait(false);
+
+ using var response = await _httpClientFactory.CreateClient(NamedClient.Default)
+ .GetAsync(string.Format(CultureInfo.InvariantCulture, "{0}/tuners.html", GetApiUrl(info)), HttpCompletionOption.ResponseHeadersRead, cancellationToken)
+ .ConfigureAwait(false);
+ var tuners = new List<LiveTvTunerInfo>();
+ var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
+ await using (stream.ConfigureAwait(false))
+ {
+ using var sr = new StreamReader(stream, System.Text.Encoding.UTF8);
+ await foreach (var line in sr.ReadAllLinesAsync().ConfigureAwait(false))
+ {
+ string stripedLine = StripXML(line);
+ if (stripedLine.Contains("Channel", StringComparison.Ordinal))
+ {
+ LiveTvTunerStatus status;
+ var index = stripedLine.IndexOf("Channel", StringComparison.OrdinalIgnoreCase);
+ var name = stripedLine.Substring(0, index - 1);
+ var currentChannel = stripedLine.Substring(index + 7);
+ if (string.Equals(currentChannel, "none", StringComparison.Ordinal))
+ {
+ status = LiveTvTunerStatus.LiveTv;
+ }
+ else
+ {
+ status = LiveTvTunerStatus.Available;
+ }
+
+ tuners.Add(new LiveTvTunerInfo
+ {
+ Name = name,
+ SourceType = string.IsNullOrWhiteSpace(model.ModelNumber) ? Name : model.ModelNumber,
+ ProgramName = currentChannel,
+ Status = status
+ });
+ }
+ }
+ }
+
+ return tuners;
+ }
+
+ private static string StripXML(string source)
+ {
+ if (string.IsNullOrEmpty(source))
+ {
+ return string.Empty;
+ }
+
+ char[] buffer = new char[source.Length];
+ int bufferIndex = 0;
+ bool inside = false;
+
+ for (int i = 0; i < source.Length; i++)
+ {
+ char let = source[i];
+ if (let == '<')
+ {
+ inside = true;
+ continue;
+ }
+
+ if (let == '>')
+ {
+ inside = false;
+ continue;
+ }
+
+ if (!inside)
+ {
+ buffer[bufferIndex++] = let;
+ }
+ }
+
+ return new string(buffer, 0, bufferIndex);
+ }
+
+ private async Task<List<LiveTvTunerInfo>> GetTunerInfosUdp(TunerHostInfo info, CancellationToken cancellationToken)
+ {
+ var model = await GetModelInfo(info, false, cancellationToken).ConfigureAwait(false);
+
+ var tuners = new List<LiveTvTunerInfo>(model.TunerCount);
+
+ var uri = new Uri(GetApiUrl(info));
+
+ using (var manager = new HdHomerunManager())
+ {
+ // Legacy HdHomeruns are IPv4 only
+ var ipInfo = IPAddress.Parse(uri.Host);
+
+ for (int i = 0; i < model.TunerCount; i++)
+ {
+ var name = string.Format(CultureInfo.InvariantCulture, "Tuner {0}", i + 1);
+ var currentChannel = "none"; // TODO: Get current channel and map back to Station Id
+ var isAvailable = await manager.CheckTunerAvailability(ipInfo, i, cancellationToken).ConfigureAwait(false);
+ var status = isAvailable ? LiveTvTunerStatus.Available : LiveTvTunerStatus.LiveTv;
+ tuners.Add(new LiveTvTunerInfo
+ {
+ Name = name,
+ SourceType = string.IsNullOrWhiteSpace(model.ModelNumber) ? Name : model.ModelNumber,
+ ProgramName = currentChannel,
+ Status = status
+ });
+ }
+ }
+
+ return tuners;
+ }
+
+ public async Task<List<LiveTvTunerInfo>> GetTunerInfos(CancellationToken cancellationToken)
+ {
+ var list = new List<LiveTvTunerInfo>();
+
+ foreach (var host in GetConfiguration().TunerHosts
+ .Where(i => string.Equals(i.Type, Type, StringComparison.OrdinalIgnoreCase)))
+ {
+ try
+ {
+ list.AddRange(await GetTunerInfos(host, cancellationToken).ConfigureAwait(false));
+ }
+ catch (Exception ex)
+ {
+ Logger.LogError(ex, "Error getting tuner info");
+ }
+ }
+
+ return list;
+ }
+
+ public async Task<List<LiveTvTunerInfo>> GetTunerInfos(TunerHostInfo info, CancellationToken cancellationToken)
+ {
+ // TODO Need faster way to determine UDP vs HTTP
+ var channels = await GetChannels(info, true, cancellationToken).ConfigureAwait(false);
+
+ var hdHomerunChannelInfo = channels.FirstOrDefault() as HdHomerunChannelInfo;
+
+ if (hdHomerunChannelInfo is null || hdHomerunChannelInfo.IsLegacyTuner)
+ {
+ return await GetTunerInfosUdp(info, cancellationToken).ConfigureAwait(false);
+ }
+
+ return await GetTunerInfosHttp(info, cancellationToken).ConfigureAwait(false);
+ }
+
+ private static string GetApiUrl(TunerHostInfo info)
+ {
+ var url = info.Url;
+
+ if (string.IsNullOrWhiteSpace(url))
+ {
+ throw new ArgumentException("Invalid tuner info");
+ }
+
+ if (!url.StartsWith("http", StringComparison.OrdinalIgnoreCase))
+ {
+ url = "http://" + url;
+ }
+
+ return new Uri(url).AbsoluteUri.TrimEnd('/');
+ }
+
+ private static string GetHdHrIdFromChannelId(string channelId)
+ {
+ return channelId.Split('_')[1];
+ }
+
+ private MediaSourceInfo GetMediaSource(TunerHostInfo info, string channelId, ChannelInfo channelInfo, string profile)
+ {
+ int? width = null;
+ int? height = null;
+ bool isInterlaced = true;
+ string videoCodec = null;
+
+ int? videoBitrate = null;
+
+ var isHd = channelInfo.IsHD ?? true;
+
+ if (string.Equals(profile, "mobile", StringComparison.OrdinalIgnoreCase))
+ {
+ width = 1280;
+ height = 720;
+ isInterlaced = false;
+ videoCodec = "h264";
+ videoBitrate = 2000000;
+ }
+ else if (string.Equals(profile, "heavy", StringComparison.OrdinalIgnoreCase))
+ {
+ width = 1920;
+ height = 1080;
+ isInterlaced = false;
+ videoCodec = "h264";
+ videoBitrate = 15000000;
+ }
+ else if (string.Equals(profile, "internet720", StringComparison.OrdinalIgnoreCase))
+ {
+ width = 1280;
+ height = 720;
+ isInterlaced = false;
+ videoCodec = "h264";
+ videoBitrate = 8000000;
+ }
+ else if (string.Equals(profile, "internet540", StringComparison.OrdinalIgnoreCase))
+ {
+ width = 960;
+ height = 540;
+ isInterlaced = false;
+ videoCodec = "h264";
+ videoBitrate = 2500000;
+ }
+ else if (string.Equals(profile, "internet480", StringComparison.OrdinalIgnoreCase))
+ {
+ width = 848;
+ height = 480;
+ isInterlaced = false;
+ videoCodec = "h264";
+ videoBitrate = 2000000;
+ }
+ else if (string.Equals(profile, "internet360", StringComparison.OrdinalIgnoreCase))
+ {
+ width = 640;
+ height = 360;
+ isInterlaced = false;
+ videoCodec = "h264";
+ videoBitrate = 1500000;
+ }
+ else if (string.Equals(profile, "internet240", StringComparison.OrdinalIgnoreCase))
+ {
+ width = 432;
+ height = 240;
+ isInterlaced = false;
+ videoCodec = "h264";
+ videoBitrate = 1000000;
+ }
+ else
+ {
+ // This is for android tv's 1200 condition. Remove once not needed anymore so that we can avoid possible side effects of dummying up this data
+ if (isHd)
+ {
+ width = 1920;
+ height = 1080;
+ }
+ }
+
+ if (string.IsNullOrWhiteSpace(videoCodec))
+ {
+ videoCodec = channelInfo.VideoCodec;
+ }
+
+ string audioCodec = channelInfo.AudioCodec;
+
+ videoBitrate ??= isHd ? 15000000 : 2000000;
+
+ int? audioBitrate = isHd ? 448000 : 192000;
+
+ // normalize
+ if (string.Equals(videoCodec, "mpeg2", StringComparison.OrdinalIgnoreCase))
+ {
+ videoCodec = "mpeg2video";
+ }
+
+ string nal = null;
+ if (string.Equals(videoCodec, "h264", StringComparison.OrdinalIgnoreCase))
+ {
+ nal = "0";
+ }
+
+ var url = GetApiUrl(info);
+
+ var id = profile;
+ if (string.IsNullOrWhiteSpace(id))
+ {
+ id = "native";
+ }
+
+ id += "_" + channelId.GetMD5().ToString("N", CultureInfo.InvariantCulture) + "_" + url.GetMD5().ToString("N", CultureInfo.InvariantCulture);
+
+ var mediaSource = new MediaSourceInfo
+ {
+ Path = url,
+ Protocol = MediaProtocol.Udp,
+ 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,
+ IsInterlaced = isInterlaced,
+ Codec = videoCodec,
+ Width = width,
+ Height = height,
+ BitRate = videoBitrate,
+ NalLengthSize = nal
+ },
+ 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,
+ Codec = audioCodec,
+ BitRate = audioBitrate
+ }
+ },
+ RequiresOpening = true,
+ RequiresClosing = true,
+ BufferMs = 0,
+ Container = "ts",
+ Id = id,
+ SupportsDirectPlay = false,
+ SupportsDirectStream = true,
+ SupportsTranscoding = true,
+ IsInfiniteStream = true,
+ IgnoreDts = true,
+ // IgnoreIndex = true,
+ // ReadAtNativeFramerate = true
+ };
+
+ mediaSource.InferTotalBitrate();
+
+ return mediaSource;
+ }
+
+ protected override async Task<List<MediaSourceInfo>> GetChannelStreamMediaSources(TunerHostInfo tuner, ChannelInfo channel, CancellationToken cancellationToken)
+ {
+ var list = new List<MediaSourceInfo>();
+
+ var channelId = channel.Id;
+ var hdhrId = GetHdHrIdFromChannelId(channelId);
+
+ if (channel is HdHomerunChannelInfo hdHomerunChannelInfo && hdHomerunChannelInfo.IsLegacyTuner)
+ {
+ list.Add(GetMediaSource(tuner, hdhrId, channel, "native"));
+ }
+ else
+ {
+ var modelInfo = await GetModelInfo(tuner, false, cancellationToken).ConfigureAwait(false);
+
+ if (modelInfo is not null && modelInfo.SupportsTranscoding)
+ {
+ if (tuner.AllowHWTranscoding)
+ {
+ list.Add(GetMediaSource(tuner, hdhrId, channel, "heavy"));
+
+ list.Add(GetMediaSource(tuner, hdhrId, channel, "internet540"));
+ list.Add(GetMediaSource(tuner, hdhrId, channel, "internet480"));
+ list.Add(GetMediaSource(tuner, hdhrId, channel, "internet360"));
+ list.Add(GetMediaSource(tuner, hdhrId, channel, "internet240"));
+ list.Add(GetMediaSource(tuner, hdhrId, channel, "mobile"));
+ }
+
+ list.Add(GetMediaSource(tuner, hdhrId, channel, "native"));
+ }
+
+ if (list.Count == 0)
+ {
+ list.Add(GetMediaSource(tuner, hdhrId, channel, "native"));
+ }
+ }
+
+ return list;
+ }
+
+ protected override async Task<ILiveStream> GetChannelStream(TunerHostInfo tunerHost, ChannelInfo channel, string streamId, IList<ILiveStream> currentLiveStreams, CancellationToken cancellationToken)
+ {
+ var tunerCount = tunerHost.TunerCount;
+
+ if (tunerCount > 0)
+ {
+ var tunerHostId = tunerHost.Id;
+ var liveStreams = currentLiveStreams.Where(i => string.Equals(i.TunerHostId, tunerHostId, StringComparison.OrdinalIgnoreCase));
+
+ if (liveStreams.Count() >= tunerCount)
+ {
+ throw new LiveTvConflictException("HDHomeRun simultaneous stream limit has been reached.");
+ }
+ }
+
+ var profile = streamId.AsSpan().LeftPart('_').ToString();
+
+ Logger.LogInformation("GetChannelStream: channel id: {0}. stream id: {1} profile: {2}", channel.Id, streamId, profile);
+
+ var hdhrId = GetHdHrIdFromChannelId(channel.Id);
+
+ var hdhomerunChannel = channel as HdHomerunChannelInfo;
+
+ var modelInfo = await GetModelInfo(tunerHost, false, cancellationToken).ConfigureAwait(false);
+
+ if (!modelInfo.SupportsTranscoding)
+ {
+ profile = "native";
+ }
+
+ var mediaSource = GetMediaSource(tunerHost, hdhrId, channel, profile);
+
+ if (hdhomerunChannel is not null && hdhomerunChannel.IsLegacyTuner)
+ {
+ return new HdHomerunUdpStream(
+ mediaSource,
+ tunerHost,
+ streamId,
+ new LegacyHdHomerunChannelCommands(hdhomerunChannel.Path),
+ modelInfo.TunerCount,
+ FileSystem,
+ Logger,
+ Config,
+ _appHost,
+ _streamHelper);
+ }
+
+ var enableHttpStream = true;
+ if (enableHttpStream)
+ {
+ mediaSource.Protocol = MediaProtocol.Http;
+
+ var httpUrl = channel.Path;
+
+ // If raw was used, the tuner doesn't support params
+ if (!string.IsNullOrWhiteSpace(profile) && !string.Equals(profile, "native", StringComparison.OrdinalIgnoreCase))
+ {
+ httpUrl += "?transcode=" + profile;
+ }
+
+ mediaSource.Path = httpUrl;
+
+ return new SharedHttpStream(
+ mediaSource,
+ tunerHost,
+ streamId,
+ FileSystem,
+ _httpClientFactory,
+ Logger,
+ Config,
+ _appHost,
+ _streamHelper);
+ }
+
+ return new HdHomerunUdpStream(
+ mediaSource,
+ tunerHost,
+ streamId,
+ new HdHomerunChannelCommands(hdhomerunChannel.Number, profile),
+ modelInfo.TunerCount,
+ FileSystem,
+ Logger,
+ Config,
+ _appHost,
+ _streamHelper);
+ }
+
+ public async Task Validate(TunerHostInfo info)
+ {
+ lock (_modelCache)
+ {
+ _modelCache.Clear();
+ }
+
+ try
+ {
+ // Test it by pulling down the lineup
+ var modelInfo = await GetModelInfo(info, true, CancellationToken.None).ConfigureAwait(false);
+ info.DeviceId = modelInfo.DeviceID;
+ }
+ catch (HttpRequestException ex)
+ {
+ if (ex.StatusCode.HasValue && ex.StatusCode.Value == HttpStatusCode.NotFound)
+ {
+ // HDHR4 doesn't have this api
+ return;
+ }
+
+ throw;
+ }
+ }
+
+ public async Task<List<TunerHostInfo>> DiscoverDevices(int discoveryDurationMs, CancellationToken cancellationToken)
+ {
+ lock (_modelCache)
+ {
+ _modelCache.Clear();
+ }
+
+ using var timedCancellationToken = new CancellationTokenSource(discoveryDurationMs);
+ using var linkedCancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(timedCancellationToken.Token, cancellationToken);
+ cancellationToken = linkedCancellationTokenSource.Token;
+ var list = new List<TunerHostInfo>();
+
+ // Create udp broadcast discovery message
+ byte[] discBytes = { 0, 2, 0, 12, 1, 4, 255, 255, 255, 255, 2, 4, 255, 255, 255, 255, 115, 204, 125, 143 };
+ using (var udpClient = _socketFactory.CreateUdpBroadcastSocket(0))
+ {
+ // Need a way to set the Receive timeout on the socket otherwise this might never timeout?
+ try
+ {
+ await udpClient.SendToAsync(discBytes, new IPEndPoint(IPAddress.Broadcast, 65001), cancellationToken).ConfigureAwait(false);
+ var receiveBuffer = new byte[8192];
+
+ while (!cancellationToken.IsCancellationRequested)
+ {
+ var response = await udpClient.ReceiveMessageFromAsync(receiveBuffer, new IPEndPoint(IPAddress.Any, 0), cancellationToken).ConfigureAwait(false);
+ var deviceIP = ((IPEndPoint)response.RemoteEndPoint).Address.ToString();
+
+ // Check to make sure we have enough bytes received to be a valid message and make sure the 2nd byte is the discover reply byte
+ if (response.ReceivedBytes > 13 && receiveBuffer[1] == 3)
+ {
+ var deviceAddress = "http://" + deviceIP;
+
+ var info = await TryGetTunerHostInfo(deviceAddress, cancellationToken).ConfigureAwait(false);
+
+ if (info is not null)
+ {
+ list.Add(info);
+ }
+ }
+ }
+ }
+ catch (OperationCanceledException)
+ {
+ }
+ catch (Exception ex)
+ {
+ // Socket timeout indicates all messages have been received.
+ Logger.LogError(ex, "Error while sending discovery message");
+ }
+ }
+
+ return list;
+ }
+
+ internal async Task<TunerHostInfo> TryGetTunerHostInfo(string url, CancellationToken cancellationToken)
+ {
+ var hostInfo = new TunerHostInfo
+ {
+ Type = Type,
+ Url = url
+ };
+
+ var modelInfo = await GetModelInfo(hostInfo, false, cancellationToken).ConfigureAwait(false);
+
+ hostInfo.DeviceId = modelInfo.DeviceID;
+ hostInfo.FriendlyName = modelInfo.FriendlyName;
+ hostInfo.TunerCount = modelInfo.TunerCount;
+
+ return hostInfo;
+ }
+
+ private class HdHomerunChannelInfo : ChannelInfo
+ {
+ public bool IsLegacyTuner { get; set; }
+ }
+ }
+}
diff --git a/src/Jellyfin.LiveTv/TunerHosts/HdHomerun/HdHomerunManager.cs b/src/Jellyfin.LiveTv/TunerHosts/HdHomerun/HdHomerunManager.cs
new file mode 100644
index 000000000..861338727
--- /dev/null
+++ b/src/Jellyfin.LiveTv/TunerHosts/HdHomerun/HdHomerunManager.cs
@@ -0,0 +1,351 @@
+#nullable disable
+
+#pragma warning disable CS1591
+
+using System;
+using System.Buffers;
+using System.Buffers.Binary;
+using System.Globalization;
+using System.Net;
+using System.Net.Sockets;
+using System.Text;
+using System.Threading;
+using System.Threading.Tasks;
+using MediaBrowser.Common;
+using MediaBrowser.Controller.LiveTv;
+
+namespace Jellyfin.LiveTv.TunerHosts.HdHomerun
+{
+ public sealed class HdHomerunManager : IDisposable
+ {
+ public const int HdHomeRunPort = 65001;
+
+ // Message constants
+ private const byte GetSetName = 3;
+ private const byte GetSetValue = 4;
+ private const byte GetSetLockkey = 21;
+ private const ushort GetSetRequest = 4;
+ private const ushort GetSetReply = 5;
+
+ private uint? _lockkey = null;
+ private int _activeTuner = -1;
+ private IPEndPoint _remoteEndPoint;
+
+ private TcpClient _tcpClient;
+
+ public void Dispose()
+ {
+ using (var socket = _tcpClient)
+ {
+ if (socket is not null)
+ {
+ _tcpClient = null;
+
+ StopStreaming(socket).GetAwaiter().GetResult();
+ }
+ }
+ }
+
+ public async Task<bool> CheckTunerAvailability(IPAddress remoteIP, int tuner, CancellationToken cancellationToken)
+ {
+ using var client = new TcpClient();
+ await client.ConnectAsync(remoteIP, HdHomeRunPort, cancellationToken).ConfigureAwait(false);
+
+ using var stream = client.GetStream();
+ return await CheckTunerAvailability(stream, tuner, cancellationToken).ConfigureAwait(false);
+ }
+
+ private static async Task<bool> CheckTunerAvailability(NetworkStream stream, int tuner, CancellationToken cancellationToken)
+ {
+ byte[] buffer = ArrayPool<byte>.Shared.Rent(8192);
+ try
+ {
+ var msgLen = WriteGetMessage(buffer, tuner, "lockkey");
+ await stream.WriteAsync(buffer.AsMemory(0, msgLen), cancellationToken).ConfigureAwait(false);
+
+ int receivedBytes = await stream.ReadAsync(buffer, cancellationToken).ConfigureAwait(false);
+
+ return VerifyReturnValueOfGetSet(buffer.AsSpan(0, receivedBytes), "none");
+ }
+ finally
+ {
+ ArrayPool<byte>.Shared.Return(buffer);
+ }
+ }
+
+ public async Task StartStreaming(IPAddress remoteIP, IPAddress localIP, int localPort, IHdHomerunChannelCommands commands, int numTuners, CancellationToken cancellationToken)
+ {
+ _remoteEndPoint = new IPEndPoint(remoteIP, HdHomeRunPort);
+
+ _tcpClient = new TcpClient();
+ await _tcpClient.ConnectAsync(_remoteEndPoint, cancellationToken).ConfigureAwait(false);
+
+ if (!_lockkey.HasValue)
+ {
+ _lockkey = (uint)Random.Shared.Next();
+ }
+
+ var lockKeyValue = _lockkey.Value;
+ var stream = _tcpClient.GetStream();
+
+ byte[] buffer = ArrayPool<byte>.Shared.Rent(8192);
+ try
+ {
+ for (int i = 0; i < numTuners; ++i)
+ {
+ if (!await CheckTunerAvailability(stream, i, cancellationToken).ConfigureAwait(false))
+ {
+ continue;
+ }
+
+ _activeTuner = i;
+ var lockKeyString = string.Format(CultureInfo.InvariantCulture, "{0:d}", lockKeyValue);
+ var lockkeyMsgLen = WriteSetMessage(buffer, i, "lockkey", lockKeyString, null);
+ await stream.WriteAsync(buffer.AsMemory(0, lockkeyMsgLen), cancellationToken).ConfigureAwait(false);
+ int receivedBytes = await stream.ReadAsync(buffer, cancellationToken).ConfigureAwait(false);
+
+ // parse response to make sure it worked
+ if (!TryGetReturnValueOfGetSet(buffer.AsSpan(0, receivedBytes), out _))
+ {
+ continue;
+ }
+
+ foreach (var command in commands.GetCommands())
+ {
+ var channelMsgLen = WriteSetMessage(buffer, i, command.CommandName, command.CommandValue, lockKeyValue);
+ await stream.WriteAsync(buffer.AsMemory(0, channelMsgLen), cancellationToken).ConfigureAwait(false);
+ receivedBytes = await stream.ReadAsync(buffer, cancellationToken).ConfigureAwait(false);
+
+ // parse response to make sure it worked
+ if (!TryGetReturnValueOfGetSet(buffer.AsSpan(0, receivedBytes), out _))
+ {
+ await ReleaseLockkey(_tcpClient, lockKeyValue).ConfigureAwait(false);
+ }
+ }
+
+ var targetValue = string.Format(CultureInfo.InvariantCulture, "rtp://{0}:{1}", localIP, localPort);
+ var targetMsgLen = WriteSetMessage(buffer, i, "target", targetValue, lockKeyValue);
+
+ await stream.WriteAsync(buffer.AsMemory(0, targetMsgLen), cancellationToken).ConfigureAwait(false);
+ receivedBytes = await stream.ReadAsync(buffer, cancellationToken).ConfigureAwait(false);
+
+ // parse response to make sure it worked
+ if (!TryGetReturnValueOfGetSet(buffer.AsSpan(0, receivedBytes), out _))
+ {
+ await ReleaseLockkey(_tcpClient, lockKeyValue).ConfigureAwait(false);
+ continue;
+ }
+
+ return;
+ }
+ }
+ finally
+ {
+ ArrayPool<byte>.Shared.Return(buffer);
+ }
+
+ _activeTuner = -1;
+ throw new LiveTvConflictException();
+ }
+
+ public async Task ChangeChannel(IHdHomerunChannelCommands commands, CancellationToken cancellationToken)
+ {
+ if (!_lockkey.HasValue)
+ {
+ return;
+ }
+
+ using var tcpClient = new TcpClient();
+ await tcpClient.ConnectAsync(_remoteEndPoint, cancellationToken).ConfigureAwait(false);
+
+ using var stream = tcpClient.GetStream();
+ var commandList = commands.GetCommands();
+ byte[] buffer = ArrayPool<byte>.Shared.Rent(8192);
+ try
+ {
+ foreach (var command in commandList)
+ {
+ var channelMsgLen = WriteSetMessage(buffer, _activeTuner, command.CommandName, command.CommandValue, _lockkey);
+ await stream.WriteAsync(buffer.AsMemory(0, channelMsgLen), cancellationToken).ConfigureAwait(false);
+ int receivedBytes = await stream.ReadAsync(buffer, cancellationToken).ConfigureAwait(false);
+
+ // parse response to make sure it worked
+ if (!TryGetReturnValueOfGetSet(buffer.AsSpan(0, receivedBytes), out _))
+ {
+ return;
+ }
+ }
+ }
+ finally
+ {
+ ArrayPool<byte>.Shared.Return(buffer);
+ }
+ }
+
+ public Task StopStreaming(TcpClient client)
+ {
+ var lockKey = _lockkey;
+
+ if (!lockKey.HasValue)
+ {
+ return Task.CompletedTask;
+ }
+
+ return ReleaseLockkey(client, lockKey.Value);
+ }
+
+ private async Task ReleaseLockkey(TcpClient client, uint lockKeyValue)
+ {
+ var stream = client.GetStream();
+
+ var buffer = ArrayPool<byte>.Shared.Rent(8192);
+ try
+ {
+ var releaseTargetLen = WriteSetMessage(buffer, _activeTuner, "target", "none", lockKeyValue);
+ await stream.WriteAsync(buffer.AsMemory(0, releaseTargetLen)).ConfigureAwait(false);
+
+ await stream.ReadAsync(buffer).ConfigureAwait(false);
+ var releaseKeyMsgLen = WriteSetMessage(buffer, _activeTuner, "lockkey", "none", lockKeyValue);
+ _lockkey = null;
+ await stream.WriteAsync(buffer.AsMemory(0, releaseKeyMsgLen)).ConfigureAwait(false);
+ await stream.ReadAsync(buffer).ConfigureAwait(false);
+ }
+ finally
+ {
+ ArrayPool<byte>.Shared.Return(buffer);
+ }
+ }
+
+ internal static int WriteGetMessage(Span<byte> buffer, int tuner, string name)
+ {
+ var byteName = string.Format(CultureInfo.InvariantCulture, "/tuner{0}/{1}", tuner, name);
+ int offset = WriteHeaderAndPayload(buffer, byteName);
+ return FinishPacket(buffer, offset);
+ }
+
+ internal static int WriteSetMessage(Span<byte> buffer, int tuner, string name, string value, uint? lockkey)
+ {
+ var byteName = string.Format(CultureInfo.InvariantCulture, "/tuner{0}/{1}", tuner, name);
+ int offset = WriteHeaderAndPayload(buffer, byteName);
+
+ buffer[offset++] = GetSetValue;
+ offset += WriteNullTerminatedString(buffer.Slice(offset), value);
+
+ if (lockkey.HasValue)
+ {
+ buffer[offset++] = GetSetLockkey;
+ buffer[offset++] = 4;
+ BinaryPrimitives.WriteUInt32BigEndian(buffer.Slice(offset), lockkey.Value);
+ offset += 4;
+ }
+
+ return FinishPacket(buffer, offset);
+ }
+
+ internal static int WriteNullTerminatedString(Span<byte> buffer, ReadOnlySpan<char> payload)
+ {
+ int len = Encoding.UTF8.GetBytes(payload, buffer.Slice(1)) + 1;
+
+ // TODO: variable length: this can be 2 bytes if len > 127
+ // Write length in front of value
+ buffer[0] = Convert.ToByte(len);
+
+ // null-terminate
+ buffer[len++] = 0;
+
+ return len;
+ }
+
+ private static int WriteHeaderAndPayload(Span<byte> buffer, ReadOnlySpan<char> payload)
+ {
+ // Packet type
+ BinaryPrimitives.WriteUInt16BigEndian(buffer, GetSetRequest);
+
+ // We write the payload length at the end
+ int offset = 4;
+
+ // Tag
+ buffer[offset++] = GetSetName;
+
+ // Payload length + data
+ int strLen = WriteNullTerminatedString(buffer.Slice(offset), payload);
+ offset += strLen;
+
+ return offset;
+ }
+
+ private static int FinishPacket(Span<byte> buffer, int offset)
+ {
+ // Payload length
+ BinaryPrimitives.WriteUInt16BigEndian(buffer.Slice(2), (ushort)(offset - 4));
+
+ // calculate crc and insert at the end of the message
+ var crc = Crc32.Compute(buffer.Slice(0, offset));
+ BinaryPrimitives.WriteUInt32LittleEndian(buffer.Slice(offset), crc);
+
+ return offset + 4;
+ }
+
+ internal static bool VerifyReturnValueOfGetSet(ReadOnlySpan<byte> buffer, string expected)
+ {
+ return TryGetReturnValueOfGetSet(buffer, out var value)
+ && string.Equals(Encoding.UTF8.GetString(value), expected, StringComparison.OrdinalIgnoreCase);
+ }
+
+ internal static bool TryGetReturnValueOfGetSet(ReadOnlySpan<byte> buffer, out ReadOnlySpan<byte> value)
+ {
+ value = ReadOnlySpan<byte>.Empty;
+
+ if (buffer.Length < 8)
+ {
+ return false;
+ }
+
+ uint crc = BinaryPrimitives.ReadUInt32LittleEndian(buffer[^4..]);
+ if (crc != Crc32.Compute(buffer[..^4]))
+ {
+ return false;
+ }
+
+ if (BinaryPrimitives.ReadUInt16BigEndian(buffer) != GetSetReply)
+ {
+ return false;
+ }
+
+ var msgLength = BinaryPrimitives.ReadUInt16BigEndian(buffer.Slice(2));
+ if (buffer.Length != 2 + 2 + 4 + msgLength)
+ {
+ return false;
+ }
+
+ var offset = 4;
+ if (buffer[offset++] != GetSetName)
+ {
+ return false;
+ }
+
+ var nameLength = buffer[offset++];
+ if (buffer.Length < 4 + 1 + offset + nameLength)
+ {
+ return false;
+ }
+
+ offset += nameLength;
+
+ if (buffer[offset++] != GetSetValue)
+ {
+ return false;
+ }
+
+ var valueLength = buffer[offset++];
+ if (buffer.Length < 4 + offset + valueLength)
+ {
+ return false;
+ }
+
+ // remove null terminator
+ value = buffer.Slice(offset, valueLength - 1);
+ return true;
+ }
+ }
+}
diff --git a/src/Jellyfin.LiveTv/TunerHosts/HdHomerun/HdHomerunUdpStream.cs b/src/Jellyfin.LiveTv/TunerHosts/HdHomerun/HdHomerunUdpStream.cs
new file mode 100644
index 000000000..6c8cde62c
--- /dev/null
+++ b/src/Jellyfin.LiveTv/TunerHosts/HdHomerun/HdHomerunUdpStream.cs
@@ -0,0 +1,219 @@
+#nullable disable
+
+#pragma warning disable CA1711
+#pragma warning disable CS1591
+
+using System;
+using System.IO;
+using System.Linq;
+using System.Net;
+using System.Net.NetworkInformation;
+using System.Net.Sockets;
+using System.Threading;
+using System.Threading.Tasks;
+using MediaBrowser.Common.Configuration;
+using MediaBrowser.Controller;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Model.Dto;
+using MediaBrowser.Model.IO;
+using MediaBrowser.Model.LiveTv;
+using MediaBrowser.Model.MediaInfo;
+using Microsoft.Extensions.Logging;
+
+namespace Jellyfin.LiveTv.TunerHosts.HdHomerun
+{
+ public class HdHomerunUdpStream : LiveStream, IDirectStreamProvider
+ {
+ private const int RtpHeaderBytes = 12;
+
+ private readonly IServerApplicationHost _appHost;
+ private readonly IHdHomerunChannelCommands _channelCommands;
+ private readonly int _numTuners;
+
+ public HdHomerunUdpStream(
+ MediaSourceInfo mediaSource,
+ TunerHostInfo tunerHostInfo,
+ string originalStreamId,
+ IHdHomerunChannelCommands channelCommands,
+ int numTuners,
+ IFileSystem fileSystem,
+ ILogger logger,
+ IConfigurationManager configurationManager,
+ IServerApplicationHost appHost,
+ IStreamHelper streamHelper)
+ : base(mediaSource, tunerHostInfo, fileSystem, logger, configurationManager, streamHelper)
+ {
+ _appHost = appHost;
+ OriginalStreamId = originalStreamId;
+ _channelCommands = channelCommands;
+ _numTuners = numTuners;
+ EnableStreamSharing = true;
+ }
+
+ /// <summary>
+ /// Returns an unused UDP port number in the range specified.
+ /// Temporarily placed here until future network PR merged.
+ /// </summary>
+ /// <param name="range">Upper and Lower boundary of ports to select.</param>
+ /// <returns>System.Int32.</returns>
+ private static int GetUdpPortFromRange((int Min, int Max) range)
+ {
+ var properties = IPGlobalProperties.GetIPGlobalProperties();
+
+ // Get active udp listeners.
+ var udpListenerPorts = properties.GetActiveUdpListeners()
+ .Where(n => n.Port >= range.Min && n.Port <= range.Max)
+ .Select(n => n.Port);
+
+ return Enumerable
+ .Range(range.Min, range.Max)
+ .FirstOrDefault(i => !udpListenerPorts.Contains(i));
+ }
+
+ public override async Task Open(CancellationToken openCancellationToken)
+ {
+ LiveStreamCancellationTokenSource.Token.ThrowIfCancellationRequested();
+
+ var mediaSource = OriginalMediaSource;
+
+ var uri = new Uri(mediaSource.Path);
+ // Temporary code to reduce PR size. This will be updated by a future network pr.
+ var localPort = GetUdpPortFromRange((49152, 65535));
+
+ Directory.CreateDirectory(Path.GetDirectoryName(TempFilePath));
+
+ Logger.LogInformation("Opening HDHR UDP Live stream from {Host}", uri.Host);
+
+ var remoteAddress = IPAddress.Parse(uri.Host);
+ IPAddress localAddress;
+ using (var tcpClient = new TcpClient())
+ {
+ try
+ {
+ await tcpClient.ConnectAsync(remoteAddress, HdHomerunManager.HdHomeRunPort, openCancellationToken).ConfigureAwait(false);
+ localAddress = ((IPEndPoint)tcpClient.Client.LocalEndPoint).Address;
+ tcpClient.Close();
+ }
+ catch (Exception ex)
+ {
+ Logger.LogError(ex, "Unable to determine local ip address for Legacy HDHomerun stream.");
+ return;
+ }
+ }
+
+ if (localAddress.IsIPv4MappedToIPv6)
+ {
+ localAddress = localAddress.MapToIPv4();
+ }
+
+ var udpClient = new UdpClient(localPort, AddressFamily.InterNetwork);
+ var hdHomerunManager = new HdHomerunManager();
+
+ try
+ {
+ // send url to start streaming
+ await hdHomerunManager.StartStreaming(
+ remoteAddress,
+ localAddress,
+ localPort,
+ _channelCommands,
+ _numTuners,
+ openCancellationToken).ConfigureAwait(false);
+ }
+ catch (Exception ex)
+ {
+ using (udpClient)
+ using (hdHomerunManager)
+ {
+ if (ex is not OperationCanceledException)
+ {
+ Logger.LogError(ex, "Error opening live stream:");
+ }
+
+ throw;
+ }
+ }
+
+ var taskCompletionSource = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
+
+ _ = StartStreaming(
+ udpClient,
+ hdHomerunManager,
+ remoteAddress,
+ taskCompletionSource,
+ LiveStreamCancellationTokenSource.Token);
+
+ // OpenedMediaSource.Protocol = MediaProtocol.File;
+ // OpenedMediaSource.Path = tempFile;
+ // OpenedMediaSource.ReadAtNativeFramerate = true;
+
+ MediaSource.Path = _appHost.GetApiUrlForLocalAccess() + "/LiveTv/LiveStreamFiles/" + UniqueId + "/stream.ts";
+ MediaSource.Protocol = MediaProtocol.Http;
+ // OpenedMediaSource.SupportsDirectPlay = false;
+ // OpenedMediaSource.SupportsDirectStream = true;
+ // OpenedMediaSource.SupportsTranscoding = true;
+
+ // await Task.Delay(5000).ConfigureAwait(false);
+ await taskCompletionSource.Task.ConfigureAwait(false);
+ }
+
+ private async Task StartStreaming(UdpClient udpClient, HdHomerunManager hdHomerunManager, IPAddress remoteAddress, TaskCompletionSource<bool> openTaskCompletionSource, CancellationToken cancellationToken)
+ {
+ using (udpClient)
+ using (hdHomerunManager)
+ {
+ try
+ {
+ await CopyTo(udpClient, TempFilePath, openTaskCompletionSource, cancellationToken).ConfigureAwait(false);
+ }
+ catch (Exception ex) when (ex is OperationCanceledException || ex is TimeoutException)
+ {
+ Logger.LogInformation("HDHR UDP stream cancelled or timed out from {0}", remoteAddress);
+ openTaskCompletionSource.TrySetException(ex);
+ }
+ catch (Exception ex)
+ {
+ Logger.LogError(ex, "Error opening live stream:");
+ openTaskCompletionSource.TrySetException(ex);
+ }
+
+ EnableStreamSharing = false;
+ }
+
+ await DeleteTempFiles(TempFilePath).ConfigureAwait(false);
+ }
+
+ private async Task CopyTo(UdpClient udpClient, string file, TaskCompletionSource<bool> openTaskCompletionSource, CancellationToken cancellationToken)
+ {
+ var resolved = false;
+
+ var fileStream = new FileStream(file, FileMode.Create, FileAccess.Write, FileShare.Read);
+ await using (fileStream.ConfigureAwait(false))
+ {
+ while (true)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ var res = await udpClient.ReceiveAsync(cancellationToken)
+ .AsTask()
+ .WaitAsync(TimeSpan.FromMilliseconds(30000), CancellationToken.None)
+ .ConfigureAwait(false);
+ var buffer = res.Buffer;
+
+ var read = buffer.Length - RtpHeaderBytes;
+
+ if (read > 0)
+ {
+ await fileStream.WriteAsync(buffer.AsMemory(RtpHeaderBytes, read), cancellationToken).ConfigureAwait(false);
+ }
+
+ if (!resolved)
+ {
+ resolved = true;
+ DateOpened = DateTime.UtcNow;
+ openTaskCompletionSource.TrySetResult(true);
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/src/Jellyfin.LiveTv/TunerHosts/HdHomerun/IHdHomerunChannelCommands.cs b/src/Jellyfin.LiveTv/TunerHosts/HdHomerun/IHdHomerunChannelCommands.cs
new file mode 100644
index 000000000..9fcf386f9
--- /dev/null
+++ b/src/Jellyfin.LiveTv/TunerHosts/HdHomerun/IHdHomerunChannelCommands.cs
@@ -0,0 +1,11 @@
+#pragma warning disable CS1591
+
+using System.Collections.Generic;
+
+namespace Jellyfin.LiveTv.TunerHosts.HdHomerun
+{
+ public interface IHdHomerunChannelCommands
+ {
+ IEnumerable<(string CommandName, string CommandValue)> GetCommands();
+ }
+}
diff --git a/src/Jellyfin.LiveTv/TunerHosts/HdHomerun/LegacyHdHomerunChannelCommands.cs b/src/Jellyfin.LiveTv/TunerHosts/HdHomerun/LegacyHdHomerunChannelCommands.cs
new file mode 100644
index 000000000..6dc9c885f
--- /dev/null
+++ b/src/Jellyfin.LiveTv/TunerHosts/HdHomerun/LegacyHdHomerunChannelCommands.cs
@@ -0,0 +1,40 @@
+#pragma warning disable CS1591
+
+using System.Collections.Generic;
+using System.Text.RegularExpressions;
+
+namespace Jellyfin.LiveTv.TunerHosts.HdHomerun
+{
+ public partial class LegacyHdHomerunChannelCommands : IHdHomerunChannelCommands
+ {
+ private string? _channel;
+ private string? _program;
+
+ public LegacyHdHomerunChannelCommands(string url)
+ {
+ // parse url for channel and program
+ var match = ChannelAndProgramRegex().Match(url);
+ if (match.Success)
+ {
+ _channel = match.Groups[1].Value;
+ _program = match.Groups[2].Value;
+ }
+ }
+
+ [GeneratedRegex(@"\/ch([0-9]+)-?([0-9]*)")]
+ private static partial Regex ChannelAndProgramRegex();
+
+ public IEnumerable<(string CommandName, string CommandValue)> GetCommands()
+ {
+ if (!string.IsNullOrEmpty(_channel))
+ {
+ yield return ("channel", _channel);
+ }
+
+ if (!string.IsNullOrEmpty(_program))
+ {
+ yield return ("program", _program);
+ }
+ }
+ }
+}
diff --git a/src/Jellyfin.LiveTv/TunerHosts/LiveStream.cs b/src/Jellyfin.LiveTv/TunerHosts/LiveStream.cs
new file mode 100644
index 000000000..70d8afc5d
--- /dev/null
+++ b/src/Jellyfin.LiveTv/TunerHosts/LiveStream.cs
@@ -0,0 +1,176 @@
+#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.Common.Configuration;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Model.Dto;
+using MediaBrowser.Model.IO;
+using MediaBrowser.Model.LiveTv;
+using Microsoft.Extensions.Logging;
+
+namespace Jellyfin.LiveTv.TunerHosts
+{
+ public class LiveStream : ILiveStream
+ {
+ private readonly IConfigurationManager _configurationManager;
+
+ public LiveStream(
+ MediaSourceInfo mediaSource,
+ TunerHostInfo tuner,
+ IFileSystem fileSystem,
+ ILogger logger,
+ IConfigurationManager configurationManager,
+ IStreamHelper streamHelper)
+ {
+ OriginalMediaSource = mediaSource;
+ FileSystem = fileSystem;
+ MediaSource = mediaSource;
+ Logger = logger;
+ EnableStreamSharing = true;
+ UniqueId = Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture);
+
+ if (tuner is not null)
+ {
+ TunerHostId = tuner.Id;
+ }
+
+ _configurationManager = configurationManager;
+ StreamHelper = streamHelper;
+
+ ConsumerCount = 1;
+ SetTempFilePath("ts");
+ }
+
+ protected IFileSystem FileSystem { get; }
+
+ protected IStreamHelper StreamHelper { get; }
+
+ protected ILogger Logger { get; }
+
+ protected CancellationTokenSource LiveStreamCancellationTokenSource { get; } = new CancellationTokenSource();
+
+ protected string TempFilePath { get; set; }
+
+ public MediaSourceInfo OriginalMediaSource { get; set; }
+
+ public MediaSourceInfo MediaSource { get; set; }
+
+ public int ConsumerCount { get; set; }
+
+ public string OriginalStreamId { get; set; }
+
+ public bool EnableStreamSharing { get; set; }
+
+ public string UniqueId { get; }
+
+ public string TunerHostId { get; }
+
+ public DateTime DateOpened { get; protected set; }
+
+ protected void SetTempFilePath(string extension)
+ {
+ TempFilePath = Path.Combine(_configurationManager.GetTranscodePath(), UniqueId + "." + extension);
+ }
+
+ public virtual Task Open(CancellationToken openCancellationToken)
+ {
+ DateOpened = DateTime.UtcNow;
+ return Task.CompletedTask;
+ }
+
+ public async Task Close()
+ {
+ EnableStreamSharing = false;
+
+ Logger.LogInformation("Closing {Type}", GetType().Name);
+
+ await LiveStreamCancellationTokenSource.CancelAsync().ConfigureAwait(false);
+ }
+
+ public Stream GetStream()
+ {
+ var stream = new FileStream(
+ TempFilePath,
+ FileMode.Open,
+ FileAccess.Read,
+ FileShare.ReadWrite,
+ IODefaults.FileStreamBufferSize,
+ FileOptions.SequentialScan | FileOptions.Asynchronous);
+
+ bool seekFile = (DateTime.UtcNow - DateOpened).TotalSeconds > 10;
+ if (seekFile)
+ {
+ TrySeek(stream, -20000);
+ }
+
+ return stream;
+ }
+
+ /// <inheritdoc />
+ public void Dispose()
+ {
+ Dispose(true);
+ GC.SuppressFinalize(this);
+ }
+
+ protected virtual void Dispose(bool dispose)
+ {
+ if (dispose)
+ {
+ LiveStreamCancellationTokenSource?.Dispose();
+ }
+ }
+
+ protected async Task DeleteTempFiles(string path, int retryCount = 0)
+ {
+ if (retryCount == 0)
+ {
+ Logger.LogInformation("Deleting temp file {FilePath}", path);
+ }
+
+ try
+ {
+ FileSystem.DeleteFile(path);
+ }
+ catch (Exception ex)
+ {
+ Logger.LogError(ex, "Error deleting file {FilePath}", path);
+ if (retryCount <= 40)
+ {
+ await Task.Delay(500).ConfigureAwait(false);
+ await DeleteTempFiles(path, retryCount + 1).ConfigureAwait(false);
+ }
+ }
+ }
+
+ private void TrySeek(FileStream stream, long offset)
+ {
+ if (!stream.CanSeek)
+ {
+ return;
+ }
+
+ try
+ {
+ stream.Seek(offset, SeekOrigin.End);
+ }
+ catch (IOException)
+ {
+ }
+ catch (ArgumentException)
+ {
+ }
+ catch (Exception ex)
+ {
+ Logger.LogError(ex, "Error seeking stream");
+ }
+ }
+ }
+}
diff --git a/src/Jellyfin.LiveTv/TunerHosts/M3UTunerHost.cs b/src/Jellyfin.LiveTv/TunerHosts/M3UTunerHost.cs
new file mode 100644
index 000000000..7235e65b6
--- /dev/null
+++ b/src/Jellyfin.LiveTv/TunerHosts/M3UTunerHost.cs
@@ -0,0 +1,220 @@
+#nullable disable
+
+#pragma warning disable CS1591
+
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.Linq;
+using System.Net.Http;
+using System.Threading;
+using System.Threading.Tasks;
+using Jellyfin.Extensions;
+using MediaBrowser.Common.Extensions;
+using MediaBrowser.Common.Net;
+using MediaBrowser.Controller;
+using MediaBrowser.Controller.Configuration;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.LiveTv;
+using MediaBrowser.Model.Dto;
+using MediaBrowser.Model.Entities;
+using MediaBrowser.Model.IO;
+using MediaBrowser.Model.LiveTv;
+using MediaBrowser.Model.MediaInfo;
+using Microsoft.Extensions.Logging;
+using Microsoft.Net.Http.Headers;
+
+namespace Jellyfin.LiveTv.TunerHosts
+{
+ public class M3UTunerHost : BaseTunerHost, ITunerHost, IConfigurableTunerHost
+ {
+ private static readonly string[] _disallowedMimeTypes =
+ {
+ "video/x-matroska",
+ "video/mp4",
+ "application/vnd.apple.mpegurl",
+ "application/mpegurl",
+ "application/x-mpegurl",
+ "video/vnd.mpeg.dash.mpd"
+ };
+
+ private readonly IHttpClientFactory _httpClientFactory;
+ private readonly IServerApplicationHost _appHost;
+ private readonly INetworkManager _networkManager;
+ private readonly IMediaSourceManager _mediaSourceManager;
+ private readonly IStreamHelper _streamHelper;
+
+ public M3UTunerHost(
+ IServerConfigurationManager config,
+ IMediaSourceManager mediaSourceManager,
+ ILogger<M3UTunerHost> logger,
+ IFileSystem fileSystem,
+ IHttpClientFactory httpClientFactory,
+ IServerApplicationHost appHost,
+ INetworkManager networkManager,
+ IStreamHelper streamHelper)
+ : base(config, logger, fileSystem)
+ {
+ _httpClientFactory = httpClientFactory;
+ _appHost = appHost;
+ _networkManager = networkManager;
+ _mediaSourceManager = mediaSourceManager;
+ _streamHelper = streamHelper;
+ }
+
+ public override string Type => "m3u";
+
+ public virtual string Name => "M3U Tuner";
+
+ private string GetFullChannelIdPrefix(TunerHostInfo info)
+ {
+ return ChannelIdPrefix + info.Url.GetMD5().ToString("N", CultureInfo.InvariantCulture);
+ }
+
+ protected override async Task<List<ChannelInfo>> GetChannelsInternal(TunerHostInfo tuner, CancellationToken cancellationToken)
+ {
+ var channelIdPrefix = GetFullChannelIdPrefix(tuner);
+
+ return await new M3uParser(Logger, _httpClientFactory)
+ .Parse(tuner, channelIdPrefix, cancellationToken)
+ .ConfigureAwait(false);
+ }
+
+ public Task<List<LiveTvTunerInfo>> GetTunerInfos(CancellationToken cancellationToken)
+ {
+ var list = GetTunerHosts()
+ .Select(i => new LiveTvTunerInfo()
+ {
+ Name = Name,
+ SourceType = Type,
+ Status = LiveTvTunerStatus.Available,
+ Id = i.Url.GetMD5().ToString("N", CultureInfo.InvariantCulture),
+ Url = i.Url
+ })
+ .ToList();
+
+ return Task.FromResult(list);
+ }
+
+ protected override async Task<ILiveStream> GetChannelStream(TunerHostInfo tunerHost, ChannelInfo channel, string streamId, IList<ILiveStream> currentLiveStreams, CancellationToken cancellationToken)
+ {
+ var tunerCount = tunerHost.TunerCount;
+
+ if (tunerCount > 0)
+ {
+ var tunerHostId = tunerHost.Id;
+ var liveStreams = currentLiveStreams.Where(i => string.Equals(i.TunerHostId, tunerHostId, StringComparison.OrdinalIgnoreCase));
+
+ if (liveStreams.Count() >= tunerCount)
+ {
+ throw new LiveTvConflictException("M3U simultaneous stream limit has been reached.");
+ }
+ }
+
+ var sources = await GetChannelStreamMediaSources(tunerHost, channel, cancellationToken).ConfigureAwait(false);
+
+ var mediaSource = sources[0];
+
+ if (mediaSource.Protocol == MediaProtocol.Http && !mediaSource.RequiresLooping)
+ {
+ using var message = new HttpRequestMessage(HttpMethod.Head, mediaSource.Path);
+ using var response = await _httpClientFactory.CreateClient(NamedClient.Default)
+ .SendAsync(message, cancellationToken)
+ .ConfigureAwait(false);
+
+ response.EnsureSuccessStatusCode();
+
+ if (!_disallowedMimeTypes.Contains(response.Content.Headers.ContentType?.ToString(), StringComparison.OrdinalIgnoreCase))
+ {
+ return new SharedHttpStream(mediaSource, tunerHost, streamId, FileSystem, _httpClientFactory, Logger, Config, _appHost, _streamHelper);
+ }
+ }
+
+ return new LiveStream(mediaSource, tunerHost, FileSystem, Logger, Config, _streamHelper);
+ }
+
+ public async Task Validate(TunerHostInfo info)
+ {
+ using (await new M3uParser(Logger, _httpClientFactory).GetListingsStream(info, CancellationToken.None).ConfigureAwait(false))
+ {
+ }
+ }
+
+ protected override Task<List<MediaSourceInfo>> GetChannelStreamMediaSources(TunerHostInfo tuner, ChannelInfo channel, CancellationToken cancellationToken)
+ {
+ return Task.FromResult(new List<MediaSourceInfo> { CreateMediaSourceInfo(tuner, channel) });
+ }
+
+ protected virtual MediaSourceInfo CreateMediaSourceInfo(TunerHostInfo info, ChannelInfo channel)
+ {
+ var path = channel.Path;
+
+ var supportsDirectPlay = !info.EnableStreamLooping && info.TunerCount == 0;
+ var supportsDirectStream = !info.EnableStreamLooping;
+
+ var protocol = _mediaSourceManager.GetPathProtocol(path);
+
+ var isRemote = true;
+ if (Uri.TryCreate(path, UriKind.Absolute, out var uri))
+ {
+ isRemote = !_networkManager.IsInLocalNetwork(uri.Host);
+ }
+
+ var httpHeaders = new Dictionary<string, string>();
+
+ if (protocol == MediaProtocol.Http)
+ {
+ // Use user-defined user-agent. If there isn't one, make it look like a browser.
+ httpHeaders[HeaderNames.UserAgent] = string.IsNullOrWhiteSpace(info.UserAgent) ?
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/64.0.3282.85 Safari/537.36" :
+ info.UserAgent;
+ }
+
+ var mediaSource = new MediaSourceInfo
+ {
+ Path = path,
+ Protocol = protocol,
+ 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,
+ 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
+ }
+ },
+ RequiresOpening = true,
+ RequiresClosing = true,
+ RequiresLooping = info.EnableStreamLooping,
+
+ ReadAtNativeFramerate = false,
+
+ Id = channel.Path.GetMD5().ToString("N", CultureInfo.InvariantCulture),
+ IsInfiniteStream = true,
+ IsRemote = isRemote,
+
+ IgnoreDts = info.IgnoreDts,
+ SupportsDirectPlay = supportsDirectPlay,
+ SupportsDirectStream = supportsDirectStream,
+
+ RequiredHttpHeaders = httpHeaders
+ };
+
+ mediaSource.InferTotalBitrate();
+
+ return mediaSource;
+ }
+
+ public Task<List<TunerHostInfo>> DiscoverDevices(int discoveryDurationMs, CancellationToken cancellationToken)
+ {
+ return Task.FromResult(new List<TunerHostInfo>());
+ }
+ }
+}
diff --git a/src/Jellyfin.LiveTv/TunerHosts/M3uParser.cs b/src/Jellyfin.LiveTv/TunerHosts/M3uParser.cs
new file mode 100644
index 000000000..5900d1c5b
--- /dev/null
+++ b/src/Jellyfin.LiveTv/TunerHosts/M3uParser.cs
@@ -0,0 +1,326 @@
+#nullable disable
+
+#pragma warning disable CS1591
+
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.IO;
+using System.Net.Http;
+using System.Text.RegularExpressions;
+using System.Threading;
+using System.Threading.Tasks;
+using Jellyfin.Extensions;
+using MediaBrowser.Common.Extensions;
+using MediaBrowser.Common.Net;
+using MediaBrowser.Controller.LiveTv;
+using MediaBrowser.Model.IO;
+using MediaBrowser.Model.LiveTv;
+using Microsoft.Extensions.Logging;
+
+namespace Jellyfin.LiveTv.TunerHosts
+{
+ public partial class M3uParser
+ {
+ private const string ExtInfPrefix = "#EXTINF:";
+
+ private readonly ILogger _logger;
+ private readonly IHttpClientFactory _httpClientFactory;
+
+ public M3uParser(ILogger logger, IHttpClientFactory httpClientFactory)
+ {
+ _logger = logger;
+ _httpClientFactory = httpClientFactory;
+ }
+
+ [GeneratedRegex(@"([a-z0-9\-_]+)=\""([^""]+)\""", RegexOptions.IgnoreCase, "en-US")]
+ private static partial Regex KeyValueRegex();
+
+ public async Task<List<ChannelInfo>> Parse(TunerHostInfo info, string channelIdPrefix, CancellationToken cancellationToken)
+ {
+ // Read the file and display it line by line.
+ using (var reader = new StreamReader(await GetListingsStream(info, cancellationToken).ConfigureAwait(false)))
+ {
+ return await GetChannelsAsync(reader, channelIdPrefix, info.Id).ConfigureAwait(false);
+ }
+ }
+
+ public async Task<Stream> GetListingsStream(TunerHostInfo info, CancellationToken cancellationToken)
+ {
+ ArgumentNullException.ThrowIfNull(info);
+
+ if (!info.Url.StartsWith("http", StringComparison.OrdinalIgnoreCase))
+ {
+ return AsyncFile.OpenRead(info.Url);
+ }
+
+ using var requestMessage = new HttpRequestMessage(HttpMethod.Get, info.Url);
+ if (!string.IsNullOrEmpty(info.UserAgent))
+ {
+ requestMessage.Headers.UserAgent.TryParseAdd(info.UserAgent);
+ }
+
+ // Set HttpCompletionOption.ResponseHeadersRead to prevent timeouts on larger files
+ var response = await _httpClientFactory.CreateClient(NamedClient.Default)
+ .SendAsync(requestMessage, HttpCompletionOption.ResponseHeadersRead, cancellationToken)
+ .ConfigureAwait(false);
+ response.EnsureSuccessStatusCode();
+
+ return await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
+ }
+
+ private async Task<List<ChannelInfo>> GetChannelsAsync(TextReader reader, string channelIdPrefix, string tunerHostId)
+ {
+ var channels = new List<ChannelInfo>();
+ string extInf = string.Empty;
+
+ await foreach (var line in reader.ReadAllLinesAsync().ConfigureAwait(false))
+ {
+ var trimmedLine = line.Trim();
+ if (string.IsNullOrWhiteSpace(trimmedLine))
+ {
+ continue;
+ }
+
+ if (trimmedLine.StartsWith("#EXTM3U", StringComparison.OrdinalIgnoreCase))
+ {
+ continue;
+ }
+
+ if (trimmedLine.StartsWith(ExtInfPrefix, StringComparison.OrdinalIgnoreCase))
+ {
+ extInf = trimmedLine.Substring(ExtInfPrefix.Length).Trim();
+ }
+ else if (!string.IsNullOrWhiteSpace(extInf) && !trimmedLine.StartsWith('#'))
+ {
+ var channel = GetChannelnfo(extInf, tunerHostId, trimmedLine);
+ channel.Id = channelIdPrefix + trimmedLine.GetMD5().ToString("N", CultureInfo.InvariantCulture);
+
+ channel.Path = trimmedLine;
+ channels.Add(channel);
+ _logger.LogInformation("Parsed channel: {ChannelName}", channel.Name);
+ extInf = string.Empty;
+ }
+ }
+
+ return channels;
+ }
+
+ private ChannelInfo GetChannelnfo(string extInf, string tunerHostId, string mediaUrl)
+ {
+ var channel = new ChannelInfo()
+ {
+ TunerHostId = tunerHostId
+ };
+
+ extInf = extInf.Trim();
+
+ var attributes = ParseExtInf(extInf, out string remaining);
+ extInf = remaining;
+
+ if (attributes.TryGetValue("tvg-logo", out string tvgLogo))
+ {
+ channel.ImageUrl = tvgLogo;
+ }
+ else if (attributes.TryGetValue("logo", out string logo))
+ {
+ channel.ImageUrl = logo;
+ }
+
+ if (attributes.TryGetValue("group-title", out string groupTitle))
+ {
+ channel.ChannelGroup = groupTitle;
+ }
+
+ channel.Name = GetChannelName(extInf, attributes);
+ channel.Number = GetChannelNumber(extInf, attributes, mediaUrl);
+
+ attributes.TryGetValue("tvg-id", out string tvgId);
+
+ attributes.TryGetValue("channel-id", out string channelId);
+
+ channel.TunerChannelId = string.IsNullOrWhiteSpace(tvgId) ? channelId : tvgId;
+
+ var channelIdValues = new List<string>();
+ if (!string.IsNullOrWhiteSpace(channelId))
+ {
+ channelIdValues.Add(channelId);
+ }
+
+ if (!string.IsNullOrWhiteSpace(tvgId))
+ {
+ channelIdValues.Add(tvgId);
+ }
+
+ if (channelIdValues.Count > 0)
+ {
+ channel.Id = string.Join('_', channelIdValues);
+ }
+
+ return channel;
+ }
+
+ private string GetChannelNumber(string extInf, Dictionary<string, string> attributes, string mediaUrl)
+ {
+ var nameParts = extInf.Split(',', StringSplitOptions.RemoveEmptyEntries);
+ var nameInExtInf = nameParts.Length > 1 ? nameParts[^1].AsSpan().Trim() : ReadOnlySpan<char>.Empty;
+
+ string numberString = null;
+
+ if (attributes.TryGetValue("tvg-chno", out var attributeValue)
+ && double.TryParse(attributeValue, CultureInfo.InvariantCulture, out _))
+ {
+ numberString = attributeValue;
+ }
+
+ if (!IsValidChannelNumber(numberString))
+ {
+ if (attributes.TryGetValue("tvg-id", out attributeValue))
+ {
+ if (double.TryParse(attributeValue, CultureInfo.InvariantCulture, out _))
+ {
+ numberString = attributeValue;
+ }
+ else if (attributes.TryGetValue("channel-id", out attributeValue)
+ && double.TryParse(attributeValue, CultureInfo.InvariantCulture, out _))
+ {
+ numberString = attributeValue;
+ }
+ }
+
+ if (string.IsNullOrWhiteSpace(numberString))
+ {
+ // Using this as a fallback now as this leads to Problems with channels like "5 USA"
+ // where 5 isn't meant to be the channel number
+ // Check for channel number with the format from SatIp
+ // #EXTINF:0,84. VOX Schweiz
+ // #EXTINF:0,84.0 - VOX Schweiz
+ if (!nameInExtInf.IsEmpty && !nameInExtInf.IsWhiteSpace())
+ {
+ var numberIndex = nameInExtInf.IndexOf(' ');
+ if (numberIndex > 0)
+ {
+ var numberPart = nameInExtInf.Slice(0, numberIndex).Trim(new[] { ' ', '.' });
+
+ if (double.TryParse(numberPart, CultureInfo.InvariantCulture, out _))
+ {
+ numberString = numberPart.ToString();
+ }
+ }
+ }
+ }
+ }
+
+ if (!IsValidChannelNumber(numberString))
+ {
+ numberString = null;
+ }
+
+ if (!string.IsNullOrWhiteSpace(numberString))
+ {
+ numberString = numberString.Trim();
+ }
+ else
+ {
+ if (string.IsNullOrWhiteSpace(mediaUrl))
+ {
+ numberString = null;
+ }
+ else
+ {
+ try
+ {
+ numberString = Path.GetFileNameWithoutExtension(mediaUrl.AsSpan().RightPart('/')).ToString();
+
+ if (!IsValidChannelNumber(numberString))
+ {
+ numberString = null;
+ }
+ }
+ catch
+ {
+ // Seeing occasional argument exception here
+ numberString = null;
+ }
+ }
+ }
+
+ return numberString;
+ }
+
+ private static bool IsValidChannelNumber(string numberString)
+ {
+ if (string.IsNullOrWhiteSpace(numberString)
+ || string.Equals(numberString, "-1", StringComparison.Ordinal)
+ || string.Equals(numberString, "0", StringComparison.Ordinal))
+ {
+ return false;
+ }
+
+ return double.TryParse(numberString, CultureInfo.InvariantCulture, out _);
+ }
+
+ private static string GetChannelName(string extInf, Dictionary<string, string> attributes)
+ {
+ var nameParts = extInf.Split(',', StringSplitOptions.RemoveEmptyEntries);
+ var nameInExtInf = nameParts.Length > 1 ? nameParts[^1].Trim() : null;
+
+ // Check for channel number with the format from SatIp
+ // #EXTINF:0,84. VOX Schweiz
+ // #EXTINF:0,84.0 - VOX Schweiz
+ if (!string.IsNullOrWhiteSpace(nameInExtInf))
+ {
+ var numberIndex = nameInExtInf.IndexOf(' ', StringComparison.Ordinal);
+ if (numberIndex > 0)
+ {
+ var numberPart = nameInExtInf.Substring(0, numberIndex).Trim(new[] { ' ', '.' });
+
+ if (double.TryParse(numberPart, CultureInfo.InvariantCulture, out _))
+ {
+ // channel.Number = number.ToString();
+ nameInExtInf = nameInExtInf.Substring(numberIndex + 1).Trim(new[] { ' ', '-' });
+ }
+ }
+ }
+
+ string name = nameInExtInf;
+
+ if (string.IsNullOrWhiteSpace(name))
+ {
+ attributes.TryGetValue("tvg-name", out name);
+ }
+
+ if (string.IsNullOrWhiteSpace(name))
+ {
+ attributes.TryGetValue("tvg-id", out name);
+ }
+
+ if (string.IsNullOrWhiteSpace(name))
+ {
+ name = null;
+ }
+
+ return name;
+ }
+
+ private static Dictionary<string, string> ParseExtInf(string line, out string remaining)
+ {
+ var dict = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
+
+ var matches = KeyValueRegex().Matches(line);
+
+ remaining = line;
+
+ foreach (Match match in matches)
+ {
+ var key = match.Groups[1].Value;
+ var value = match.Groups[2].Value;
+
+ dict[key] = value;
+ remaining = remaining.Replace(key + "=\"" + value + "\"", string.Empty, StringComparison.OrdinalIgnoreCase);
+ }
+
+ return dict;
+ }
+ }
+}
diff --git a/src/Jellyfin.LiveTv/TunerHosts/SharedHttpStream.cs b/src/Jellyfin.LiveTv/TunerHosts/SharedHttpStream.cs
new file mode 100644
index 000000000..5ef04ad9e
--- /dev/null
+++ b/src/Jellyfin.LiveTv/TunerHosts/SharedHttpStream.cs
@@ -0,0 +1,135 @@
+#pragma warning disable CA1711
+#pragma warning disable CS1591
+
+using System;
+using System.Globalization;
+using System.IO;
+using System.Net.Http;
+using System.Threading;
+using System.Threading.Tasks;
+using MediaBrowser.Common.Configuration;
+using MediaBrowser.Common.Net;
+using MediaBrowser.Controller;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Model.Dto;
+using MediaBrowser.Model.IO;
+using MediaBrowser.Model.LiveTv;
+using MediaBrowser.Model.MediaInfo;
+using Microsoft.Extensions.Logging;
+
+namespace Jellyfin.LiveTv.TunerHosts
+{
+ public class SharedHttpStream : LiveStream, IDirectStreamProvider
+ {
+ private readonly IHttpClientFactory _httpClientFactory;
+ private readonly IServerApplicationHost _appHost;
+
+ public SharedHttpStream(
+ MediaSourceInfo mediaSource,
+ TunerHostInfo tunerHostInfo,
+ string originalStreamId,
+ IFileSystem fileSystem,
+ IHttpClientFactory httpClientFactory,
+ ILogger logger,
+ IConfigurationManager configurationManager,
+ IServerApplicationHost appHost,
+ IStreamHelper streamHelper)
+ : base(mediaSource, tunerHostInfo, fileSystem, logger, configurationManager, streamHelper)
+ {
+ _httpClientFactory = httpClientFactory;
+ _appHost = appHost;
+ OriginalStreamId = originalStreamId;
+ }
+
+ public override async Task Open(CancellationToken openCancellationToken)
+ {
+ LiveStreamCancellationTokenSource.Token.ThrowIfCancellationRequested();
+
+ var mediaSource = OriginalMediaSource;
+
+ var url = mediaSource.Path;
+
+ Directory.CreateDirectory(Path.GetDirectoryName(TempFilePath) ?? throw new InvalidOperationException("Path can't be a root directory."));
+
+ var typeName = GetType().Name;
+ Logger.LogInformation("Opening {StreamType} Live stream from {Url}", typeName, url);
+
+ // Response stream is disposed manually.
+ var response = await _httpClientFactory.CreateClient(NamedClient.Default)
+ .GetAsync(url, HttpCompletionOption.ResponseHeadersRead, CancellationToken.None)
+ .ConfigureAwait(false);
+
+ var taskCompletionSource = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
+
+ _ = StartStreaming(response, taskCompletionSource, LiveStreamCancellationTokenSource.Token);
+
+ MediaSource.Path = _appHost.GetApiUrlForLocalAccess() + "/LiveTv/LiveStreamFiles/" + UniqueId + "/stream.ts";
+ MediaSource.Protocol = MediaProtocol.Http;
+
+ var res = await taskCompletionSource.Task.ConfigureAwait(false);
+ if (!res)
+ {
+ Logger.LogWarning("Zero bytes copied from stream {StreamType} to {FilePath} but no exception raised", GetType().Name, TempFilePath);
+ throw new EndOfStreamException(string.Format(CultureInfo.InvariantCulture, "Zero bytes copied from stream {0}", GetType().Name));
+ }
+ }
+
+ private Task StartStreaming(HttpResponseMessage response, TaskCompletionSource<bool> openTaskCompletionSource, CancellationToken cancellationToken)
+ {
+ return Task.Run(
+ async () =>
+ {
+ try
+ {
+ Logger.LogInformation("Beginning {StreamType} stream to {FilePath}", GetType().Name, TempFilePath);
+ using (response)
+ {
+ var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
+ await using (stream.ConfigureAwait(false))
+ {
+ var fileStream = new FileStream(
+ TempFilePath,
+ FileMode.Create,
+ FileAccess.Write,
+ FileShare.Read,
+ IODefaults.FileStreamBufferSize,
+ FileOptions.Asynchronous);
+
+ await using (fileStream.ConfigureAwait(false))
+ {
+ await StreamHelper.CopyToAsync(
+ stream,
+ fileStream,
+ IODefaults.CopyToBufferSize,
+ () => Resolve(openTaskCompletionSource),
+ cancellationToken).ConfigureAwait(false);
+ }
+ }
+ }
+ }
+ catch (OperationCanceledException ex)
+ {
+ Logger.LogInformation("Copying of {StreamType} to {FilePath} was canceled", GetType().Name, TempFilePath);
+ openTaskCompletionSource.TrySetException(ex);
+ }
+ catch (Exception ex)
+ {
+ Logger.LogError(ex, "Error copying live stream {StreamType} to {FilePath}", GetType().Name, TempFilePath);
+ openTaskCompletionSource.TrySetException(ex);
+ }
+
+ openTaskCompletionSource.TrySetResult(false);
+
+ EnableStreamSharing = false;
+ await DeleteTempFiles(TempFilePath).ConfigureAwait(false);
+ },
+ CancellationToken.None);
+ }
+
+ private void Resolve(TaskCompletionSource<bool> openTaskCompletionSource)
+ {
+ DateOpened = DateTime.UtcNow;
+ openTaskCompletionSource.TrySetResult(true);
+ }
+ }
+}