#nullable disable #pragma warning disable CS1591 using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.Diagnostics; using System.Globalization; using System.IO; using System.Linq; using System.Net.Http; using System.Text; using System.Threading; using System.Threading.Tasks; using System.Xml; using Emby.Server.Implementations.Library; using Jellyfin.Data.Enums; using Jellyfin.Data.Events; using Jellyfin.Extensions; using MediaBrowser.Common.Configuration; using MediaBrowser.Common.Extensions; using MediaBrowser.Common.Progress; using MediaBrowser.Controller; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.TV; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.LiveTv; using MediaBrowser.Controller.MediaEncoding; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Configuration; using MediaBrowser.Model.Dto; using MediaBrowser.Model.Entities; using MediaBrowser.Model.IO; using MediaBrowser.Model.LiveTv; using MediaBrowser.Model.MediaInfo; using MediaBrowser.Model.Providers; using MediaBrowser.Model.Querying; using Microsoft.Extensions.Logging; namespace Emby.Server.Implementations.LiveTv.EmbyTV { public class EmbyTV : ILiveTvService, ISupportsDirectStreamProvider, ISupportsNewTimerIds, IDisposable { public const string DateAddedFormat = "yyyy-MM-dd HH:mm:ss"; private const int TunerDiscoveryDurationMs = 3000; private readonly IServerApplicationHost _appHost; private readonly ILogger _logger; private readonly IHttpClientFactory _httpClientFactory; private readonly IServerConfigurationManager _config; private readonly ItemDataProvider _seriesTimerProvider; private readonly TimerManager _timerProvider; private readonly LiveTvManager _liveTvManager; private readonly IFileSystem _fileSystem; private readonly ILibraryMonitor _libraryMonitor; private readonly ILibraryManager _libraryManager; private readonly IProviderManager _providerManager; private readonly IMediaEncoder _mediaEncoder; private readonly IMediaSourceManager _mediaSourceManager; private readonly IStreamHelper _streamHelper; private readonly ConcurrentDictionary _activeRecordings = new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase); private readonly ConcurrentDictionary _epgChannels = new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase); private readonly SemaphoreSlim _recordingDeleteSemaphore = new SemaphoreSlim(1, 1); private bool _disposed = false; public EmbyTV( IServerApplicationHost appHost, IStreamHelper streamHelper, IMediaSourceManager mediaSourceManager, ILogger logger, IHttpClientFactory httpClientFactory, IServerConfigurationManager config, ILiveTvManager liveTvManager, IFileSystem fileSystem, ILibraryManager libraryManager, ILibraryMonitor libraryMonitor, IProviderManager providerManager, IMediaEncoder mediaEncoder) { Current = this; _appHost = appHost; _logger = logger; _httpClientFactory = httpClientFactory; _config = config; _fileSystem = fileSystem; _libraryManager = libraryManager; _libraryMonitor = libraryMonitor; _providerManager = providerManager; _mediaEncoder = mediaEncoder; _liveTvManager = (LiveTvManager)liveTvManager; _mediaSourceManager = mediaSourceManager; _streamHelper = streamHelper; _seriesTimerProvider = new SeriesTimerManager(_logger, Path.Combine(DataPath, "seriestimers.json")); _timerProvider = new TimerManager(_logger, Path.Combine(DataPath, "timers.json")); _timerProvider.TimerFired += OnTimerProviderTimerFired; _config.NamedConfigurationUpdated += OnNamedConfigurationUpdated; } public event EventHandler> TimerCreated; public event EventHandler> TimerCancelled; public static EmbyTV Current { get; private set; } /// public string Name => "Emby"; public string DataPath => Path.Combine(_config.CommonApplicationPaths.DataPath, "livetv"); /// public string HomePageUrl => "https://github.com/jellyfin/jellyfin"; private string DefaultRecordingPath => Path.Combine(DataPath, "recordings"); private string RecordingPath { get { var path = GetConfiguration().RecordingPath; return string.IsNullOrWhiteSpace(path) ? DefaultRecordingPath : path; } } private async void OnNamedConfigurationUpdated(object sender, ConfigurationUpdateEventArgs e) { if (string.Equals(e.Key, "livetv", StringComparison.OrdinalIgnoreCase)) { await CreateRecordingFolders().ConfigureAwait(false); } } public Task Start() { _timerProvider.RestartTimers(); return CreateRecordingFolders(); } internal async Task CreateRecordingFolders() { try { var recordingFolders = GetRecordingFolders().ToArray(); var virtualFolders = _libraryManager.GetVirtualFolders(); var allExistingPaths = virtualFolders.SelectMany(i => i.Locations).ToList(); var pathsAdded = new List(); foreach (var recordingFolder in recordingFolders) { var pathsToCreate = recordingFolder.Locations .Where(i => !allExistingPaths.Any(p => _fileSystem.AreEqual(p, i))) .ToList(); if (pathsToCreate.Count == 0) { continue; } var mediaPathInfos = pathsToCreate.Select(i => new MediaPathInfo(i)).ToArray(); var libraryOptions = new LibraryOptions { PathInfos = mediaPathInfos }; try { await _libraryManager.AddVirtualFolder(recordingFolder.Name, recordingFolder.CollectionType, libraryOptions, true).ConfigureAwait(false); } catch (Exception ex) { _logger.LogError(ex, "Error creating virtual folder"); } pathsAdded.AddRange(pathsToCreate); } var config = GetConfiguration(); var pathsToRemove = config.MediaLocationsCreated .Except(recordingFolders.SelectMany(i => i.Locations)) .ToList(); if (pathsAdded.Count > 0 || pathsToRemove.Count > 0) { pathsAdded.InsertRange(0, config.MediaLocationsCreated); config.MediaLocationsCreated = pathsAdded.Except(pathsToRemove).Distinct(StringComparer.OrdinalIgnoreCase).ToArray(); _config.SaveConfiguration("livetv", config); } foreach (var path in pathsToRemove) { await RemovePathFromLibraryAsync(path).ConfigureAwait(false); } } catch (Exception ex) { _logger.LogError(ex, "Error creating recording folders"); } } private async Task RemovePathFromLibraryAsync(string path) { _logger.LogDebug("Removing path from library: {0}", path); var requiresRefresh = false; var virtualFolders = _libraryManager.GetVirtualFolders(); foreach (var virtualFolder in virtualFolders) { if (!virtualFolder.Locations.Contains(path, StringComparison.OrdinalIgnoreCase)) { continue; } if (virtualFolder.Locations.Length == 1) { // remove entire virtual folder try { await _libraryManager.RemoveVirtualFolder(virtualFolder.Name, true).ConfigureAwait(false); } catch (Exception ex) { _logger.LogError(ex, "Error removing virtual folder"); } } else { try { _libraryManager.RemoveMediaPath(virtualFolder.Name, path); requiresRefresh = true; } catch (Exception ex) { _logger.LogError(ex, "Error removing media path"); } } } if (requiresRefresh) { await _libraryManager.ValidateMediaLibrary(new SimpleProgress(), CancellationToken.None).ConfigureAwait(false); } } public async Task RefreshSeriesTimers(CancellationToken cancellationToken) { var seriesTimers = await GetSeriesTimersAsync(cancellationToken).ConfigureAwait(false); foreach (var timer in seriesTimers) { UpdateTimersForSeriesTimer(timer, false, true); } } public async Task RefreshTimers(CancellationToken cancellationToken) { var timers = await GetTimersAsync(cancellationToken).ConfigureAwait(false); var tempChannelCache = new Dictionary(); foreach (var timer in timers) { if (DateTime.UtcNow > timer.EndDate && !_activeRecordings.ContainsKey(timer.Id)) { OnTimerOutOfDate(timer); continue; } if (string.IsNullOrWhiteSpace(timer.ProgramId) || string.IsNullOrWhiteSpace(timer.ChannelId)) { continue; } var program = GetProgramInfoFromCache(timer); if (program is null) { OnTimerOutOfDate(timer); continue; } CopyProgramInfoToTimerInfo(program, timer, tempChannelCache); _timerProvider.Update(timer); } } private void OnTimerOutOfDate(TimerInfo timer) { _timerProvider.Delete(timer); } private async Task> GetChannelsAsync(bool enableCache, CancellationToken cancellationToken) { var list = new List(); foreach (var hostInstance in _liveTvManager.TunerHosts) { try { var channels = await hostInstance.GetChannels(enableCache, cancellationToken).ConfigureAwait(false); list.AddRange(channels); } catch (Exception ex) { _logger.LogError(ex, "Error getting channels"); } } foreach (var provider in GetListingProviders()) { var enabledChannels = list .Where(i => IsListingProviderEnabledForTuner(provider.Item2, i.TunerHostId)) .ToList(); if (enabledChannels.Count > 0) { try { await AddMetadata(provider.Item1, provider.Item2, enabledChannels, enableCache, cancellationToken).ConfigureAwait(false); } catch (NotSupportedException) { } catch (Exception ex) { _logger.LogError(ex, "Error adding metadata"); } } } return list; } private async Task AddMetadata( IListingsProvider provider, ListingsProviderInfo info, IEnumerable tunerChannels, bool enableCache, CancellationToken cancellationToken) { var epgChannels = await GetEpgChannels(provider, info, enableCache, cancellationToken).ConfigureAwait(false); foreach (var tunerChannel in tunerChannels) { var epgChannel = GetEpgChannelFromTunerChannel(info, tunerChannel, epgChannels); if (epgChannel is not null) { if (!string.IsNullOrWhiteSpace(epgChannel.Name)) { // tunerChannel.Name = epgChannel.Name; } if (!string.IsNullOrWhiteSpace(epgChannel.ImageUrl)) { tunerChannel.ImageUrl = epgChannel.ImageUrl; } } } } private async Task GetEpgChannels( IListingsProvider provider, ListingsProviderInfo info, bool enableCache, CancellationToken cancellationToken) { if (!enableCache || !_epgChannels.TryGetValue(info.Id, out var result)) { var channels = await provider.GetChannels(info, cancellationToken).ConfigureAwait(false); foreach (var channel in channels) { _logger.LogInformation("Found epg channel in {0} {1} {2} {3}", provider.Name, info.ListingsId, channel.Name, channel.Id); } result = new EpgChannelData(channels); _epgChannels.AddOrUpdate(info.Id, result, (_, _) => result); } return result; } private async Task GetEpgChannelFromTunerChannel(IListingsProvider provider, ListingsProviderInfo info, ChannelInfo tunerChannel, CancellationToken cancellationToken) { var epgChannels = await GetEpgChannels(provider, info, true, cancellationToken).ConfigureAwait(false); return GetEpgChannelFromTunerChannel(info, tunerChannel, epgChannels); } private static string GetMappedChannel(string channelId, NameValuePair[] mappings) { foreach (NameValuePair mapping in mappings) { if (string.Equals(mapping.Name, channelId, StringComparison.OrdinalIgnoreCase)) { return mapping.Value; } } return channelId; } internal ChannelInfo GetEpgChannelFromTunerChannel(NameValuePair[] mappings, ChannelInfo tunerChannel, List epgChannels) { return GetEpgChannelFromTunerChannel(mappings, tunerChannel, new EpgChannelData(epgChannels)); } private ChannelInfo GetEpgChannelFromTunerChannel(ListingsProviderInfo info, ChannelInfo tunerChannel, EpgChannelData epgChannels) { return GetEpgChannelFromTunerChannel(info.ChannelMappings, tunerChannel, epgChannels); } private ChannelInfo GetEpgChannelFromTunerChannel( NameValuePair[] mappings, ChannelInfo tunerChannel, EpgChannelData epgChannelData) { if (!string.IsNullOrWhiteSpace(tunerChannel.Id)) { var mappedTunerChannelId = GetMappedChannel(tunerChannel.Id, mappings); if (string.IsNullOrWhiteSpace(mappedTunerChannelId)) { mappedTunerChannelId = tunerChannel.Id; } var channel = epgChannelData.GetChannelById(mappedTunerChannelId); if (channel is not null) { return channel; } } if (!string.IsNullOrWhiteSpace(tunerChannel.TunerChannelId)) { var tunerChannelId = tunerChannel.TunerChannelId; if (tunerChannelId.Contains(".json.schedulesdirect.org", StringComparison.OrdinalIgnoreCase)) { tunerChannelId = tunerChannelId.Replace(".json.schedulesdirect.org", string.Empty, StringComparison.OrdinalIgnoreCase).TrimStart('I'); } var mappedTunerChannelId = GetMappedChannel(tunerChannelId, mappings); if (string.IsNullOrWhiteSpace(mappedTunerChannelId)) { mappedTunerChannelId = tunerChannelId; } var channel = epgChannelData.GetChannelById(mappedTunerChannelId); if (channel is not null) { return channel; } } if (!string.IsNullOrWhiteSpace(tunerChannel.Number)) { var tunerChannelNumber = GetMappedChannel(tunerChannel.Number, mappings); if (string.IsNullOrWhiteSpace(tunerChannelNumber)) { tunerChannelNumber = tunerChannel.Number; } var channel = epgChannelData.GetChannelByNumber(tunerChannelNumber); if (channel is not null) { return channel; } } if (!string.IsNullOrWhiteSpace(tunerChannel.Name)) { var normalizedName = EpgChannelData.NormalizeName(tunerChannel.Name); var channel = epgChannelData.GetChannelByName(normalizedName); if (channel is not null) { return channel; } } return null; } public async Task> GetChannelsForListingsProvider(ListingsProviderInfo listingsProvider, CancellationToken cancellationToken) { var list = new List(); foreach (var hostInstance in _liveTvManager.TunerHosts) { try { var channels = await hostInstance.GetChannels(false, cancellationToken).ConfigureAwait(false); list.AddRange(channels); } catch (Exception ex) { _logger.LogError(ex, "Error getting channels"); } } return list .Where(i => IsListingProviderEnabledForTuner(listingsProvider, i.TunerHostId)) .ToList(); } public Task> GetChannelsAsync(CancellationToken cancellationToken) { return GetChannelsAsync(false, cancellationToken); } public Task CancelSeriesTimerAsync(string timerId, CancellationToken cancellationToken) { var timers = _timerProvider .GetAll() .Where(i => string.Equals(i.SeriesTimerId, timerId, StringComparison.OrdinalIgnoreCase)) .ToList(); foreach (var timer in timers) { CancelTimerInternal(timer.Id, true, true); } var remove = _seriesTimerProvider.GetAll().FirstOrDefault(r => string.Equals(r.Id, timerId, StringComparison.OrdinalIgnoreCase)); if (remove is not null) { _seriesTimerProvider.Delete(remove); } return Task.CompletedTask; } private void CancelTimerInternal(string timerId, bool isSeriesCancelled, bool isManualCancellation) { var timer = _timerProvider.GetTimer(timerId); if (timer is not null) { var statusChanging = timer.Status != RecordingStatus.Cancelled; timer.Status = RecordingStatus.Cancelled; if (isManualCancellation) { timer.IsManual = true; } if (string.IsNullOrWhiteSpace(timer.SeriesTimerId) || isSeriesCancelled) { _timerProvider.Delete(timer); } else { _timerProvider.AddOrUpdate(timer, false); } if (statusChanging && TimerCancelled is not null) { TimerCancelled(this, new GenericEventArgs(timerId)); } } if (_activeRecordings.TryGetValue(timerId, out var activeRecordingInfo)) { activeRecordingInfo.Timer = timer; activeRecordingInfo.CancellationTokenSource.Cancel(); } } public Task CancelTimerAsync(string timerId, CancellationToken cancellationToken) { CancelTimerInternal(timerId, false, true); return Task.CompletedTask; } public Task CreateSeriesTimerAsync(SeriesTimerInfo info, CancellationToken cancellationToken) { throw new NotImplementedException(); } public Task CreateTimerAsync(TimerInfo info, CancellationToken cancellationToken) { throw new NotImplementedException(); } public Task CreateTimer(TimerInfo info, CancellationToken cancellationToken) { var existingTimer = string.IsNullOrWhiteSpace(info.ProgramId) ? null : _timerProvider.GetTimerByProgramId(info.ProgramId); if (existingTimer is not null) { if (existingTimer.Status == RecordingStatus.Cancelled || existingTimer.Status == RecordingStatus.Completed) { existingTimer.Status = RecordingStatus.New; existingTimer.IsManual = true; _timerProvider.Update(existingTimer); return Task.FromResult(existingTimer.Id); } else { throw new ArgumentException("A scheduled recording already exists for this program."); } } info.Id = Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture); LiveTvProgram programInfo = null; if (!string.IsNullOrWhiteSpace(info.ProgramId)) { programInfo = GetProgramInfoFromCache(info); } if (programInfo is null) { _logger.LogInformation("Unable to find program with Id {0}. Will search using start date", info.ProgramId); programInfo = GetProgramInfoFromCache(info.ChannelId, info.StartDate); } if (programInfo is not null) { CopyProgramInfoToTimerInfo(programInfo, info); } info.IsManual = true; _timerProvider.Add(info); TimerCreated?.Invoke(this, new GenericEventArgs(info)); return Task.FromResult(info.Id); } public async Task CreateSeriesTimer(SeriesTimerInfo info, CancellationToken cancellationToken) { info.Id = Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture); // populate info.seriesID var program = GetProgramInfoFromCache(info.ProgramId); if (program is not null) { info.SeriesId = program.ExternalSeriesId; } else { throw new InvalidOperationException("SeriesId for program not found"); } // If any timers have already been manually created, make sure they don't get cancelled var existingTimers = (await GetTimersAsync(CancellationToken.None).ConfigureAwait(false)) .Where(i => { if (string.Equals(i.ProgramId, info.ProgramId, StringComparison.OrdinalIgnoreCase) && !string.IsNullOrWhiteSpace(info.ProgramId)) { return true; } if (string.Equals(i.SeriesId, info.SeriesId, StringComparison.OrdinalIgnoreCase) && !string.IsNullOrWhiteSpace(info.SeriesId)) { return true; } return false; }) .ToList(); _seriesTimerProvider.Add(info); foreach (var timer in existingTimers) { timer.SeriesTimerId = info.Id; timer.IsManual = true; _timerProvider.AddOrUpdate(timer, false); } UpdateTimersForSeriesTimer(info, true, false); return info.Id; } public Task UpdateSeriesTimerAsync(SeriesTimerInfo info, CancellationToken cancellationToken) { var instance = _seriesTimerProvider.GetAll().FirstOrDefault(i => string.Equals(i.Id, info.Id, StringComparison.OrdinalIgnoreCase)); if (instance is not null) { instance.ChannelId = info.ChannelId; instance.Days = info.Days; instance.EndDate = info.EndDate; instance.IsPostPaddingRequired = info.IsPostPaddingRequired; instance.IsPrePaddingRequired = info.IsPrePaddingRequired; instance.PostPaddingSeconds = info.PostPaddingSeconds; instance.PrePaddingSeconds = info.PrePaddingSeconds; instance.Priority = info.Priority; instance.RecordAnyChannel = info.RecordAnyChannel; instance.RecordAnyTime = info.RecordAnyTime; instance.RecordNewOnly = info.RecordNewOnly; instance.SkipEpisodesInLibrary = info.SkipEpisodesInLibrary; instance.KeepUpTo = info.KeepUpTo; instance.KeepUntil = info.KeepUntil; instance.StartDate = info.StartDate; _seriesTimerProvider.Update(instance); UpdateTimersForSeriesTimer(instance, true, true); } return Task.CompletedTask; } public Task UpdateTimerAsync(TimerInfo updatedTimer, CancellationToken cancellationToken) { var existingTimer = _timerProvider.GetTimer(updatedTimer.Id); if (existingTimer is null) { throw new ResourceNotFoundException(); } // Only update if not currently active if (!_activeRecordings.TryGetValue(updatedTimer.Id, out _)) { existingTimer.PrePaddingSeconds = updatedTimer.PrePaddingSeconds; existingTimer.PostPaddingSeconds = updatedTimer.PostPaddingSeconds; existingTimer.IsPostPaddingRequired = updatedTimer.IsPostPaddingRequired; existingTimer.IsPrePaddingRequired = updatedTimer.IsPrePaddingRequired; _timerProvider.Update(existingTimer); } return Task.CompletedTask; } private static void UpdateExistingTimerWithNewMetadata(TimerInfo existingTimer, TimerInfo updatedTimer) { // Update the program info but retain the status existingTimer.ChannelId = updatedTimer.ChannelId; existingTimer.CommunityRating = updatedTimer.CommunityRating; existingTimer.EndDate = updatedTimer.EndDate; existingTimer.EpisodeNumber = updatedTimer.EpisodeNumber; existingTimer.EpisodeTitle = updatedTimer.EpisodeTitle; existingTimer.Genres = updatedTimer.Genres; existingTimer.IsMovie = updatedTimer.IsMovie; existingTimer.IsSeries = updatedTimer.IsSeries; existingTimer.Tags = updatedTimer.Tags; existingTimer.IsProgramSeries = updatedTimer.IsProgramSeries; existingTimer.IsRepeat = updatedTimer.IsRepeat; existingTimer.Name = updatedTimer.Name; existingTimer.OfficialRating = updatedTimer.OfficialRating; existingTimer.OriginalAirDate = updatedTimer.OriginalAirDate; existingTimer.Overview = updatedTimer.Overview; existingTimer.ProductionYear = updatedTimer.ProductionYear; existingTimer.ProgramId = updatedTimer.ProgramId; existingTimer.SeasonNumber = updatedTimer.SeasonNumber; existingTimer.StartDate = updatedTimer.StartDate; existingTimer.ShowId = updatedTimer.ShowId; existingTimer.ProviderIds = updatedTimer.ProviderIds; existingTimer.SeriesProviderIds = updatedTimer.SeriesProviderIds; } public string GetActiveRecordingPath(string id) { if (_activeRecordings.TryGetValue(id, out var info)) { return info.Path; } return null; } public ActiveRecordingInfo GetActiveRecordingInfo(string path) { if (string.IsNullOrWhiteSpace(path) || _activeRecordings.IsEmpty) { return null; } foreach (var (_, recordingInfo) in _activeRecordings) { if (string.Equals(recordingInfo.Path, path, StringComparison.Ordinal) && !recordingInfo.CancellationTokenSource.IsCancellationRequested) { var timer = recordingInfo.Timer; if (timer.Status != RecordingStatus.InProgress) { return null; } return recordingInfo; } } return null; } public Task> GetTimersAsync(CancellationToken cancellationToken) { var excludeStatues = new List { RecordingStatus.Completed }; var timers = _timerProvider.GetAll() .Where(i => !excludeStatues.Contains(i.Status)); return Task.FromResult(timers); } public Task GetNewTimerDefaultsAsync(CancellationToken cancellationToken, ProgramInfo program = null) { var config = GetConfiguration(); var defaults = new SeriesTimerInfo() { PostPaddingSeconds = Math.Max(config.PostPaddingSeconds, 0), PrePaddingSeconds = Math.Max(config.PrePaddingSeconds, 0), RecordAnyChannel = false, RecordAnyTime = true, RecordNewOnly = true, Days = new List { DayOfWeek.Sunday, DayOfWeek.Monday, DayOfWeek.Tuesday, DayOfWeek.Wednesday, DayOfWeek.Thursday, DayOfWeek.Friday, DayOfWeek.Saturday } }; if (program is not null) { defaults.SeriesId = program.SeriesId; defaults.ProgramId = program.Id; defaults.RecordNewOnly = !program.IsRepeat; defaults.Name = program.Name; } defaults.SkipEpisodesInLibrary = defaults.RecordNewOnly; defaults.KeepUntil = KeepUntil.UntilDeleted; return Task.FromResult(defaults); } public Task> GetSeriesTimersAsync(CancellationToken cancellationToken) { return Task.FromResult((IEnumerable)_seriesTimerProvider.GetAll()); } private bool IsListingProviderEnabledForTuner(ListingsProviderInfo info, string tunerHostId) { if (info.EnableAllTuners) { return true; } if (string.IsNullOrWhiteSpace(tunerHostId)) { throw new ArgumentNullException(nameof(tunerHostId)); } return info.EnabledTuners.Contains(tunerHostId, StringComparison.OrdinalIgnoreCase); } public async Task> GetProgramsAsync(string channelId, DateTime startDateUtc, DateTime endDateUtc, CancellationToken cancellationToken) { var channels = await GetChannelsAsync(true, cancellationToken).ConfigureAwait(false); var channel = channels.First(i => string.Equals(i.Id, channelId, StringComparison.OrdinalIgnoreCase)); foreach (var provider in GetListingProviders()) { if (!IsListingProviderEnabledForTuner(provider.Item2, channel.TunerHostId)) { _logger.LogDebug("Skipping getting programs for channel {0}-{1} from {2}-{3}, because it's not enabled for this tuner.", channel.Number, channel.Name, provider.Item1.Name, provider.Item2.ListingsId ?? string.Empty); continue; } _logger.LogDebug("Getting programs for channel {0}-{1} from {2}-{3}", channel.Number, channel.Name, provider.Item1.Name, provider.Item2.ListingsId ?? string.Empty); var epgChannel = await GetEpgChannelFromTunerChannel(provider.Item1, provider.Item2, channel, cancellationToken).ConfigureAwait(false); if (epgChannel is null) { _logger.LogDebug("EPG channel not found for tuner channel {0}-{1} from {2}-{3}", channel.Number, channel.Name, provider.Item1.Name, provider.Item2.ListingsId ?? string.Empty); continue; } List programs = (await provider.Item1.GetProgramsAsync(provider.Item2, epgChannel.Id, startDateUtc, endDateUtc, cancellationToken) .ConfigureAwait(false)).ToList(); // Replace the value that came from the provider with a normalized value foreach (var program in programs) { program.ChannelId = channelId; program.Id += "_" + channelId; } if (programs.Count > 0) { return programs; } } return Enumerable.Empty(); } private List> GetListingProviders() { return GetConfiguration().ListingProviders .Select(i => { var provider = _liveTvManager.ListingProviders.FirstOrDefault(l => string.Equals(l.Type, i.Type, StringComparison.OrdinalIgnoreCase)); return provider is null ? null : new Tuple(provider, i); }) .Where(i => i is not null) .ToList(); } public Task GetChannelStream(string channelId, string streamId, CancellationToken cancellationToken) { throw new NotImplementedException(); } public async Task GetChannelStreamWithDirectStreamProvider(string channelId, string streamId, List currentLiveStreams, CancellationToken cancellationToken) { _logger.LogInformation("Streaming Channel {Id}", channelId); var result = string.IsNullOrEmpty(streamId) ? null : currentLiveStreams.FirstOrDefault(i => string.Equals(i.OriginalStreamId, streamId, StringComparison.OrdinalIgnoreCase)); if (result is not null && result.EnableStreamSharing) { result.ConsumerCount++; _logger.LogInformation("Live stream {0} consumer count is now {1}", streamId, result.ConsumerCount); return result; } foreach (var hostInstance in _liveTvManager.TunerHosts) { try { result = await hostInstance.GetChannelStream(channelId, streamId, currentLiveStreams, cancellationToken).ConfigureAwait(false); var openedMediaSource = result.MediaSource; result.OriginalStreamId = streamId; _logger.LogInformation("Returning mediasource streamId {0}, mediaSource.Id {1}, mediaSource.LiveStreamId {2}", streamId, openedMediaSource.Id, openedMediaSource.LiveStreamId); return result; } catch (FileNotFoundException) { } catch (OperationCanceledException) { } } throw new ResourceNotFoundException("Tuner not found."); } public async Task> GetChannelStreamMediaSources(string channelId, CancellationToken cancellationToken) { if (string.IsNullOrWhiteSpace(channelId)) { throw new ArgumentNullException(nameof(channelId)); } foreach (var hostInstance in _liveTvManager.TunerHosts) { try { var sources = await hostInstance.GetChannelStreamMediaSources(channelId, cancellationToken).ConfigureAwait(false); if (sources.Count > 0) { return sources; } } catch (NotImplementedException) { } } throw new NotImplementedException(); } public async Task> GetRecordingStreamMediaSources(ActiveRecordingInfo info, CancellationToken cancellationToken) { var stream = new MediaSourceInfo { EncoderPath = _appHost.GetApiUrlForLocalAccess() + "/LiveTv/LiveRecordings/" + info.Id + "/stream", EncoderProtocol = MediaProtocol.Http, Path = info.Path, Protocol = MediaProtocol.File, Id = info.Id, SupportsDirectPlay = false, SupportsDirectStream = true, SupportsTranscoding = true, IsInfiniteStream = true, RequiresOpening = false, RequiresClosing = false, BufferMs = 0, IgnoreDts = true, IgnoreIndex = true }; await new LiveStreamHelper(_mediaEncoder, _logger, _config.CommonApplicationPaths) .AddMediaInfoWithProbe(stream, false, false, cancellationToken).ConfigureAwait(false); return new List { stream }; } public Task CloseLiveStream(string id, CancellationToken cancellationToken) { return Task.CompletedTask; } public Task RecordLiveStream(string id, CancellationToken cancellationToken) { return Task.CompletedTask; } public Task ResetTuner(string id, CancellationToken cancellationToken) { return Task.CompletedTask; } private async void OnTimerProviderTimerFired(object sender, GenericEventArgs e) { var timer = e.Argument; _logger.LogInformation("Recording timer fired for {0}.", timer.Name); try { var recordingEndDate = timer.EndDate.AddSeconds(timer.PostPaddingSeconds); if (recordingEndDate <= DateTime.UtcNow) { _logger.LogWarning("Recording timer fired for updatedTimer {0}, Id: {1}, but the program has already ended.", timer.Name, timer.Id); OnTimerOutOfDate(timer); return; } var activeRecordingInfo = new ActiveRecordingInfo { CancellationTokenSource = new CancellationTokenSource(), Timer = timer, Id = timer.Id }; if (!_activeRecordings.ContainsKey(timer.Id)) { await RecordStream(timer, recordingEndDate, activeRecordingInfo).ConfigureAwait(false); } else { _logger.LogInformation("Skipping RecordStream because it's already in progress."); } } catch (OperationCanceledException) { } catch (Exception ex) { _logger.LogError(ex, "Error recording stream"); } } private string GetRecordingPath(TimerInfo timer, RemoteSearchResult metadata, out string seriesPath) { var recordPath = RecordingPath; var config = GetConfiguration(); seriesPath = null; if (timer.IsProgramSeries) { var customRecordingPath = config.SeriesRecordingPath; var allowSubfolder = true; if (!string.IsNullOrWhiteSpace(customRecordingPath)) { allowSubfolder = string.Equals(customRecordingPath, recordPath, StringComparison.OrdinalIgnoreCase); recordPath = customRecordingPath; } if (allowSubfolder && config.EnableRecordingSubfolders) { recordPath = Path.Combine(recordPath, "Series"); } // trim trailing period from the folder name var folderName = _fileSystem.GetValidFilename(timer.Name).Trim().TrimEnd('.').Trim(); if (metadata is not null && metadata.ProductionYear.HasValue) { folderName += " (" + metadata.ProductionYear.Value.ToString(CultureInfo.InvariantCulture) + ")"; } // Can't use the year here in the folder name because it is the year of the episode, not the series. recordPath = Path.Combine(recordPath, folderName); seriesPath = recordPath; if (timer.SeasonNumber.HasValue) { folderName = string.Format( CultureInfo.InvariantCulture, "Season {0}", timer.SeasonNumber.Value); recordPath = Path.Combine(recordPath, folderName); } } else if (timer.IsMovie) { var customRecordingPath = config.MovieRecordingPath; var allowSubfolder = true; if (!string.IsNullOrWhiteSpace(customRecordingPath)) { allowSubfolder = string.Equals(customRecordingPath, recordPath, StringComparison.OrdinalIgnoreCase); recordPath = customRecordingPath; } if (allowSubfolder && config.EnableRecordingSubfolders) { recordPath = Path.Combine(recordPath, "Movies"); } var folderName = _fileSystem.GetValidFilename(timer.Name).Trim(); if (timer.ProductionYear.HasValue) { folderName += " (" + timer.ProductionYear.Value.ToString(CultureInfo.InvariantCulture) + ")"; } // trim trailing period from the folder name folderName = folderName.TrimEnd('.').Trim(); recordPath = Path.Combine(recordPath, folderName); } else if (timer.IsKids) { if (config.EnableRecordingSubfolders) { recordPath = Path.Combine(recordPath, "Kids"); } var folderName = _fileSystem.GetValidFilename(timer.Name).Trim(); if (timer.ProductionYear.HasValue) { folderName += " (" + timer.ProductionYear.Value.ToString(CultureInfo.InvariantCulture) + ")"; } // trim trailing period from the folder name folderName = folderName.TrimEnd('.').Trim(); recordPath = Path.Combine(recordPath, folderName); } else if (timer.IsSports) { if (config.EnableRecordingSubfolders) { recordPath = Path.Combine(recordPath, "Sports"); } recordPath = Path.Combine(recordPath, _fileSystem.GetValidFilename(timer.Name).Trim()); } else { if (config.EnableRecordingSubfolders) { recordPath = Path.Combine(recordPath, "Other"); } recordPath = Path.Combine(recordPath, _fileSystem.GetValidFilename(timer.Name).Trim()); } var recordingFileName = _fileSystem.GetValidFilename(RecordingHelper.GetRecordingName(timer)).Trim() + ".ts"; return Path.Combine(recordPath, recordingFileName); } private async Task RecordStream(TimerInfo timer, DateTime recordingEndDate, ActiveRecordingInfo activeRecordingInfo) { ArgumentNullException.ThrowIfNull(timer); LiveTvProgram programInfo = null; if (!string.IsNullOrWhiteSpace(timer.ProgramId)) { programInfo = GetProgramInfoFromCache(timer); } if (programInfo is null) { _logger.LogInformation("Unable to find program with Id {0}. Will search using start date", timer.ProgramId); programInfo = GetProgramInfoFromCache(timer.ChannelId, timer.StartDate); } if (programInfo is not null) { CopyProgramInfoToTimerInfo(programInfo, timer); } var remoteMetadata = await FetchInternetMetadata(timer, CancellationToken.None).ConfigureAwait(false); var recordPath = GetRecordingPath(timer, remoteMetadata, out string seriesPath); var channelItem = _liveTvManager.GetLiveTvChannel(timer, this); string liveStreamId = null; RecordingStatus recordingStatus; try { var allMediaSources = await _mediaSourceManager.GetPlaybackMediaSources(channelItem, null, true, false, CancellationToken.None).ConfigureAwait(false); var mediaStreamInfo = allMediaSources[0]; IDirectStreamProvider directStreamProvider = null; if (mediaStreamInfo.RequiresOpening) { var liveStreamResponse = await _mediaSourceManager.OpenLiveStreamInternal( new LiveStreamRequest { ItemId = channelItem.Id, OpenToken = mediaStreamInfo.OpenToken }, CancellationToken.None).ConfigureAwait(false); mediaStreamInfo = liveStreamResponse.Item1.MediaSource; liveStreamId = mediaStreamInfo.LiveStreamId; directStreamProvider = liveStreamResponse.Item2; } var recorder = GetRecorder(mediaStreamInfo); recordPath = recorder.GetOutputPath(mediaStreamInfo, recordPath); recordPath = EnsureFileUnique(recordPath, timer.Id); _libraryMonitor.ReportFileSystemChangeBeginning(recordPath); var duration = recordingEndDate - DateTime.UtcNow; _logger.LogInformation("Beginning recording. Will record for {0} minutes.", duration.TotalMinutes.ToString(CultureInfo.InvariantCulture)); _logger.LogInformation("Writing file to: {Path}", recordPath); Action onStarted = async () => { activeRecordingInfo.Path = recordPath; _activeRecordings.TryAdd(timer.Id, activeRecordingInfo); timer.Status = RecordingStatus.InProgress; _timerProvider.AddOrUpdate(timer, false); await SaveRecordingMetadata(timer, recordPath, seriesPath).ConfigureAwait(false); await CreateRecordingFolders().ConfigureAwait(false); TriggerRefresh(recordPath); await EnforceKeepUpTo(timer, seriesPath).ConfigureAwait(false); }; await recorder.Record(directStreamProvider, mediaStreamInfo, recordPath, duration, onStarted, activeRecordingInfo.CancellationTokenSource.Token).ConfigureAwait(false); recordingStatus = RecordingStatus.Completed; _logger.LogInformation("Recording completed: {RecordPath}", recordPath); } catch (OperationCanceledException) { _logger.LogInformation("Recording stopped: {RecordPath}", recordPath); recordingStatus = RecordingStatus.Completed; } catch (Exception ex) { _logger.LogError(ex, "Error recording to {RecordPath}", recordPath); recordingStatus = RecordingStatus.Error; } if (!string.IsNullOrWhiteSpace(liveStreamId)) { try { await _mediaSourceManager.CloseLiveStream(liveStreamId).ConfigureAwait(false); } catch (Exception ex) { _logger.LogError(ex, "Error closing live stream"); } } DeleteFileIfEmpty(recordPath); TriggerRefresh(recordPath); _libraryMonitor.ReportFileSystemChangeComplete(recordPath, false); _activeRecordings.TryRemove(timer.Id, out _); if (recordingStatus != RecordingStatus.Completed && DateTime.UtcNow < timer.EndDate && timer.RetryCount < 10) { const int RetryIntervalSeconds = 60; _logger.LogInformation("Retrying recording in {0} seconds.", RetryIntervalSeconds); timer.Status = RecordingStatus.New; timer.PrePaddingSeconds = 0; timer.StartDate = DateTime.UtcNow.AddSeconds(RetryIntervalSeconds); timer.RetryCount++; _timerProvider.AddOrUpdate(timer); } else if (File.Exists(recordPath)) { timer.RecordingPath = recordPath; timer.Status = RecordingStatus.Completed; _timerProvider.AddOrUpdate(timer, false); OnSuccessfulRecording(timer, recordPath); } else { _timerProvider.Delete(timer); } } private async Task FetchInternetMetadata(TimerInfo timer, CancellationToken cancellationToken) { if (timer.IsSeries) { if (timer.SeriesProviderIds.Count == 0) { return null; } var query = new RemoteSearchQuery() { SearchInfo = new SeriesInfo { ProviderIds = timer.SeriesProviderIds, Name = timer.Name, MetadataCountryCode = _config.Configuration.MetadataCountryCode, MetadataLanguage = _config.Configuration.PreferredMetadataLanguage } }; var results = await _providerManager.GetRemoteSearchResults(query, cancellationToken).ConfigureAwait(false); return results.FirstOrDefault(); } return null; } private void DeleteFileIfEmpty(string path) { var file = _fileSystem.GetFileInfo(path); if (file.Exists && file.Length == 0) { try { _fileSystem.DeleteFile(path); } catch (Exception ex) { _logger.LogError(ex, "Error deleting 0-byte failed recording file {Path}", path); } } } private void TriggerRefresh(string path) { _logger.LogInformation("Triggering refresh on {Path}", path); var item = GetAffectedBaseItem(Path.GetDirectoryName(path)); if (item is not null) { _logger.LogInformation("Refreshing recording parent {Path}", item.Path); _providerManager.QueueRefresh( item.Id, new MetadataRefreshOptions(new DirectoryService(_fileSystem)) { RefreshPaths = new string[] { path, Path.GetDirectoryName(path), Path.GetDirectoryName(Path.GetDirectoryName(path)) } }, RefreshPriority.High); } } private BaseItem GetAffectedBaseItem(string path) { BaseItem item = null; var parentPath = Path.GetDirectoryName(path); while (item is null && !string.IsNullOrEmpty(path)) { item = _libraryManager.FindByPath(path, null); path = Path.GetDirectoryName(path); } if (item is not null) { if (item.GetType() == typeof(Folder) && string.Equals(item.Path, parentPath, StringComparison.OrdinalIgnoreCase)) { var parentItem = item.GetParent(); if (parentItem is not null && parentItem is not AggregateFolder) { item = parentItem; } } } return item; } private async Task EnforceKeepUpTo(TimerInfo timer, string seriesPath) { if (string.IsNullOrWhiteSpace(timer.SeriesTimerId)) { return; } if (string.IsNullOrWhiteSpace(seriesPath)) { return; } var seriesTimerId = timer.SeriesTimerId; var seriesTimer = _seriesTimerProvider.GetAll().FirstOrDefault(i => string.Equals(i.Id, seriesTimerId, StringComparison.OrdinalIgnoreCase)); if (seriesTimer is null || seriesTimer.KeepUpTo <= 0) { return; } if (_disposed) { return; } await _recordingDeleteSemaphore.WaitAsync().ConfigureAwait(false); try { if (_disposed) { return; } var timersToDelete = _timerProvider.GetAll() .Where(i => i.Status == RecordingStatus.Completed && !string.IsNullOrWhiteSpace(i.RecordingPath)) .Where(i => string.Equals(i.SeriesTimerId, seriesTimerId, StringComparison.OrdinalIgnoreCase)) .OrderByDescending(i => i.EndDate) .Where(i => File.Exists(i.RecordingPath)) .Skip(seriesTimer.KeepUpTo - 1) .ToList(); DeleteLibraryItemsForTimers(timersToDelete); if (_libraryManager.FindByPath(seriesPath, true) is not Folder librarySeries) { return; } var episodesToDelete = librarySeries.GetItemList( new InternalItemsQuery { OrderBy = new[] { (ItemSortBy.DateCreated, SortOrder.Descending) }, IsVirtualItem = false, IsFolder = false, Recursive = true, DtoOptions = new DtoOptions(true) }) .Where(i => i.IsFileProtocol && File.Exists(i.Path)) .Skip(seriesTimer.KeepUpTo - 1) .ToList(); foreach (var item in episodesToDelete) { try { _libraryManager.DeleteItem( item, new DeleteOptions { DeleteFileLocation = true }, true); } catch (Exception ex) { _logger.LogError(ex, "Error deleting item"); } } } finally { _recordingDeleteSemaphore.Release(); } } private void DeleteLibraryItemsForTimers(List timers) { foreach (var timer in timers) { if (_disposed) { return; } try { DeleteLibraryItemForTimer(timer); } catch (Exception ex) { _logger.LogError(ex, "Error deleting recording"); } } } private void DeleteLibraryItemForTimer(TimerInfo timer) { var libraryItem = _libraryManager.FindByPath(timer.RecordingPath, false); if (libraryItem is not null) { _libraryManager.DeleteItem( libraryItem, new DeleteOptions { DeleteFileLocation = true }, true); } else if (File.Exists(timer.RecordingPath)) { _fileSystem.DeleteFile(timer.RecordingPath); } _timerProvider.Delete(timer); } private string EnsureFileUnique(string path, string timerId) { var originalPath = path; var index = 1; while (FileExists(path, timerId)) { var parent = Path.GetDirectoryName(originalPath); var name = Path.GetFileNameWithoutExtension(originalPath); name += " - " + index.ToString(CultureInfo.InvariantCulture); path = Path.ChangeExtension(Path.Combine(parent, name), Path.GetExtension(originalPath)); index++; } return path; } private bool FileExists(string path, string timerId) { if (File.Exists(path)) { return true; } return _activeRecordings .Any(i => string.Equals(i.Value.Path, path, StringComparison.OrdinalIgnoreCase) && !string.Equals(i.Value.Timer.Id, timerId, StringComparison.OrdinalIgnoreCase)); } private IRecorder GetRecorder(MediaSourceInfo mediaSource) { if (mediaSource.RequiresLooping || !(mediaSource.Container ?? string.Empty).EndsWith("ts", StringComparison.OrdinalIgnoreCase) || (mediaSource.Protocol != MediaProtocol.File && mediaSource.Protocol != MediaProtocol.Http)) { return new EncodedRecorder(_logger, _mediaEncoder, _config.ApplicationPaths, _config); } return new DirectRecorder(_logger, _httpClientFactory, _streamHelper); } private void OnSuccessfulRecording(TimerInfo timer, string path) { PostProcessRecording(timer, path); } private void PostProcessRecording(TimerInfo timer, string path) { var options = GetConfiguration(); if (string.IsNullOrWhiteSpace(options.RecordingPostProcessor)) { return; } try { var process = new Process { StartInfo = new ProcessStartInfo { Arguments = GetPostProcessArguments(path, options.RecordingPostProcessorArguments), CreateNoWindow = true, ErrorDialog = false, FileName = options.RecordingPostProcessor, WindowStyle = ProcessWindowStyle.Hidden, UseShellExecute = false }, EnableRaisingEvents = true }; _logger.LogInformation("Running recording post processor {0} {1}", process.StartInfo.FileName, process.StartInfo.Arguments); process.Exited += OnProcessExited; process.Start(); } catch (Exception ex) { _logger.LogError(ex, "Error running recording post processor"); } } private static string GetPostProcessArguments(string path, string arguments) { return arguments.Replace("{path}", path, StringComparison.OrdinalIgnoreCase); } private void OnProcessExited(object sender, EventArgs e) { using (var process = (Process)sender) { _logger.LogInformation("Recording post-processing script completed with exit code {ExitCode}", process.ExitCode); } } private async Task SaveRecordingImage(string recordingPath, LiveTvProgram program, ItemImageInfo image) { if (!image.IsLocalFile) { image = await _libraryManager.ConvertImageToLocal(program, image, 0).ConfigureAwait(false); } string imageSaveFilenameWithoutExtension = image.Type switch { ImageType.Primary => program.IsSeries ? Path.GetFileNameWithoutExtension(recordingPath) + "-thumb" : "poster", ImageType.Logo => "logo", ImageType.Thumb => program.IsSeries ? Path.GetFileNameWithoutExtension(recordingPath) + "-thumb" : "landscape", ImageType.Backdrop => "fanart", _ => null }; if (imageSaveFilenameWithoutExtension is null) { return; } var imageSavePath = Path.Combine(Path.GetDirectoryName(recordingPath), imageSaveFilenameWithoutExtension); // preserve original image extension imageSavePath = Path.ChangeExtension(imageSavePath, Path.GetExtension(image.Path)); File.Copy(image.Path, imageSavePath, true); } private async Task SaveRecordingImages(string recordingPath, LiveTvProgram program) { var image = program.IsSeries ? (program.GetImageInfo(ImageType.Thumb, 0) ?? program.GetImageInfo(ImageType.Primary, 0)) : (program.GetImageInfo(ImageType.Primary, 0) ?? program.GetImageInfo(ImageType.Thumb, 0)); if (image is not null) { try { await SaveRecordingImage(recordingPath, program, image).ConfigureAwait(false); } catch (Exception ex) { _logger.LogError(ex, "Error saving recording image"); } } if (!program.IsSeries) { image = program.GetImageInfo(ImageType.Backdrop, 0); if (image is not null) { try { await SaveRecordingImage(recordingPath, program, image).ConfigureAwait(false); } catch (Exception ex) { _logger.LogError(ex, "Error saving recording image"); } } image = program.GetImageInfo(ImageType.Thumb, 0); if (image is not null) { try { await SaveRecordingImage(recordingPath, program, image).ConfigureAwait(false); } catch (Exception ex) { _logger.LogError(ex, "Error saving recording image"); } } image = program.GetImageInfo(ImageType.Logo, 0); if (image is not null) { try { await SaveRecordingImage(recordingPath, program, image).ConfigureAwait(false); } catch (Exception ex) { _logger.LogError(ex, "Error saving recording image"); } } } } private async Task SaveRecordingMetadata(TimerInfo timer, string recordingPath, string seriesPath) { try { var program = string.IsNullOrWhiteSpace(timer.ProgramId) ? null : _libraryManager.GetItemList(new InternalItemsQuery { IncludeItemTypes = new[] { BaseItemKind.LiveTvProgram }, Limit = 1, ExternalId = timer.ProgramId, DtoOptions = new DtoOptions(true) }).FirstOrDefault() as LiveTvProgram; // dummy this up if (program is null) { program = new LiveTvProgram { Name = timer.Name, Overview = timer.Overview, Genres = timer.Genres, CommunityRating = timer.CommunityRating, OfficialRating = timer.OfficialRating, ProductionYear = timer.ProductionYear, PremiereDate = timer.OriginalAirDate, IndexNumber = timer.EpisodeNumber, ParentIndexNumber = timer.SeasonNumber }; } if (timer.IsSports) { program.AddGenre("Sports"); } if (timer.IsKids) { program.AddGenre("Kids"); program.AddGenre("Children"); } if (timer.IsNews) { program.AddGenre("News"); } var config = GetConfiguration(); if (config.SaveRecordingNFO) { if (timer.IsProgramSeries) { await SaveSeriesNfoAsync(timer, seriesPath).ConfigureAwait(false); await SaveVideoNfoAsync(timer, recordingPath, program, false).ConfigureAwait(false); } else if (!timer.IsMovie || timer.IsSports || timer.IsNews) { await SaveVideoNfoAsync(timer, recordingPath, program, true).ConfigureAwait(false); } else { await SaveVideoNfoAsync(timer, recordingPath, program, false).ConfigureAwait(false); } } if (config.SaveRecordingImages) { await SaveRecordingImages(recordingPath, program).ConfigureAwait(false); } } catch (Exception ex) { _logger.LogError(ex, "Error saving nfo"); } } private async Task SaveSeriesNfoAsync(TimerInfo timer, string seriesPath) { var nfoPath = Path.Combine(seriesPath, "tvshow.nfo"); if (File.Exists(nfoPath)) { return; } await using (var stream = new FileStream(nfoPath, FileMode.CreateNew, FileAccess.Write, FileShare.None)) { var settings = new XmlWriterSettings { Indent = true, Encoding = Encoding.UTF8, Async = true }; await using (var writer = XmlWriter.Create(stream, settings)) { await writer.WriteStartDocumentAsync(true).ConfigureAwait(false); await writer.WriteStartElementAsync(null, "tvshow", null).ConfigureAwait(false); string id; if (timer.SeriesProviderIds.TryGetValue(MetadataProvider.Tvdb.ToString(), out 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; } await using (var stream = new FileStream(nfoPath, FileMode.CreateNew, FileAccess.Write, FileShare.None)) { var settings = new XmlWriterSettings { Indent = true, Encoding = Encoding.UTF8, Async = true }; var options = _config.GetNfoConfiguration(); var isSeriesEpisode = timer.IsProgramSeries; await using (var writer = XmlWriter.Create(stream, settings)) { 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); if (!string.IsNullOrWhiteSpace(item.Name)) { await writer.WriteElementStringAsync(null, "title", null, item.Name).ConfigureAwait(false); } if (!string.IsNullOrWhiteSpace(item.OriginalTitle)) { await writer.WriteElementStringAsync(null, "originaltitle", null, item.OriginalTitle).ConfigureAwait(false); } if (item.PremiereDate.HasValue) { var formatString = options.ReleaseDateFormat; await writer.WriteElementStringAsync( null, "premiered", null, item.PremiereDate.Value.ToLocalTime().ToString(formatString, CultureInfo.InvariantCulture)).ConfigureAwait(false); await writer.WriteElementStringAsync( null, "releasedate", null, item.PremiereDate.Value.ToLocalTime().ToString(formatString, CultureInfo.InvariantCulture)).ConfigureAwait(false); } } await writer.WriteElementStringAsync( null, "dateadded", null, DateTime.Now.ToString(DateAddedFormat, CultureInfo.InvariantCulture)).ConfigureAwait(false); if (item.ProductionYear.HasValue) { await writer.WriteElementStringAsync(null, "year", null, item.ProductionYear.Value.ToString(CultureInfo.InvariantCulture)).ConfigureAwait(false); } if (!string.IsNullOrEmpty(item.OfficialRating)) { await writer.WriteElementStringAsync(null, "mpaa", null, item.OfficialRating).ConfigureAwait(false); } var overview = (item.Overview ?? string.Empty) .StripHtml() .Replace(""", "'", StringComparison.Ordinal); await writer.WriteElementStringAsync(null, "plot", null, overview).ConfigureAwait(false); if (item.CommunityRating.HasValue) { await writer.WriteElementStringAsync(null, "rating", null, item.CommunityRating.Value.ToString(CultureInfo.InvariantCulture)).ConfigureAwait(false); } foreach (var genre in item.Genres) { await writer.WriteElementStringAsync(null, "genre", null, genre).ConfigureAwait(false); } var people = item.Id.Equals(default) ? new List() : _libraryManager.GetPeople(item); var directors = people .Where(i => i.IsType(PersonKind.Director)) .Select(i => i.Name) .ToList(); foreach (var person in directors) { await writer.WriteElementStringAsync(null, "director", null, person).ConfigureAwait(false); } var writers = people .Where(i => i.IsType(PersonKind.Writer)) .Select(i => i.Name) .Distinct(StringComparer.OrdinalIgnoreCase) .ToList(); foreach (var person in writers) { await writer.WriteElementStringAsync(null, "writer", null, person).ConfigureAwait(false); } foreach (var person in writers) { await writer.WriteElementStringAsync(null, "credits", null, person).ConfigureAwait(false); } var tmdbCollection = item.GetProviderId(MetadataProvider.TmdbCollection); if (!string.IsNullOrEmpty(tmdbCollection)) { await writer.WriteElementStringAsync(null, "collectionnumber", null, tmdbCollection).ConfigureAwait(false); } var imdb = item.GetProviderId(MetadataProvider.Imdb); if (!string.IsNullOrEmpty(imdb)) { if (!isSeriesEpisode) { await writer.WriteElementStringAsync(null, "id", null, imdb).ConfigureAwait(false); } await writer.WriteElementStringAsync(null, "imdbid", null, imdb).ConfigureAwait(false); // No need to lock if we have identified the content already lockData = false; } var tvdb = item.GetProviderId(MetadataProvider.Tvdb); if (!string.IsNullOrEmpty(tvdb)) { await writer.WriteElementStringAsync(null, "tvdbid", null, tvdb).ConfigureAwait(false); // No need to lock if we have identified the content already lockData = false; } var tmdb = item.GetProviderId(MetadataProvider.Tmdb); if (!string.IsNullOrEmpty(tmdb)) { await writer.WriteElementStringAsync(null, "tmdbid", null, tmdb).ConfigureAwait(false); // No need to lock if we have identified the content already lockData = false; } if (lockData) { await writer.WriteElementStringAsync(null, "lockdata", null, "true").ConfigureAwait(false); } if (item.CriticRating.HasValue) { await writer.WriteElementStringAsync(null, "criticrating", null, item.CriticRating.Value.ToString(CultureInfo.InvariantCulture)).ConfigureAwait(false); } if (!string.IsNullOrWhiteSpace(item.Tagline)) { await writer.WriteElementStringAsync(null, "tagline", null, item.Tagline).ConfigureAwait(false); } foreach (var studio in item.Studios) { await writer.WriteElementStringAsync(null, "studio", null, studio).ConfigureAwait(false); } await writer.WriteEndElementAsync().ConfigureAwait(false); await writer.WriteEndDocumentAsync().ConfigureAwait(false); } } } private LiveTvProgram GetProgramInfoFromCache(string programId) { var query = new InternalItemsQuery { ItemIds = new[] { _liveTvManager.GetInternalProgramId(programId) }, Limit = 1, DtoOptions = new DtoOptions() }; return _libraryManager.GetItemList(query).Cast().FirstOrDefault(); } private LiveTvProgram GetProgramInfoFromCache(TimerInfo timer) { return GetProgramInfoFromCache(timer.ProgramId); } private LiveTvProgram GetProgramInfoFromCache(string channelId, DateTime startDateUtc) { var query = new InternalItemsQuery { IncludeItemTypes = new[] { BaseItemKind.LiveTvProgram }, Limit = 1, DtoOptions = new DtoOptions(true) { EnableImages = false }, MinStartDate = startDateUtc.AddMinutes(-3), MaxStartDate = startDateUtc.AddMinutes(3), OrderBy = new[] { (ItemSortBy.StartDate, SortOrder.Ascending) } }; if (!string.IsNullOrWhiteSpace(channelId)) { query.ChannelIds = new[] { _liveTvManager.GetInternalChannelId(Name, channelId) }; } return _libraryManager.GetItemList(query).Cast().FirstOrDefault(); } private LiveTvOptions GetConfiguration() { return _config.GetConfiguration("livetv"); } private bool ShouldCancelTimerForSeriesTimer(SeriesTimerInfo seriesTimer, TimerInfo timer) { if (timer.IsManual) { return false; } if (!seriesTimer.RecordAnyTime && Math.Abs(seriesTimer.StartDate.TimeOfDay.Ticks - timer.StartDate.TimeOfDay.Ticks) >= TimeSpan.FromMinutes(10).Ticks) { return true; } if (seriesTimer.RecordNewOnly && timer.IsRepeat) { return true; } if (!seriesTimer.RecordAnyChannel && !string.Equals(timer.ChannelId, seriesTimer.ChannelId, StringComparison.OrdinalIgnoreCase)) { return true; } return seriesTimer.SkipEpisodesInLibrary && IsProgramAlreadyInLibrary(timer); } private void HandleDuplicateShowIds(List timers) { // sort showings by HD channels first, then by startDate, record earliest showing possible foreach (var timer in timers.OrderByDescending(t => _liveTvManager.GetLiveTvChannel(t, this).IsHD).ThenBy(t => t.StartDate).Skip(1)) { timer.Status = RecordingStatus.Cancelled; _timerProvider.Update(timer); } } private void SearchForDuplicateShowIds(IEnumerable timers) { var groups = timers.ToLookup(i => i.ShowId ?? string.Empty).ToList(); foreach (var group in groups) { if (string.IsNullOrWhiteSpace(group.Key)) { continue; } var groupTimers = group.ToList(); if (groupTimers.Count < 2) { continue; } // Skip ShowId without SubKey from duplicate removal actions - https://github.com/jellyfin/jellyfin/issues/5856 if (group.Key.EndsWith("0000", StringComparison.Ordinal)) { continue; } HandleDuplicateShowIds(groupTimers); } } private void UpdateTimersForSeriesTimer(SeriesTimerInfo seriesTimer, bool updateTimerSettings, bool deleteInvalidTimers) { var allTimers = GetTimersForSeries(seriesTimer).ToList(); var enabledTimersForSeries = new List(); foreach (var timer in allTimers) { var existingTimer = _timerProvider.GetTimer(timer.Id) ?? (string.IsNullOrWhiteSpace(timer.ProgramId) ? null : _timerProvider.GetTimerByProgramId(timer.ProgramId)); if (existingTimer is null) { if (ShouldCancelTimerForSeriesTimer(seriesTimer, timer)) { timer.Status = RecordingStatus.Cancelled; } else { enabledTimersForSeries.Add(timer); } _timerProvider.Add(timer); TimerCreated?.Invoke(this, new GenericEventArgs(timer)); } // Only update if not currently active - test both new timer and existing in case Id's are different // Id's could be different if the timer was created manually prior to series timer creation else if (!_activeRecordings.TryGetValue(timer.Id, out _) && !_activeRecordings.TryGetValue(existingTimer.Id, out _)) { UpdateExistingTimerWithNewMetadata(existingTimer, timer); // Needed by ShouldCancelTimerForSeriesTimer timer.IsManual = existingTimer.IsManual; if (ShouldCancelTimerForSeriesTimer(seriesTimer, timer)) { existingTimer.Status = RecordingStatus.Cancelled; } else if (!existingTimer.IsManual) { existingTimer.Status = RecordingStatus.New; } if (existingTimer.Status != RecordingStatus.Cancelled) { enabledTimersForSeries.Add(existingTimer); } if (updateTimerSettings) { existingTimer.KeepUntil = seriesTimer.KeepUntil; existingTimer.IsPostPaddingRequired = seriesTimer.IsPostPaddingRequired; existingTimer.IsPrePaddingRequired = seriesTimer.IsPrePaddingRequired; existingTimer.PostPaddingSeconds = seriesTimer.PostPaddingSeconds; existingTimer.PrePaddingSeconds = seriesTimer.PrePaddingSeconds; existingTimer.Priority = seriesTimer.Priority; existingTimer.SeriesTimerId = seriesTimer.Id; } existingTimer.SeriesTimerId = seriesTimer.Id; _timerProvider.Update(existingTimer); } } SearchForDuplicateShowIds(enabledTimersForSeries); if (deleteInvalidTimers) { var allTimerIds = allTimers .Select(i => i.Id) .ToList(); var deleteStatuses = new[] { RecordingStatus.New }; var deletes = _timerProvider.GetAll() .Where(i => string.Equals(i.SeriesTimerId, seriesTimer.Id, StringComparison.OrdinalIgnoreCase)) .Where(i => !allTimerIds.Contains(i.Id, StringComparison.OrdinalIgnoreCase) && i.StartDate > DateTime.UtcNow) .Where(i => deleteStatuses.Contains(i.Status)) .ToList(); foreach (var timer in deletes) { CancelTimerInternal(timer.Id, false, false); } } } private IEnumerable GetTimersForSeries(SeriesTimerInfo seriesTimer) { ArgumentNullException.ThrowIfNull(seriesTimer); var query = new InternalItemsQuery { IncludeItemTypes = new[] { BaseItemKind.LiveTvProgram }, ExternalSeriesId = seriesTimer.SeriesId, DtoOptions = new DtoOptions(true) { EnableImages = false }, MinEndDate = DateTime.UtcNow }; if (string.IsNullOrEmpty(seriesTimer.SeriesId)) { query.Name = seriesTimer.Name; } if (!seriesTimer.RecordAnyChannel) { query.ChannelIds = new[] { _liveTvManager.GetInternalChannelId(Name, seriesTimer.ChannelId) }; } var tempChannelCache = new Dictionary(); return _libraryManager.GetItemList(query).Cast().Select(i => CreateTimer(i, seriesTimer, tempChannelCache)); } private TimerInfo CreateTimer(LiveTvProgram parent, SeriesTimerInfo seriesTimer, Dictionary tempChannelCache) { string channelId = seriesTimer.RecordAnyChannel ? null : seriesTimer.ChannelId; if (string.IsNullOrWhiteSpace(channelId) && !parent.ChannelId.Equals(default)) { if (!tempChannelCache.TryGetValue(parent.ChannelId, out LiveTvChannel channel)) { channel = _libraryManager.GetItemList( new InternalItemsQuery { IncludeItemTypes = new[] { BaseItemKind.LiveTvChannel }, ItemIds = new[] { parent.ChannelId }, DtoOptions = new DtoOptions() }).FirstOrDefault() as LiveTvChannel; if (channel is not null && !string.IsNullOrWhiteSpace(channel.ExternalId)) { tempChannelCache[parent.ChannelId] = channel; } } if (channel is not null || tempChannelCache.TryGetValue(parent.ChannelId, out channel)) { channelId = channel.ExternalId; } } var timer = new TimerInfo { ChannelId = channelId, Id = (seriesTimer.Id + parent.ExternalId).GetMD5().ToString("N", CultureInfo.InvariantCulture), StartDate = parent.StartDate, EndDate = parent.EndDate.Value, ProgramId = parent.ExternalId, PrePaddingSeconds = seriesTimer.PrePaddingSeconds, PostPaddingSeconds = seriesTimer.PostPaddingSeconds, IsPostPaddingRequired = seriesTimer.IsPostPaddingRequired, IsPrePaddingRequired = seriesTimer.IsPrePaddingRequired, KeepUntil = seriesTimer.KeepUntil, Priority = seriesTimer.Priority, Name = parent.Name, Overview = parent.Overview, SeriesId = parent.ExternalSeriesId, SeriesTimerId = seriesTimer.Id, ShowId = parent.ShowId }; CopyProgramInfoToTimerInfo(parent, timer, tempChannelCache); return timer; } private void CopyProgramInfoToTimerInfo(LiveTvProgram programInfo, TimerInfo timerInfo) { var tempChannelCache = new Dictionary(); CopyProgramInfoToTimerInfo(programInfo, timerInfo, tempChannelCache); } private void CopyProgramInfoToTimerInfo(LiveTvProgram programInfo, TimerInfo timerInfo, Dictionary tempChannelCache) { string channelId = null; if (!programInfo.ChannelId.Equals(default)) { if (!tempChannelCache.TryGetValue(programInfo.ChannelId, out LiveTvChannel channel)) { channel = _libraryManager.GetItemList( new InternalItemsQuery { IncludeItemTypes = new[] { BaseItemKind.LiveTvChannel }, ItemIds = new[] { programInfo.ChannelId }, DtoOptions = new DtoOptions() }).FirstOrDefault() as LiveTvChannel; if (channel is not null && !string.IsNullOrWhiteSpace(channel.ExternalId)) { tempChannelCache[programInfo.ChannelId] = channel; } } if (channel is not null || tempChannelCache.TryGetValue(programInfo.ChannelId, out channel)) { channelId = channel.ExternalId; } } timerInfo.Name = programInfo.Name; timerInfo.StartDate = programInfo.StartDate; timerInfo.EndDate = programInfo.EndDate.Value; if (!string.IsNullOrWhiteSpace(channelId)) { timerInfo.ChannelId = channelId; } timerInfo.SeasonNumber = programInfo.ParentIndexNumber; timerInfo.EpisodeNumber = programInfo.IndexNumber; timerInfo.IsMovie = programInfo.IsMovie; timerInfo.ProductionYear = programInfo.ProductionYear; timerInfo.EpisodeTitle = programInfo.EpisodeTitle; timerInfo.OriginalAirDate = programInfo.PremiereDate; timerInfo.IsProgramSeries = programInfo.IsSeries; timerInfo.IsSeries = programInfo.IsSeries; timerInfo.CommunityRating = programInfo.CommunityRating; timerInfo.Overview = programInfo.Overview; timerInfo.OfficialRating = programInfo.OfficialRating; timerInfo.IsRepeat = programInfo.IsRepeat; timerInfo.SeriesId = programInfo.ExternalSeriesId; timerInfo.ProviderIds = programInfo.ProviderIds; timerInfo.Tags = programInfo.Tags; var seriesProviderIds = new Dictionary(StringComparer.OrdinalIgnoreCase); foreach (var providerId in timerInfo.ProviderIds) { const string Search = "Series"; if (providerId.Key.StartsWith(Search, StringComparison.OrdinalIgnoreCase)) { seriesProviderIds[providerId.Key.Substring(Search.Length)] = providerId.Value; } } timerInfo.SeriesProviderIds = seriesProviderIds; } private bool IsProgramAlreadyInLibrary(TimerInfo program) { if ((program.EpisodeNumber.HasValue && program.SeasonNumber.HasValue) || !string.IsNullOrWhiteSpace(program.EpisodeTitle)) { var seriesIds = _libraryManager.GetItemIds( new InternalItemsQuery { IncludeItemTypes = new[] { BaseItemKind.Series }, Name = program.Name }).ToArray(); if (seriesIds.Length == 0) { return false; } if (program.EpisodeNumber.HasValue && program.SeasonNumber.HasValue) { var result = _libraryManager.GetItemIds(new InternalItemsQuery { IncludeItemTypes = new[] { BaseItemKind.Episode }, ParentIndexNumber = program.SeasonNumber.Value, IndexNumber = program.EpisodeNumber.Value, AncestorIds = seriesIds, IsVirtualItem = false, Limit = 1 }); if (result.Count > 0) { return true; } } } return false; } /// public void Dispose() { Dispose(true); GC.SuppressFinalize(this); } protected virtual void Dispose(bool disposing) { if (_disposed) { return; } if (disposing) { _recordingDeleteSemaphore.Dispose(); } foreach (var pair in _activeRecordings.ToList()) { pair.Value.CancellationTokenSource.Cancel(); } _disposed = true; } public IEnumerable GetRecordingFolders() { var defaultFolder = RecordingPath; var defaultName = "Recordings"; if (Directory.Exists(defaultFolder)) { yield return new VirtualFolderInfo { Locations = new string[] { defaultFolder }, Name = defaultName }; } var customPath = GetConfiguration().MovieRecordingPath; if (!string.IsNullOrWhiteSpace(customPath) && !string.Equals(customPath, defaultFolder, StringComparison.OrdinalIgnoreCase) && Directory.Exists(customPath)) { yield return new VirtualFolderInfo { Locations = new string[] { customPath }, Name = "Recorded Movies", CollectionType = CollectionTypeOptions.Movies }; } customPath = GetConfiguration().SeriesRecordingPath; if (!string.IsNullOrWhiteSpace(customPath) && !string.Equals(customPath, defaultFolder, StringComparison.OrdinalIgnoreCase) && Directory.Exists(customPath)) { yield return new VirtualFolderInfo { Locations = new string[] { customPath }, Name = "Recorded Shows", CollectionType = CollectionTypeOptions.TvShows }; } } public async Task> DiscoverTuners(bool newDevicesOnly, CancellationToken cancellationToken) { var list = new List(); var configuredDeviceIds = GetConfiguration().TunerHosts .Where(i => !string.IsNullOrWhiteSpace(i.DeviceId)) .Select(i => i.DeviceId) .ToList(); foreach (var host in _liveTvManager.TunerHosts) { var discoveredDevices = await DiscoverDevices(host, TunerDiscoveryDurationMs, cancellationToken).ConfigureAwait(false); if (newDevicesOnly) { discoveredDevices = discoveredDevices.Where(d => !configuredDeviceIds.Contains(d.DeviceId, StringComparison.OrdinalIgnoreCase)) .ToList(); } list.AddRange(discoveredDevices); } return list; } public async Task ScanForTunerDeviceChanges(CancellationToken cancellationToken) { foreach (var host in _liveTvManager.TunerHosts) { await ScanForTunerDeviceChanges(host, cancellationToken).ConfigureAwait(false); } } private async Task ScanForTunerDeviceChanges(ITunerHost host, CancellationToken cancellationToken) { var discoveredDevices = await DiscoverDevices(host, TunerDiscoveryDurationMs, cancellationToken).ConfigureAwait(false); var configuredDevices = GetConfiguration().TunerHosts .Where(i => string.Equals(i.Type, host.Type, StringComparison.OrdinalIgnoreCase)) .ToList(); foreach (var device in discoveredDevices) { var configuredDevice = configuredDevices.FirstOrDefault(i => string.Equals(i.DeviceId, device.DeviceId, StringComparison.OrdinalIgnoreCase)); if (configuredDevice is not null && !string.Equals(device.Url, configuredDevice.Url, StringComparison.OrdinalIgnoreCase)) { _logger.LogInformation("Tuner url has changed from {PreviousUrl} to {NewUrl}", configuredDevice.Url, device.Url); configuredDevice.Url = device.Url; await _liveTvManager.SaveTunerHost(configuredDevice).ConfigureAwait(false); } } } private async Task> DiscoverDevices(ITunerHost host, int discoveryDurationMs, CancellationToken cancellationToken) { try { var discoveredDevices = await host.DiscoverDevices(discoveryDurationMs, cancellationToken).ConfigureAwait(false); foreach (var device in discoveredDevices) { _logger.LogInformation("Discovered tuner device {0} at {1}", host.Name, device.Url); } return discoveredDevices; } catch (Exception ex) { _logger.LogError(ex, "Error discovering tuner devices"); return new List(); } } } }