using MediaBrowser.Common; using MediaBrowser.Common.Configuration; using MediaBrowser.Common.Net; using MediaBrowser.Common.Security; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Drawing; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.LiveTv; using MediaBrowser.Controller.MediaEncoding; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Dto; using MediaBrowser.Model.Entities; using MediaBrowser.Model.Events; using MediaBrowser.Model.LiveTv; using MediaBrowser.Model.Logging; using MediaBrowser.Model.Serialization; using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.Globalization; using System.IO; using System.Linq; using System.Text; using System.Threading; using System.Threading.Tasks; using System.Xml; using MediaBrowser.Model.IO; using MediaBrowser.Common.Events; using MediaBrowser.Common.Extensions; using MediaBrowser.Common.Progress; using MediaBrowser.Controller; using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.TV; using MediaBrowser.Controller.IO; using MediaBrowser.Model.Configuration; using MediaBrowser.Model.Diagnostics; using MediaBrowser.Model.System; using MediaBrowser.Model.Threading; using MediaBrowser.Model.Extensions; using MediaBrowser.Model.Querying; namespace Emby.Server.Implementations.LiveTv.EmbyTV { public class EmbyTV : ILiveTvService, ISupportsDirectStreamProvider, ISupportsNewTimerIds, IDisposable { private readonly IServerApplicationHost _appHost; private readonly ILogger _logger; private readonly IHttpClient _httpClient; private readonly IServerConfigurationManager _config; private readonly IJsonSerializer _jsonSerializer; 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 IProcessFactory _processFactory; private readonly ISystemEvents _systemEvents; public static EmbyTV Current; public event EventHandler DataSourceChanged; public event EventHandler RecordingStatusChanged; private readonly ConcurrentDictionary _activeRecordings = new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase); public EmbyTV(IServerApplicationHost appHost, ILogger logger, IJsonSerializer jsonSerializer, IHttpClient httpClient, IServerConfigurationManager config, ILiveTvManager liveTvManager, IFileSystem fileSystem, ILibraryManager libraryManager, ILibraryMonitor libraryMonitor, IProviderManager providerManager, IMediaEncoder mediaEncoder, ITimerFactory timerFactory, IProcessFactory processFactory, ISystemEvents systemEvents) { Current = this; _appHost = appHost; _logger = logger; _httpClient = httpClient; _config = config; _fileSystem = fileSystem; _libraryManager = libraryManager; _libraryMonitor = libraryMonitor; _providerManager = providerManager; _mediaEncoder = mediaEncoder; _processFactory = processFactory; _systemEvents = systemEvents; _liveTvManager = (LiveTvManager)liveTvManager; _jsonSerializer = jsonSerializer; _seriesTimerProvider = new SeriesTimerManager(fileSystem, jsonSerializer, _logger, Path.Combine(DataPath, "seriestimers")); _timerProvider = new TimerManager(fileSystem, jsonSerializer, _logger, Path.Combine(DataPath, "timers"), _logger, timerFactory); _timerProvider.TimerFired += _timerProvider_TimerFired; _config.NamedConfigurationUpdated += _config_NamedConfigurationUpdated; } private void _config_NamedConfigurationUpdated(object sender, ConfigurationUpdateEventArgs e) { if (string.Equals(e.Key, "livetv", StringComparison.OrdinalIgnoreCase)) { OnRecordingFoldersChanged(); } } public void Start() { _timerProvider.RestartTimers(); _systemEvents.Resume += _systemEvents_Resume; CreateRecordingFolders(); } private void _systemEvents_Resume(object sender, EventArgs e) { _timerProvider.RestartTimers(); } private void OnRecordingFoldersChanged() { CreateRecordingFolders(); } internal void CreateRecordingFolders() { try { CreateRecordingFoldersInternal(); } catch (Exception ex) { _logger.ErrorException("Error creating recording folders", ex); } } internal void CreateRecordingFoldersInternal() { var recordingFolders = GetRecordingFolders(); var virtualFolders = _libraryManager.GetVirtualFolders() .ToList(); 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 { Path = i }).ToArray(); var libraryOptions = new LibraryOptions { PathInfos = mediaPathInfos }; try { _libraryManager.AddVirtualFolder(recordingFolder.Name, recordingFolder.CollectionType, libraryOptions, true); } catch (Exception ex) { _logger.ErrorException("Error creating virtual folder", ex); } 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) { RemovePathFromLibrary(path); } } private void RemovePathFromLibrary(string path) { _logger.Debug("Removing path from library: {0}", path); var requiresRefresh = false; var virtualFolders = _libraryManager.GetVirtualFolders() .ToList(); foreach (var virtualFolder in virtualFolders) { if (!virtualFolder.Locations.Contains(path, StringComparer.OrdinalIgnoreCase)) { continue; } if (virtualFolder.Locations.Length == 1) { // remove entire virtual folder try { _libraryManager.RemoveVirtualFolder(virtualFolder.Name, true); } catch (Exception ex) { _logger.ErrorException("Error removing virtual folder", ex); } } else { try { _libraryManager.RemoveMediaPath(virtualFolder.Name, path); requiresRefresh = true; } catch (Exception ex) { _logger.ErrorException("Error removing media path", ex); } } } if (requiresRefresh) { _libraryManager.ValidateMediaLibrary(new SimpleProgress(), CancellationToken.None); } } public string Name { get { return "Emby"; } } public string DataPath { get { return Path.Combine(_config.CommonApplicationPaths.DataPath, "livetv"); } } private string DefaultRecordingPath { get { return Path.Combine(DataPath, "recordings"); } } private string RecordingPath { get { var path = GetConfiguration().RecordingPath; return string.IsNullOrWhiteSpace(path) ? DefaultRecordingPath : path; } } public string HomePageUrl { get { return "http://emby.media"; } } public async Task GetStatusInfoAsync(CancellationToken cancellationToken) { var status = new LiveTvServiceStatusInfo(); var list = new List(); foreach (var hostInstance in _liveTvManager.TunerHosts) { try { var tuners = await hostInstance.GetTunerInfos(cancellationToken).ConfigureAwait(false); list.AddRange(tuners); } catch (Exception ex) { _logger.ErrorException("Error getting tuners", ex); } } status.Tuners = list; status.Status = LiveTvServiceStatus.Ok; status.Version = _appHost.ApplicationVersion.ToString(); status.IsVisible = false; return status; } public async Task RefreshSeriesTimers(CancellationToken cancellationToken, IProgress progress) { var seriesTimers = await GetSeriesTimersAsync(cancellationToken).ConfigureAwait(false); List channels = null; foreach (var timer in seriesTimers) { List epgData; if (timer.RecordAnyChannel) { if (channels == null) { channels = (await GetChannelsAsync(true, CancellationToken.None).ConfigureAwait(false)).ToList(); } var channelIds = channels.Select(i => i.Id).ToList(); epgData = GetEpgDataForChannels(channelIds); } else { epgData = GetEpgDataForChannel(timer.ChannelId); } await UpdateTimersForSeriesTimer(epgData, timer, false, true).ConfigureAwait(false); } } public async Task RefreshTimers(CancellationToken cancellationToken, IProgress progress) { var timers = await GetTimersAsync(cancellationToken).ConfigureAwait(false); 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 epg = GetEpgDataForChannel(timer.ChannelId); var program = epg.FirstOrDefault(i => string.Equals(i.Id, timer.ProgramId, StringComparison.OrdinalIgnoreCase)); if (program == null) { OnTimerOutOfDate(timer); continue; } RecordingHelper.CopyProgramInfoToTimerInfo(program, timer); _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.ErrorException("Error getting channels", ex); } } 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.ErrorException("Error adding metadata", ex); } } } return list; } private async Task AddMetadata(IListingsProvider provider, ListingsProviderInfo info, List 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 != null) { if (!string.IsNullOrWhiteSpace(epgChannel.Name)) { //tunerChannel.Name = epgChannel.Name; } if (!string.IsNullOrWhiteSpace(epgChannel.ImageUrl)) { tunerChannel.ImageUrl = epgChannel.ImageUrl; tunerChannel.HasImage = true; } } } } private readonly ConcurrentDictionary> _epgChannels = new ConcurrentDictionary>(StringComparer.OrdinalIgnoreCase); private async Task> GetEpgChannels(IListingsProvider provider, ListingsProviderInfo info, bool enableCache, CancellationToken cancellationToken) { List result; if (!enableCache || !_epgChannels.TryGetValue(info.Id, out result)) { result = await provider.GetChannels(info, cancellationToken).ConfigureAwait(false); foreach (var channel in result) { _logger.Info("Found epg channel in {0} {1} {2} {3}", provider.Name, info.ListingsId, channel.Name, channel.Id); } _epgChannels.AddOrUpdate(info.Id, result, (k, v) => 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 string GetMappedChannel(string channelId, NameValuePair[] mappings) { foreach (NameValuePair mapping in mappings) { if (StringHelper.EqualsIgnoreCase(mapping.Name, channelId)) { return mapping.Value; } } return channelId; } private ChannelInfo GetEpgChannelFromTunerChannel(ListingsProviderInfo info, ChannelInfo tunerChannel, List epgChannels) { return GetEpgChannelFromTunerChannel(info.ChannelMappings, tunerChannel, epgChannels); } public ChannelInfo GetEpgChannelFromTunerChannel(NameValuePair[] mappings, ChannelInfo tunerChannel, List epgChannels) { if (!string.IsNullOrWhiteSpace(tunerChannel.Id)) { var mappedTunerChannelId = GetMappedChannel(tunerChannel.Id, mappings); if (string.IsNullOrWhiteSpace(mappedTunerChannelId)) { mappedTunerChannelId = tunerChannel.Id; } var channel = epgChannels.FirstOrDefault(i => string.Equals(mappedTunerChannelId, i.Id, StringComparison.OrdinalIgnoreCase)); if (channel != null) { return channel; } } if (!string.IsNullOrWhiteSpace(tunerChannel.TunerChannelId)) { var tunerChannelId = tunerChannel.TunerChannelId; if (tunerChannelId.IndexOf(".json.schedulesdirect.org", StringComparison.OrdinalIgnoreCase) != -1) { 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 = epgChannels.FirstOrDefault(i => string.Equals(mappedTunerChannelId, i.Id, StringComparison.OrdinalIgnoreCase)); if (channel != null) { return channel; } } if (!string.IsNullOrWhiteSpace(tunerChannel.Number)) { var tunerChannelNumber = GetMappedChannel(tunerChannel.Number, mappings); if (string.IsNullOrWhiteSpace(tunerChannelNumber)) { tunerChannelNumber = tunerChannel.Number; } var channel = epgChannels.FirstOrDefault(i => string.Equals(tunerChannelNumber, i.Number, StringComparison.OrdinalIgnoreCase)); if (channel != null) { return channel; } } if (!string.IsNullOrWhiteSpace(tunerChannel.Name)) { var normalizedName = NormalizeName(tunerChannel.Name); var channel = epgChannels.FirstOrDefault(i => string.Equals(normalizedName, NormalizeName(i.Name ?? string.Empty), StringComparison.OrdinalIgnoreCase)); if (channel != null) { return channel; } } return null; } private string NormalizeName(string value) { return value.Replace(" ", string.Empty).Replace("-", string.Empty); } 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.ErrorException("Error getting channels", ex); } } 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); } var remove = _seriesTimerProvider.GetAll().FirstOrDefault(r => string.Equals(r.Id, timerId, StringComparison.OrdinalIgnoreCase)); if (remove != null) { _seriesTimerProvider.Delete(remove); } return Task.FromResult(true); } private void CancelTimerInternal(string timerId, bool isSeriesCancelled) { var timer = _timerProvider.GetTimer(timerId); if (timer != null) { timer.Status = RecordingStatus.Cancelled; if (string.IsNullOrWhiteSpace(timer.SeriesTimerId) || isSeriesCancelled) { _timerProvider.Delete(timer); } else { _timerProvider.AddOrUpdate(timer, false); } } ActiveRecordingInfo activeRecordingInfo; if (_activeRecordings.TryGetValue(timerId, out activeRecordingInfo)) { activeRecordingInfo.Timer = timer; activeRecordingInfo.CancellationTokenSource.Cancel(); } } public Task CancelTimerAsync(string timerId, CancellationToken cancellationToken) { CancelTimerInternal(timerId, false); return Task.FromResult(true); } public Task DeleteRecordingAsync(string recordingId, CancellationToken cancellationToken) { return Task.FromResult(true); } public Task CreateSeriesTimerAsync(SeriesTimerInfo info, CancellationToken cancellationToken) { throw new NotImplementedException(); } public Task CreateTimerAsync(TimerInfo info, CancellationToken cancellationToken) { throw new NotImplementedException(); } public Task CreateTimer(TimerInfo timer, CancellationToken cancellationToken) { var existingTimer = string.IsNullOrWhiteSpace(timer.ProgramId) ? null : _timerProvider.GetTimerByProgramId(timer.ProgramId); if (existingTimer != 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."); } } timer.Id = Guid.NewGuid().ToString("N"); ProgramInfo programInfo = null; if (!string.IsNullOrWhiteSpace(timer.ProgramId)) { programInfo = GetProgramInfoFromCache(timer.ChannelId, timer.ProgramId); } if (programInfo == null) { _logger.Info("Unable to find program with Id {0}. Will search using start date", timer.ProgramId); programInfo = GetProgramInfoFromCache(timer.ChannelId, timer.StartDate); } if (programInfo != null) { RecordingHelper.CopyProgramInfoToTimerInfo(programInfo, timer); } timer.IsManual = true; _timerProvider.Add(timer); return Task.FromResult(timer.Id); } public async Task CreateSeriesTimer(SeriesTimerInfo info, CancellationToken cancellationToken) { info.Id = Guid.NewGuid().ToString("N"); List epgData; if (info.RecordAnyChannel) { var channels = await GetChannelsAsync(true, CancellationToken.None).ConfigureAwait(false); var channelIds = channels.Select(i => i.Id).ToList(); epgData = GetEpgDataForChannels(channelIds); } else { epgData = GetEpgDataForChannel(info.ChannelId); } // populate info.seriesID var program = epgData.FirstOrDefault(i => string.Equals(i.Id, info.ProgramId, StringComparison.OrdinalIgnoreCase)); if (program != null) { info.SeriesId = program.SeriesId; } 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); } await UpdateTimersForSeriesTimer(epgData, info, true, false).ConfigureAwait(false); return info.Id; } public async Task UpdateSeriesTimerAsync(SeriesTimerInfo info, CancellationToken cancellationToken) { var instance = _seriesTimerProvider.GetAll().FirstOrDefault(i => string.Equals(i.Id, info.Id, StringComparison.OrdinalIgnoreCase)); if (instance != 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); List epgData; if (instance.RecordAnyChannel) { var channels = await GetChannelsAsync(true, CancellationToken.None).ConfigureAwait(false); var channelIds = channels.Select(i => i.Id).ToList(); epgData = GetEpgDataForChannels(channelIds); } else { epgData = GetEpgDataForChannel(instance.ChannelId); } await UpdateTimersForSeriesTimer(epgData, instance, true, true).ConfigureAwait(false); } } public Task UpdateTimerAsync(TimerInfo updatedTimer, CancellationToken cancellationToken) { var existingTimer = _timerProvider.GetTimer(updatedTimer.Id); if (existingTimer == null) { throw new ResourceNotFoundException(); } // Only update if not currently active ActiveRecordingInfo activeRecordingInfo; if (!_activeRecordings.TryGetValue(updatedTimer.Id, out activeRecordingInfo)) { existingTimer.PrePaddingSeconds = updatedTimer.PrePaddingSeconds; existingTimer.PostPaddingSeconds = updatedTimer.PostPaddingSeconds; existingTimer.IsPostPaddingRequired = updatedTimer.IsPostPaddingRequired; existingTimer.IsPrePaddingRequired = updatedTimer.IsPrePaddingRequired; _timerProvider.Update(existingTimer); } return Task.FromResult(true); } private 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.HomePageUrl = updatedTimer.HomePageUrl; existingTimer.IsKids = updatedTimer.IsKids; existingTimer.IsNews = updatedTimer.IsNews; existingTimer.IsMovie = updatedTimer.IsMovie; existingTimer.IsSeries = updatedTimer.IsSeries; existingTimer.IsLive = updatedTimer.IsLive; existingTimer.IsPremiere = updatedTimer.IsPremiere; existingTimer.IsProgramSeries = updatedTimer.IsProgramSeries; existingTimer.IsRepeat = updatedTimer.IsRepeat; existingTimer.IsSports = updatedTimer.IsSports; 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; } public Task GetChannelImageAsync(string channelId, CancellationToken cancellationToken) { throw new NotImplementedException(); } public Task GetRecordingImageAsync(string recordingId, CancellationToken cancellationToken) { throw new NotImplementedException(); } public Task GetProgramImageAsync(string programId, string channelId, CancellationToken cancellationToken) { throw new NotImplementedException(); } public async Task> GetRecordingsAsync(CancellationToken cancellationToken) { return new List(); } public string GetActiveRecordingPath(string id) { ActiveRecordingInfo info; if (_activeRecordings.TryGetValue(id, out info)) { return info.Path; } return null; } public IEnumerable GetAllActiveRecordings() { return _activeRecordings.Values.Where(i => i.Timer.Status == RecordingStatus.InProgress && !i.CancellationTokenSource.IsCancellationRequested); } public ActiveRecordingInfo GetActiveRecordingInfo(string path) { if (string.IsNullOrWhiteSpace(path)) { return null; } foreach (var recording in _activeRecordings.Values) { if (string.Equals(recording.Path, path, StringComparison.Ordinal) && !recording.CancellationTokenSource.IsCancellationRequested) { var timer = recording.Timer; if (timer.Status != RecordingStatus.InProgress) { return null; } return recording; } } 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 != null) { defaults.SeriesId = program.SeriesId; defaults.ProgramId = program.Id; defaults.RecordNewOnly = !program.IsRepeat; } defaults.SkipEpisodesInLibrary = defaults.RecordNewOnly; defaults.KeepUntil = KeepUntil.UntilDeleted; return Task.FromResult(defaults); } public Task> GetSeriesTimersAsync(CancellationToken cancellationToken) { return Task.FromResult((IEnumerable)_seriesTimerProvider.GetAll()); } public async Task> GetProgramsAsync(string channelId, DateTime startDateUtc, DateTime endDateUtc, CancellationToken cancellationToken) { try { return await GetProgramsAsyncInternal(channelId, startDateUtc, endDateUtc, cancellationToken).ConfigureAwait(false); } catch (OperationCanceledException) { throw; } catch (Exception ex) { _logger.ErrorException("Error getting programs", ex); return GetEpgDataForChannel(channelId).Where(i => i.StartDate <= endDateUtc && i.EndDate >= startDateUtc); } } private bool IsListingProviderEnabledForTuner(ListingsProviderInfo info, string tunerHostId) { if (info.EnableAllTuners) { return true; } if (string.IsNullOrWhiteSpace(tunerHostId)) { throw new ArgumentNullException("tunerHostId"); } return info.EnabledTuners.Contains(tunerHostId, StringComparer.OrdinalIgnoreCase); } private async Task> GetProgramsAsyncInternal(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.Debug("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.Debug("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); List programs; if (epgChannel == null) { _logger.Debug("EPG channel not found for tuner channel {0}-{1} from {2}-{3}", channel.Number, channel.Name, provider.Item1.Name, provider.Item2.ListingsId ?? string.Empty); programs = new List(); } else { 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; if (provider.Item2.EnableNewProgramIds) { program.Id += "_" + channelId; } } if (programs.Count > 0) { SaveEpgDataForChannel(channelId, programs); return programs; } } return new List(); } private List> GetListingProviders() { return GetConfiguration().ListingProviders .Select(i => { var provider = _liveTvManager.ListingProviders.FirstOrDefault(l => string.Equals(l.Type, i.Type, StringComparison.OrdinalIgnoreCase)); return provider == null ? null : new Tuple(provider, i); }) .Where(i => i != null) .ToList(); } public Task GetRecordingStream(string recordingId, string streamId, CancellationToken cancellationToken) { throw new NotImplementedException(); } private readonly SemaphoreSlim _liveStreamsSemaphore = new SemaphoreSlim(1, 1); private readonly List _liveStreams = new List(); public async Task GetChannelStream(string channelId, string streamId, CancellationToken cancellationToken) { var result = await GetChannelStreamWithDirectStreamProvider(channelId, streamId, cancellationToken).ConfigureAwait(false); return result.Item1; } public async Task> GetChannelStreamWithDirectStreamProvider(string channelId, string streamId, CancellationToken cancellationToken) { var result = await GetChannelStreamInternal(channelId, streamId, cancellationToken).ConfigureAwait(false); return new Tuple(result.Item2, result.Item1 as IDirectStreamProvider); } private MediaSourceInfo CloneMediaSource(MediaSourceInfo mediaSource, bool enableStreamSharing) { var json = _jsonSerializer.SerializeToString(mediaSource); mediaSource = _jsonSerializer.DeserializeFromString(json); mediaSource.Id = Guid.NewGuid().ToString("N") + "_" + mediaSource.Id; //if (mediaSource.DateLiveStreamOpened.HasValue && enableStreamSharing) //{ // var ticks = (DateTime.UtcNow - mediaSource.DateLiveStreamOpened.Value).Ticks - TimeSpan.FromSeconds(10).Ticks; // ticks = Math.Max(0, ticks); // mediaSource.Path += "?t=" + ticks.ToString(CultureInfo.InvariantCulture) + "&s=" + mediaSource.DateLiveStreamOpened.Value.Ticks.ToString(CultureInfo.InvariantCulture); //} return mediaSource; } public async Task GetLiveStream(string uniqueId) { await _liveStreamsSemaphore.WaitAsync().ConfigureAwait(false); try { return _liveStreams .FirstOrDefault(i => string.Equals(i.UniqueId, uniqueId, StringComparison.OrdinalIgnoreCase)); } finally { _liveStreamsSemaphore.Release(); } } private async Task> GetChannelStreamInternal(string channelId, string streamId, CancellationToken cancellationToken) { _logger.Info("Streaming Channel " + channelId); await _liveStreamsSemaphore.WaitAsync(cancellationToken).ConfigureAwait(false); try { var result = _liveStreams.FirstOrDefault(i => string.Equals(i.OriginalStreamId, streamId, StringComparison.OrdinalIgnoreCase)); if (result != null && result.EnableStreamSharing) { var openedMediaSource = CloneMediaSource(result.OpenedMediaSource, result.EnableStreamSharing); result.SharedStreamIds.Add(openedMediaSource.Id); _logger.Info("Live stream {0} consumer count is now {1}", streamId, result.ConsumerCount); return new Tuple(result, openedMediaSource, result.TunerHost); } foreach (var hostInstance in _liveTvManager.TunerHosts) { try { result = await hostInstance.GetChannelStream(channelId, streamId, cancellationToken).ConfigureAwait(false); var openedMediaSource = CloneMediaSource(result.OpenedMediaSource, result.EnableStreamSharing); result.SharedStreamIds.Add(openedMediaSource.Id); _liveStreams.Add(result); result.TunerHost = hostInstance; result.OriginalStreamId = streamId; _logger.Info("Returning mediasource streamId {0}, mediaSource.Id {1}, mediaSource.LiveStreamId {2}", streamId, openedMediaSource.Id, openedMediaSource.LiveStreamId); return new Tuple(result, openedMediaSource, hostInstance); } catch (FileNotFoundException) { } catch (OperationCanceledException) { } } } finally { _liveStreamsSemaphore.Release(); } throw new Exception("Tuner not found."); } public async Task> GetChannelStreamMediaSources(string channelId, CancellationToken cancellationToken) { if (string.IsNullOrWhiteSpace(channelId)) { throw new ArgumentNullException("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(string recordingId, CancellationToken cancellationToken) { ActiveRecordingInfo info; recordingId = recordingId.Replace("recording", string.Empty); if (_activeRecordings.TryGetValue(recordingId, out info)) { var stream = new MediaSourceInfo { Path = _appHost.GetLocalApiUrl("127.0.0.1") + "/LiveTv/LiveRecordings/" + recordingId + "/stream", Id = recordingId, SupportsDirectPlay = false, SupportsDirectStream = true, SupportsTranscoding = true, IsInfiniteStream = true, RequiresOpening = false, RequiresClosing = false, Protocol = MediaBrowser.Model.MediaInfo.MediaProtocol.Http, BufferMs = 0, IgnoreDts = true, IgnoreIndex = true }; var isAudio = false; await new LiveStreamHelper(_mediaEncoder, _logger).AddMediaInfoWithProbe(stream, isAudio, cancellationToken).ConfigureAwait(false); return new List { stream }; } throw new FileNotFoundException(); } public async Task> GetRecordingStreamMediaSources(ActiveRecordingInfo info, CancellationToken cancellationToken) { var stream = new MediaSourceInfo { Path = _appHost.GetLocalApiUrl("127.0.0.1") + "/LiveTv/LiveRecordings/" + info.Id + "/stream", Id = info.Id, SupportsDirectPlay = false, SupportsDirectStream = true, SupportsTranscoding = true, IsInfiniteStream = true, RequiresOpening = false, RequiresClosing = false, Protocol = MediaBrowser.Model.MediaInfo.MediaProtocol.Http, BufferMs = 0, IgnoreDts = true, IgnoreIndex = true }; var isAudio = false; await new LiveStreamHelper(_mediaEncoder, _logger).AddMediaInfoWithProbe(stream, isAudio, cancellationToken).ConfigureAwait(false); return new List { stream }; } public async Task CloseLiveStream(string id, CancellationToken cancellationToken) { // Ignore the consumer id //id = id.Substring(id.IndexOf('_') + 1); await _liveStreamsSemaphore.WaitAsync(cancellationToken).ConfigureAwait(false); try { var stream = _liveStreams.FirstOrDefault(i => i.SharedStreamIds.Contains(id)); if (stream != null) { stream.SharedStreamIds.Remove(id); _logger.Info("Live stream {0} consumer count is now {1}", id, stream.ConsumerCount); if (stream.ConsumerCount <= 0) { _liveStreams.Remove(stream); _logger.Info("Closing live stream {0}", id); await stream.Close().ConfigureAwait(false); _logger.Info("Live stream {0} closed successfully", id); } } else { _logger.Warn("Live stream not found: {0}, unable to close", id); } } catch (OperationCanceledException) { } catch (Exception ex) { _logger.ErrorException("Error closing live stream", ex); } finally { _liveStreamsSemaphore.Release(); } } public Task RecordLiveStream(string id, CancellationToken cancellationToken) { return Task.FromResult(0); } public Task ResetTuner(string id, CancellationToken cancellationToken) { return Task.FromResult(0); } async void _timerProvider_TimerFired(object sender, GenericEventArgs e) { var timer = e.Argument; _logger.Info("Recording timer fired."); try { var recordingEndDate = timer.EndDate.AddSeconds(timer.PostPaddingSeconds); if (recordingEndDate <= DateTime.UtcNow) { _logger.Warn("Recording timer fired for updatedTimer {0}, Id: {1}, but the program has already ended.", timer.Name, timer.Id); OnTimerOutOfDate(timer); return; } var registration = await _liveTvManager.GetRegistrationInfo("dvr").ConfigureAwait(false); if (!registration.IsValid) { _logger.Warn("Emby Premiere required to use Emby DVR."); OnTimerOutOfDate(timer); return; } var activeRecordingInfo = new ActiveRecordingInfo { CancellationTokenSource = new CancellationTokenSource(), Timer = timer, Id = timer.Id }; if (_activeRecordings.TryAdd(timer.Id, activeRecordingInfo)) { await RecordStream(timer, recordingEndDate, activeRecordingInfo, activeRecordingInfo.CancellationTokenSource.Token).ConfigureAwait(false); } else { _logger.Info("Skipping RecordStream because it's already in progress."); } } catch (OperationCanceledException) { } catch (Exception ex) { _logger.ErrorException("Error recording stream", ex); } } private string GetRecordingPath(TimerInfo timer, 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"); } var folderName = _fileSystem.GetValidFilename(timer.Name).Trim(); // 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("Season {0}", timer.SeasonNumber.Value.ToString(CultureInfo.InvariantCulture)); 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) + ")"; } 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) + ")"; } 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, CancellationToken cancellationToken) { if (timer == null) { throw new ArgumentNullException("timer"); } ProgramInfo programInfo = null; if (!string.IsNullOrWhiteSpace(timer.ProgramId)) { programInfo = GetProgramInfoFromCache(timer.ChannelId, timer.ProgramId); } if (programInfo == null) { _logger.Info("Unable to find program with Id {0}. Will search using start date", timer.ProgramId); programInfo = GetProgramInfoFromCache(timer.ChannelId, timer.StartDate); } if (programInfo != null) { RecordingHelper.CopyProgramInfoToTimerInfo(programInfo, timer); activeRecordingInfo.Program = programInfo; } string seriesPath = null; var recordPath = GetRecordingPath(timer, out seriesPath); var recordingStatus = RecordingStatus.New; string liveStreamId = null; OnRecordingStatusChanged(); try { var recorder = await GetRecorder().ConfigureAwait(false); var allMediaSources = await GetChannelStreamMediaSources(timer.ChannelId, CancellationToken.None).ConfigureAwait(false); var liveStreamInfo = await GetChannelStreamInternal(timer.ChannelId, allMediaSources[0].Id, CancellationToken.None) .ConfigureAwait(false); var mediaStreamInfo = liveStreamInfo.Item2; liveStreamId = mediaStreamInfo.Id; // HDHR doesn't seem to release the tuner right away after first probing with ffmpeg //await Task.Delay(3000, cancellationToken).ConfigureAwait(false); recordPath = recorder.GetOutputPath(mediaStreamInfo, recordPath); recordPath = EnsureFileUnique(recordPath, timer.Id); _libraryMonitor.ReportFileSystemChangeBeginning(recordPath); _fileSystem.CreateDirectory(_fileSystem.GetDirectoryName(recordPath)); activeRecordingInfo.Path = recordPath; var duration = recordingEndDate - DateTime.UtcNow; _logger.Info("Beginning recording. Will record for {0} minutes.", duration.TotalMinutes.ToString(CultureInfo.InvariantCulture)); _logger.Info("Writing file to path: " + recordPath); _logger.Info("Opening recording stream from tuner provider"); Action onStarted = () => { timer.Status = RecordingStatus.InProgress; _timerProvider.AddOrUpdate(timer, false); SaveRecordingMetadata(timer, recordPath, seriesPath); TriggerRefresh(recordPath); EnforceKeepUpTo(timer, seriesPath); }; await recorder.Record(liveStreamInfo.Item1 as IDirectStreamProvider, mediaStreamInfo, recordPath, duration, onStarted, cancellationToken).ConfigureAwait(false); recordingStatus = RecordingStatus.Completed; _logger.Info("Recording completed: {0}", recordPath); } catch (OperationCanceledException) { _logger.Info("Recording stopped: {0}", recordPath); recordingStatus = RecordingStatus.Completed; } catch (Exception ex) { _logger.ErrorException("Error recording to {0}", ex, recordPath); recordingStatus = RecordingStatus.Error; } if (!string.IsNullOrWhiteSpace(liveStreamId)) { try { await CloseLiveStream(liveStreamId, CancellationToken.None).ConfigureAwait(false); } catch (Exception ex) { _logger.ErrorException("Error closing live stream", ex); } } TriggerRefresh(recordPath); _libraryMonitor.ReportFileSystemChangeComplete(recordPath, true); ActiveRecordingInfo removed; _activeRecordings.TryRemove(timer.Id, out removed); if (recordingStatus != RecordingStatus.Completed && DateTime.UtcNow < timer.EndDate && timer.RetryCount < 10) { const int retryIntervalSeconds = 60; _logger.Info("Retrying recording in {0} seconds.", retryIntervalSeconds); timer.Status = RecordingStatus.New; timer.StartDate = DateTime.UtcNow.AddSeconds(retryIntervalSeconds); timer.RetryCount++; _timerProvider.AddOrUpdate(timer); } else if (_fileSystem.FileExists(recordPath)) { timer.RecordingPath = recordPath; timer.Status = RecordingStatus.Completed; _timerProvider.AddOrUpdate(timer, false); OnSuccessfulRecording(timer, recordPath); } else { _timerProvider.Delete(timer); } OnRecordingStatusChanged(); } private void TriggerRefresh(string path) { var item = GetAffectedBaseItem(_fileSystem.GetDirectoryName(path)); if (item != null) { item.ChangedExternally(); } } private BaseItem GetAffectedBaseItem(string path) { BaseItem item = null; while (item == null && !string.IsNullOrEmpty(path)) { item = _libraryManager.FindByPath(path, null); path = _fileSystem.GetDirectoryName(path); } if (item != null) { // If the item has been deleted find the first valid parent that still exists while (!_fileSystem.DirectoryExists(item.Path) && !_fileSystem.FileExists(item.Path)) { item = item.GetParent(); if (item == null) { break; } } } return item; } private void OnRecordingStatusChanged() { EventHelper.FireEventIfNotNull(RecordingStatusChanged, this, new RecordingStatusChangedEventArgs { }, _logger); } private async void 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 == null || seriesTimer.KeepUpTo <= 1) { 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 => _fileSystem.FileExists(i.RecordingPath)) .Skip(seriesTimer.KeepUpTo - 1) .ToList(); await DeleteLibraryItemsForTimers(timersToDelete).ConfigureAwait(false); var librarySeries = _libraryManager.FindByPath(seriesPath, true) as Folder; if (librarySeries == null) { return; } var episodesToDelete = (librarySeries.GetItemList(new InternalItemsQuery { OrderBy = new[] { new Tuple(ItemSortBy.DateCreated, SortOrder.Descending) }, IsVirtualItem = false, IsFolder = false, Recursive = true, DtoOptions = new DtoOptions(true) })) .Where(i => i.LocationType == LocationType.FileSystem && _fileSystem.FileExists(i.Path)) .Skip(seriesTimer.KeepUpTo - 1) .ToList(); foreach (var item in episodesToDelete) { try { await _libraryManager.DeleteItem(item, new DeleteOptions { DeleteFileLocation = true }).ConfigureAwait(false); } catch (Exception ex) { _logger.ErrorException("Error deleting item", ex); } } } finally { _recordingDeleteSemaphore.Release(); } } private readonly SemaphoreSlim _recordingDeleteSemaphore = new SemaphoreSlim(1, 1); private async Task DeleteLibraryItemsForTimers(List timers) { foreach (var timer in timers) { if (_disposed) { return; } try { await DeleteLibraryItemForTimer(timer).ConfigureAwait(false); } catch (Exception ex) { _logger.ErrorException("Error deleting recording", ex); } } } private async Task DeleteLibraryItemForTimer(TimerInfo timer) { var libraryItem = _libraryManager.FindByPath(timer.RecordingPath, false); if (libraryItem != null) { await _libraryManager.DeleteItem(libraryItem, new DeleteOptions { DeleteFileLocation = true }).ConfigureAwait(false); } else { try { _fileSystem.DeleteFile(timer.RecordingPath); } catch (IOException) { } } _timerProvider.Delete(timer); } private string EnsureFileUnique(string path, string timerId) { var originalPath = path; var index = 1; while (FileExists(path, timerId)) { var parent = _fileSystem.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 (_fileSystem.FileExists(path)) { return true; } var hasRecordingAtPath = _activeRecordings .Values .ToList() .Any(i => string.Equals(i.Path, path, StringComparison.OrdinalIgnoreCase) && !string.Equals(i.Timer.Id, timerId, StringComparison.OrdinalIgnoreCase)); if (hasRecordingAtPath) { return true; } return false; } private async Task GetRecorder() { var config = GetConfiguration(); var regInfo = await _liveTvManager.GetRegistrationInfo("dvr").ConfigureAwait(false); if (regInfo.IsValid) { if (config.EnableRecordingEncoding) { return new EncodedRecorder(_logger, _fileSystem, _mediaEncoder, _config.ApplicationPaths, _jsonSerializer, config, _httpClient, _processFactory, _config); } return new DirectRecorder(_logger, _httpClient, _fileSystem); //var options = new LiveTvOptions //{ // EnableOriginalAudioWithEncodedRecordings = true, // RecordedVideoCodec = "copy", // RecordingEncodingFormat = "ts" //}; //return new EncodedRecorder(_logger, _fileSystem, _mediaEncoder, _config.ApplicationPaths, _jsonSerializer, options, _httpClient, _processFactory, _config); } throw new InvalidOperationException("Emby DVR Requires an active Emby Premiere subscription."); } private void OnSuccessfulRecording(TimerInfo timer, string path) { //if (timer.IsProgramSeries && GetConfiguration().EnableAutoOrganize) //{ // try // { // // this is to account for the library monitor holding a lock for additional time after the change is complete. // // ideally this shouldn't be hard-coded // await Task.Delay(30000).ConfigureAwait(false); // var organize = new EpisodeFileOrganizer(_organizationService, _config, _fileSystem, _logger, _libraryManager, _libraryMonitor, _providerManager); // var result = await organize.OrganizeEpisodeFile(path, _config.GetAutoOrganizeOptions(), false, CancellationToken.None).ConfigureAwait(false); // if (result.Status == FileSortingStatus.Success) // { // return; // } // } // catch (Exception ex) // { // _logger.ErrorException("Error processing new recording", ex); // } //} PostProcessRecording(timer, path); } private void PostProcessRecording(TimerInfo timer, string path) { var options = GetConfiguration(); if (string.IsNullOrWhiteSpace(options.RecordingPostProcessor)) { return; } try { var process = _processFactory.Create(new ProcessOptions { Arguments = GetPostProcessArguments(path, options.RecordingPostProcessorArguments), CreateNoWindow = true, EnableRaisingEvents = true, ErrorDialog = false, FileName = options.RecordingPostProcessor, IsHidden = true, UseShellExecute = false }); _logger.Info("Running recording post processor {0} {1}", process.StartInfo.FileName, process.StartInfo.Arguments); process.Exited += Process_Exited; process.Start(); } catch (Exception ex) { _logger.ErrorException("Error running recording post processor", ex); } } private string GetPostProcessArguments(string path, string arguments) { return arguments.Replace("{path}", path, StringComparison.OrdinalIgnoreCase); } private void Process_Exited(object sender, EventArgs e) { var process = (IProcess)sender; try { _logger.Info("Recording post-processing script completed with exit code {0}", process.ExitCode); } catch { } process.Dispose(); } private async Task SaveRecordingImage(string recordingPath, LiveTvProgram program, ItemImageInfo image) { if (!image.IsLocalFile) { image = await _libraryManager.ConvertImageToLocal(program, image, 0).ConfigureAwait(false); } string imageSaveFilenameWithoutExtension = null; switch (image.Type) { case ImageType.Primary: if (program.IsSeries) { imageSaveFilenameWithoutExtension = Path.GetFileNameWithoutExtension(recordingPath) + "-thumb"; } else { imageSaveFilenameWithoutExtension = "poster"; } break; case ImageType.Logo: imageSaveFilenameWithoutExtension = "logo"; break; case ImageType.Thumb: imageSaveFilenameWithoutExtension = "landscape"; break; case ImageType.Backdrop: imageSaveFilenameWithoutExtension = "fanart"; break; default: break; } if (string.IsNullOrWhiteSpace(imageSaveFilenameWithoutExtension)) { return; } var imageSavePath = Path.Combine(_fileSystem.GetDirectoryName(recordingPath), imageSaveFilenameWithoutExtension); // preserve original image extension imageSavePath = Path.ChangeExtension(imageSavePath, Path.GetExtension(image.Path)); _fileSystem.CopyFile(image.Path, imageSavePath, true); } private async Task SaveRecordingImages(string recordingPath, LiveTvProgram program) { var image = program.GetImageInfo(ImageType.Primary, 0); if (image != null && program.IsMovie) { try { await SaveRecordingImage(recordingPath, program, image).ConfigureAwait(false); } catch (Exception ex) { _logger.ErrorException("Error saving recording image", ex); } } if (!program.IsSeries) { image = program.GetImageInfo(ImageType.Backdrop, 0); if (image != null) { try { await SaveRecordingImage(recordingPath, program, image).ConfigureAwait(false); } catch (Exception ex) { _logger.ErrorException("Error saving recording image", ex); } } image = program.GetImageInfo(ImageType.Thumb, 0); if (image != null) { try { await SaveRecordingImage(recordingPath, program, image).ConfigureAwait(false); } catch (Exception ex) { _logger.ErrorException("Error saving recording image", ex); } } image = program.GetImageInfo(ImageType.Logo, 0); if (image != null) { try { await SaveRecordingImage(recordingPath, program, image).ConfigureAwait(false); } catch (Exception ex) { _logger.ErrorException("Error saving recording image", ex); } } } } private async void SaveRecordingMetadata(TimerInfo timer, string recordingPath, string seriesPath) { try { var program = string.IsNullOrWhiteSpace(timer.ProgramId) ? null : _libraryManager.GetItemList(new InternalItemsQuery { IncludeItemTypes = new[] { typeof(LiveTvProgram).Name }, Limit = 1, ExternalId = timer.ProgramId, DtoOptions = new DtoOptions(true) }).FirstOrDefault() as LiveTvProgram; // dummy this up if (program == null) { program = new LiveTvProgram { Name = timer.Name, HomePageUrl = timer.HomePageUrl, 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) { AddGenre(program.Genres, "Sports"); } if (timer.IsKids) { AddGenre(program.Genres, "Kids"); AddGenre(program.Genres, "Children"); } if (timer.IsNews) { AddGenre(program.Genres, "News"); } if (timer.IsProgramSeries) { SaveSeriesNfo(timer, seriesPath); SaveVideoNfo(timer, recordingPath, program, false); } else if (!timer.IsMovie || timer.IsSports || timer.IsNews) { SaveVideoNfo(timer, recordingPath, program, true); } else { SaveVideoNfo(timer, recordingPath, program, false); } await SaveRecordingImages(recordingPath, program).ConfigureAwait(false); } catch (Exception ex) { _logger.ErrorException("Error saving nfo", ex); } } private void SaveSeriesNfo(TimerInfo timer, string seriesPath) { var nfoPath = Path.Combine(seriesPath, "tvshow.nfo"); if (_fileSystem.FileExists(nfoPath)) { return; } using (var stream = _fileSystem.GetFileStream(nfoPath, FileOpenMode.Create, FileAccessMode.Write, FileShareMode.Read)) { var settings = new XmlWriterSettings { Indent = true, Encoding = Encoding.UTF8, CloseOutput = false }; using (XmlWriter writer = XmlWriter.Create(stream, settings)) { writer.WriteStartDocument(true); writer.WriteStartElement("tvshow"); if (!string.IsNullOrWhiteSpace(timer.Name)) { writer.WriteElementString("title", timer.Name); } if (!string.IsNullOrEmpty(timer.OfficialRating)) { writer.WriteElementString("mpaa", timer.OfficialRating); } foreach (var genre in timer.Genres) { writer.WriteElementString("genre", genre); } writer.WriteEndElement(); writer.WriteEndDocument(); } } } public const string DateAddedFormat = "yyyy-MM-dd HH:mm:ss"; private void SaveVideoNfo(TimerInfo timer, string recordingPath, BaseItem item, bool lockData) { var nfoPath = Path.ChangeExtension(recordingPath, ".nfo"); if (_fileSystem.FileExists(nfoPath)) { return; } using (var stream = _fileSystem.GetFileStream(nfoPath, FileOpenMode.Create, FileAccessMode.Write, FileShareMode.Read)) { var settings = new XmlWriterSettings { Indent = true, Encoding = Encoding.UTF8, CloseOutput = false }; var options = _config.GetNfoConfiguration(); using (XmlWriter writer = XmlWriter.Create(stream, settings)) { writer.WriteStartDocument(true); if (timer.IsProgramSeries) { writer.WriteStartElement("episodedetails"); if (!string.IsNullOrWhiteSpace(timer.EpisodeTitle)) { writer.WriteElementString("title", timer.EpisodeTitle); } if (item.PremiereDate.HasValue) { var formatString = options.ReleaseDateFormat; writer.WriteElementString("aired", item.PremiereDate.Value.ToLocalTime().ToString(formatString)); } if (item.IndexNumber.HasValue) { writer.WriteElementString("episode", item.IndexNumber.Value.ToString(CultureInfo.InvariantCulture)); } if (item.ParentIndexNumber.HasValue) { writer.WriteElementString("season", item.ParentIndexNumber.Value.ToString(CultureInfo.InvariantCulture)); } } else { writer.WriteStartElement("movie"); if (!string.IsNullOrWhiteSpace(item.Name)) { writer.WriteElementString("title", item.Name); } if (!string.IsNullOrWhiteSpace(item.OriginalTitle)) { writer.WriteElementString("originaltitle", item.OriginalTitle); } if (item.PremiereDate.HasValue) { var formatString = options.ReleaseDateFormat; writer.WriteElementString("premiered", item.PremiereDate.Value.ToLocalTime().ToString(formatString)); writer.WriteElementString("releasedate", item.PremiereDate.Value.ToLocalTime().ToString(formatString)); } } writer.WriteElementString("dateadded", DateTime.UtcNow.ToLocalTime().ToString(DateAddedFormat)); if (item.ProductionYear.HasValue) { writer.WriteElementString("year", item.ProductionYear.Value.ToString(CultureInfo.InvariantCulture)); } if (!string.IsNullOrEmpty(item.OfficialRating)) { writer.WriteElementString("mpaa", item.OfficialRating); } var overview = (item.Overview ?? string.Empty) .StripHtml() .Replace(""", "'"); writer.WriteElementString("plot", overview); if (lockData) { writer.WriteElementString("lockdata", true.ToString().ToLower()); } if (item.CommunityRating.HasValue) { writer.WriteElementString("rating", item.CommunityRating.Value.ToString(CultureInfo.InvariantCulture)); } foreach (var genre in item.Genres) { writer.WriteElementString("genre", genre); } if (!string.IsNullOrWhiteSpace(item.HomePageUrl)) { writer.WriteElementString("website", item.HomePageUrl); } var people = item.Id == Guid.Empty ? new List() : _libraryManager.GetPeople(item); var directors = people .Where(i => IsPersonType(i, PersonType.Director)) .Select(i => i.Name) .ToList(); foreach (var person in directors) { writer.WriteElementString("director", person); } var writers = people .Where(i => IsPersonType(i, PersonType.Writer)) .Select(i => i.Name) .Distinct(StringComparer.OrdinalIgnoreCase) .ToList(); foreach (var person in writers) { writer.WriteElementString("writer", person); } foreach (var person in writers) { writer.WriteElementString("credits", person); } var tmdbCollection = item.GetProviderId(MetadataProviders.TmdbCollection); if (!string.IsNullOrEmpty(tmdbCollection)) { writer.WriteElementString("collectionnumber", tmdbCollection); } var imdb = item.GetProviderId(MetadataProviders.Imdb); if (!string.IsNullOrEmpty(imdb)) { if (item is Series) { writer.WriteElementString("imdb_id", imdb); } else { writer.WriteElementString("imdbid", imdb); } } var tvdb = item.GetProviderId(MetadataProviders.Tvdb); if (!string.IsNullOrEmpty(tvdb)) { writer.WriteElementString("tvdbid", tvdb); } var tmdb = item.GetProviderId(MetadataProviders.Tmdb); if (!string.IsNullOrEmpty(tmdb)) { writer.WriteElementString("tmdbid", tmdb); } if (item.CriticRating.HasValue) { writer.WriteElementString("criticrating", item.CriticRating.Value.ToString(CultureInfo.InvariantCulture)); } if (!string.IsNullOrWhiteSpace(item.Tagline)) { writer.WriteElementString("tagline", item.Tagline); } foreach (var studio in item.Studios) { writer.WriteElementString("studio", studio); } writer.WriteEndElement(); writer.WriteEndDocument(); } } } private static bool IsPersonType(PersonInfo person, string type) { return string.Equals(person.Type, type, StringComparison.OrdinalIgnoreCase) || string.Equals(person.Role, type, StringComparison.OrdinalIgnoreCase); } private void AddGenre(List genres, string genre) { if (!genres.Contains(genre, StringComparer.OrdinalIgnoreCase)) { genres.Add(genre); } } private ProgramInfo GetProgramInfoFromCache(string channelId, string programId) { var epgData = GetEpgDataForChannel(channelId); return epgData.FirstOrDefault(p => string.Equals(p.Id, programId, StringComparison.OrdinalIgnoreCase)); } private ProgramInfo GetProgramInfoFromCache(string channelId, DateTime startDateUtc) { var epgData = GetEpgDataForChannel(channelId); var startDateTicks = startDateUtc.Ticks; // Find the first program that starts within 3 minutes return epgData.FirstOrDefault(p => Math.Abs(startDateTicks - p.StartDate.Ticks) <= TimeSpan.FromMinutes(3).Ticks); } private LiveTvOptions GetConfiguration() { return _config.GetConfiguration("livetv"); } private bool ShouldCancelTimerForSeriesTimer(SeriesTimerInfo seriesTimer, TimerInfo timer) { if (timer.IsManual) { return false; } if (!seriesTimer.RecordAnyTime) { if (Math.Abs(seriesTimer.StartDate.TimeOfDay.Ticks - timer.StartDate.TimeOfDay.Ticks) >= TimeSpan.FromMinutes(5).Ticks) { return true; } } //if (!seriesTimer.Days.Contains(timer.StartDate.ToLocalTime().DayOfWeek)) //{ // 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) { foreach (var timer in timers.Skip(1)) { // TODO: Get smarter, prefer HD, etc timer.Status = RecordingStatus.Cancelled; _timerProvider.Update(timer); } } private void SearchForDuplicateShowIds(List 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; } HandleDuplicateShowIds(groupTimers); } } private async Task UpdateTimersForSeriesTimer(List epgData, SeriesTimerInfo seriesTimer, bool updateTimerSettings, bool deleteInvalidTimers) { var allTimers = GetTimersForSeries(seriesTimer, epgData) .ToList(); var registration = await _liveTvManager.GetRegistrationInfo("seriesrecordings").ConfigureAwait(false); var enabledTimersForSeries = new List(); if (registration.IsValid) { foreach (var timer in allTimers) { var existingTimer = _timerProvider.GetTimer(timer.Id); if (existingTimer == null) { existingTimer = string.IsNullOrWhiteSpace(timer.ProgramId) ? null : _timerProvider.GetTimerByProgramId(timer.ProgramId); } if (existingTimer == null) { if (ShouldCancelTimerForSeriesTimer(seriesTimer, timer)) { timer.Status = RecordingStatus.Cancelled; } else { enabledTimersForSeries.Add(timer); } _timerProvider.Add(timer); } else { // 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 ActiveRecordingInfo activeRecordingInfo; if (!_activeRecordings.TryGetValue(timer.Id, out activeRecordingInfo) && !_activeRecordings.TryGetValue(existingTimer.Id, out activeRecordingInfo)) { UpdateExistingTimerWithNewMetadata(existingTimer, timer); // Needed by ShouldCancelTimerForSeriesTimer timer.IsManual = existingTimer.IsManual; if (ShouldCancelTimerForSeriesTimer(seriesTimer, timer)) { existingTimer.Status = RecordingStatus.Cancelled; } 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; _timerProvider.Update(existingTimer); } } } } SearchForDuplicateShowIds(enabledTimersForSeries); if (deleteInvalidTimers) { var allTimerIds = allTimers .Select(i => i.Id) .ToList(); var deleteStatuses = new List { RecordingStatus.New }; var deletes = _timerProvider.GetAll() .Where(i => string.Equals(i.SeriesTimerId, seriesTimer.Id, StringComparison.OrdinalIgnoreCase)) .Where(i => !allTimerIds.Contains(i.Id, StringComparer.OrdinalIgnoreCase) && i.StartDate > DateTime.UtcNow) .Where(i => deleteStatuses.Contains(i.Status)) .ToList(); foreach (var timer in deletes) { CancelTimerInternal(timer.Id, false); } } } private IEnumerable GetTimersForSeries(SeriesTimerInfo seriesTimer, IEnumerable allPrograms) { if (seriesTimer == null) { throw new ArgumentNullException("seriesTimer"); } if (allPrograms == null) { throw new ArgumentNullException("allPrograms"); } // Exclude programs that have already ended allPrograms = allPrograms.Where(i => i.EndDate > DateTime.UtcNow); allPrograms = GetProgramsForSeries(seriesTimer, allPrograms); return allPrograms.Select(i => RecordingHelper.CreateTimer(i, seriesTimer)); } private bool IsProgramAlreadyInLibrary(TimerInfo program) { if ((program.EpisodeNumber.HasValue && program.SeasonNumber.HasValue) || !string.IsNullOrWhiteSpace(program.EpisodeTitle)) { var seriesIds = _libraryManager.GetItemIds(new InternalItemsQuery { IncludeItemTypes = new[] { typeof(Series).Name }, Name = program.Name }).Select(i => i.ToString("N")).ToArray(); if (seriesIds.Length == 0) { return false; } if (program.EpisodeNumber.HasValue && program.SeasonNumber.HasValue) { var result = _libraryManager.GetItemIds(new InternalItemsQuery { IncludeItemTypes = new[] { typeof(Episode).Name }, ParentIndexNumber = program.SeasonNumber.Value, IndexNumber = program.EpisodeNumber.Value, AncestorIds = seriesIds, IsVirtualItem = false, Limit = 1 }); if (result.Count > 0) { return true; } } } return false; } private IEnumerable GetProgramsForSeries(SeriesTimerInfo seriesTimer, IEnumerable allPrograms) { if (string.IsNullOrWhiteSpace(seriesTimer.SeriesId)) { _logger.Error("seriesTimer.SeriesId is null. Cannot find programs for series"); return new List(); } return allPrograms.Where(i => string.Equals(i.SeriesId, seriesTimer.SeriesId, StringComparison.OrdinalIgnoreCase)); } private string GetChannelEpgCachePath(string channelId) { return Path.Combine(_config.CommonApplicationPaths.CachePath, "embytvepg", channelId + ".json"); } private readonly object _epgLock = new object(); private void SaveEpgDataForChannel(string channelId, List epgData) { var path = GetChannelEpgCachePath(channelId); _fileSystem.CreateDirectory(_fileSystem.GetDirectoryName(path)); lock (_epgLock) { _jsonSerializer.SerializeToFile(epgData, path); } } private List GetEpgDataForChannel(string channelId) { try { lock (_epgLock) { return _jsonSerializer.DeserializeFromFile>(GetChannelEpgCachePath(channelId)); } } catch { return new List(); } } private List GetEpgDataForChannels(List channelIds) { return channelIds.SelectMany(GetEpgDataForChannel).ToList(); } private bool _disposed; public void Dispose() { _disposed = true; foreach (var pair in _activeRecordings.ToList()) { pair.Value.CancellationTokenSource.Cancel(); } } public List GetRecordingFolders() { var list = new List(); var defaultFolder = RecordingPath; var defaultName = "Recordings"; if (_fileSystem.DirectoryExists(defaultFolder)) { list.Add(new VirtualFolderInfo { Locations = new string[] { defaultFolder }, Name = defaultName }); } var customPath = GetConfiguration().MovieRecordingPath; if ((!string.IsNullOrWhiteSpace(customPath) && !string.Equals(customPath, defaultFolder, StringComparison.OrdinalIgnoreCase)) && _fileSystem.DirectoryExists(customPath)) { list.Add(new VirtualFolderInfo { Locations = new string[] { customPath }, Name = "Recorded Movies", CollectionType = CollectionType.Movies }); } customPath = GetConfiguration().SeriesRecordingPath; if ((!string.IsNullOrWhiteSpace(customPath) && !string.Equals(customPath, defaultFolder, StringComparison.OrdinalIgnoreCase)) && _fileSystem.DirectoryExists(customPath)) { list.Add(new VirtualFolderInfo { Locations = new string[] { customPath }, Name = "Recorded Shows", CollectionType = CollectionType.TvShows }); } return list; } private const int TunerDiscoveryDurationMs = 3000; 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, StringComparer.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 != null) { if (!string.Equals(device.Url, configuredDevice.Url, StringComparison.OrdinalIgnoreCase)) { _logger.Info("Tuner url has changed from {0} to {1}", configuredDevice.Url, device.Url); configuredDevice.Url = device.Url; await _liveTvManager.SaveTunerHost(configuredDevice).ConfigureAwait(false); } } } } private async Task> DiscoverDevices(ITunerHost host, int discoveryDuationMs, CancellationToken cancellationToken) { try { var discoveredDevices = await host.DiscoverDevices(discoveryDuationMs, cancellationToken).ConfigureAwait(false); foreach (var device in discoveredDevices) { _logger.Info("Discovered tuner device {0} at {1}", host.Name, device.Url); } return discoveredDevices; } catch (Exception ex) { _logger.ErrorException("Error discovering tuner devices", ex); return new List(); } } } public static class ConfigurationExtension { public static XbmcMetadataOptions GetNfoConfiguration(this IConfigurationManager manager) { return manager.GetConfiguration("xbmcmetadata"); } } }