diff options
| author | Luke <luke.pulverenti@gmail.com> | 2015-09-02 11:50:00 -0400 |
|---|---|---|
| committer | Luke <luke.pulverenti@gmail.com> | 2015-09-02 11:50:00 -0400 |
| commit | f868dd81e856488280978006cbb67afc2677049d (patch) | |
| tree | 616ba8ae846efe9ec889abeb12f6b2702c6b8592 /MediaBrowser.Server.Implementations/LiveTv | |
| parent | af89446c20fb302087b82c18c28da92076dbc5ac (diff) | |
| parent | e6d5901408ba7d8e344a27ea1f3b0046c40e56c1 (diff) | |
Merge pull request #1164 from MediaBrowser/dev
3.0.5724.1
Diffstat (limited to 'MediaBrowser.Server.Implementations/LiveTv')
20 files changed, 3942 insertions, 105 deletions
diff --git a/MediaBrowser.Server.Implementations/LiveTv/ChannelImageProvider.cs b/MediaBrowser.Server.Implementations/LiveTv/ChannelImageProvider.cs index 7c3af0a54..f205da70d 100644 --- a/MediaBrowser.Server.Implementations/LiveTv/ChannelImageProvider.cs +++ b/MediaBrowser.Server.Implementations/LiveTv/ChannelImageProvider.cs @@ -1,4 +1,5 @@ -using MediaBrowser.Common.Net; +using MediaBrowser.Common; +using MediaBrowser.Common.Net; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.LiveTv; using MediaBrowser.Controller.Providers; @@ -17,12 +18,14 @@ namespace MediaBrowser.Server.Implementations.LiveTv private readonly ILiveTvManager _liveTvManager; private readonly IHttpClient _httpClient; private readonly ILogger _logger; + private readonly IApplicationHost _appHost; - public ChannelImageProvider(ILiveTvManager liveTvManager, IHttpClient httpClient, ILogger logger) + public ChannelImageProvider(ILiveTvManager liveTvManager, IHttpClient httpClient, ILogger logger, IApplicationHost appHost) { _liveTvManager = liveTvManager; _httpClient = httpClient; _logger = logger; + _appHost = appHost; } public IEnumerable<ImageType> GetSupportedImages(IHasImages item) @@ -46,7 +49,10 @@ namespace MediaBrowser.Server.Implementations.LiveTv var options = new HttpRequestOptions { CancellationToken = cancellationToken, - Url = liveTvItem.ProviderImageUrl + Url = liveTvItem.ProviderImageUrl, + + // Some image hosts require a user agent to be specified. + UserAgent = "Emby Server/" + _appHost.ApplicationVersion }; var response = await _httpClient.GetResponse(options).ConfigureAwait(false); diff --git a/MediaBrowser.Server.Implementations/LiveTv/EmbyTV/EmbyTV.cs b/MediaBrowser.Server.Implementations/LiveTv/EmbyTV/EmbyTV.cs new file mode 100644 index 000000000..4e0d6e8d4 --- /dev/null +++ b/MediaBrowser.Server.Implementations/LiveTv/EmbyTV/EmbyTV.cs @@ -0,0 +1,802 @@ +using MediaBrowser.Common; +using MediaBrowser.Common.Configuration; +using MediaBrowser.Common.IO; +using MediaBrowser.Common.Net; +using MediaBrowser.Common.Security; +using MediaBrowser.Controller.Configuration; +using MediaBrowser.Controller.Drawing; +using MediaBrowser.Controller.FileOrganization; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.LiveTv; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.Dto; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.Events; +using MediaBrowser.Model.FileOrganization; +using MediaBrowser.Model.LiveTv; +using MediaBrowser.Model.Logging; +using MediaBrowser.Model.Serialization; +using MediaBrowser.Server.Implementations.FileOrganization; +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +namespace MediaBrowser.Server.Implementations.LiveTv.EmbyTV +{ + public class EmbyTV : ILiveTvService, IHasRegistrationInfo, IDisposable + { + private readonly IApplicationHost _appHpst; + private readonly ILogger _logger; + private readonly IHttpClient _httpClient; + private readonly IServerConfigurationManager _config; + private readonly IJsonSerializer _jsonSerializer; + + private readonly ItemDataProvider<RecordingInfo> _recordingProvider; + private readonly ItemDataProvider<SeriesTimerInfo> _seriesTimerProvider; + private readonly TimerManager _timerProvider; + + private readonly LiveTvManager _liveTvManager; + private readonly IFileSystem _fileSystem; + private readonly ISecurityManager _security; + + private readonly ILibraryMonitor _libraryMonitor; + private readonly ILibraryManager _libraryManager; + private readonly IProviderManager _providerManager; + private readonly IFileOrganizationService _organizationService; + + public static EmbyTV Current; + + public EmbyTV(IApplicationHost appHost, ILogger logger, IJsonSerializer jsonSerializer, IHttpClient httpClient, IServerConfigurationManager config, ILiveTvManager liveTvManager, IFileSystem fileSystem, ISecurityManager security, ILibraryManager libraryManager, ILibraryMonitor libraryMonitor, IProviderManager providerManager, IFileOrganizationService organizationService) + { + Current = this; + + _appHpst = appHost; + _logger = logger; + _httpClient = httpClient; + _config = config; + _fileSystem = fileSystem; + _security = security; + _libraryManager = libraryManager; + _libraryMonitor = libraryMonitor; + _providerManager = providerManager; + _organizationService = organizationService; + _liveTvManager = (LiveTvManager)liveTvManager; + _jsonSerializer = jsonSerializer; + + _recordingProvider = new ItemDataProvider<RecordingInfo>(jsonSerializer, _logger, Path.Combine(DataPath, "recordings"), (r1, r2) => string.Equals(r1.Id, r2.Id, StringComparison.OrdinalIgnoreCase)); + _seriesTimerProvider = new SeriesTimerManager(jsonSerializer, _logger, Path.Combine(DataPath, "seriestimers")); + _timerProvider = new TimerManager(jsonSerializer, _logger, Path.Combine(DataPath, "timers")); + _timerProvider.TimerFired += _timerProvider_TimerFired; + } + + public void Start() + { + _timerProvider.RestartTimers(); + } + + public event EventHandler DataSourceChanged; + + public event EventHandler<RecordingStatusChangedEventArgs> RecordingStatusChanged; + + private readonly ConcurrentDictionary<string, CancellationTokenSource> _activeRecordings = + new ConcurrentDictionary<string, CancellationTokenSource>(StringComparer.OrdinalIgnoreCase); + + public string Name + { + get { return "Emby"; } + } + + public string DataPath + { + get { return Path.Combine(_config.CommonApplicationPaths.DataPath, "livetv"); } + } + + public string HomePageUrl + { + get { return "http://emby.media"; } + } + + public async Task<LiveTvServiceStatusInfo> GetStatusInfoAsync(CancellationToken cancellationToken) + { + var status = new LiveTvServiceStatusInfo(); + var list = new List<LiveTvTunerInfo>(); + + 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 = _appHpst.ApplicationVersion.ToString(); + status.IsVisible = false; + return status; + } + + private List<ChannelInfo> _channelCache = null; + private async Task<IEnumerable<ChannelInfo>> GetChannelsAsync(bool enableCache, CancellationToken cancellationToken) + { + if (enableCache && _channelCache != null) + { + + return _channelCache.ToList(); + } + + var list = new List<ChannelInfo>(); + + foreach (var hostInstance in _liveTvManager.TunerHosts) + { + try + { + var channels = await hostInstance.GetChannels(cancellationToken).ConfigureAwait(false); + + list.AddRange(channels); + } + catch (Exception ex) + { + _logger.ErrorException("Error getting channels", ex); + } + } + + if (list.Count > 0) + { + foreach (var provider in GetListingProviders()) + { + try + { + await provider.Item1.AddMetadata(provider.Item2, list, cancellationToken).ConfigureAwait(false); + } + catch (NotSupportedException) + { + + } + catch (Exception ex) + { + _logger.ErrorException("Error adding metadata", ex); + } + } + } + _channelCache = list; + return list; + } + + public Task<IEnumerable<ChannelInfo>> GetChannelsAsync(CancellationToken cancellationToken) + { + return GetChannelsAsync(false, cancellationToken); + } + + public Task CancelSeriesTimerAsync(string timerId, CancellationToken cancellationToken) + { + var timers = _timerProvider.GetAll().Where(i => string.Equals(i.SeriesTimerId, timerId, StringComparison.OrdinalIgnoreCase)); + foreach (var timer in timers) + { + CancelTimerInternal(timer.Id); + } + + 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) + { + var remove = _timerProvider.GetAll().FirstOrDefault(r => string.Equals(r.Id, timerId, StringComparison.OrdinalIgnoreCase)); + if (remove != null) + { + _timerProvider.Delete(remove); + } + CancellationTokenSource cancellationTokenSource; + + if (_activeRecordings.TryGetValue(timerId, out cancellationTokenSource)) + { + cancellationTokenSource.Cancel(); + } + } + + public Task CancelTimerAsync(string timerId, CancellationToken cancellationToken) + { + CancelTimerInternal(timerId); + return Task.FromResult(true); + } + + public async Task DeleteRecordingAsync(string recordingId, CancellationToken cancellationToken) + { + var remove = _recordingProvider.GetAll().FirstOrDefault(i => string.Equals(i.Id, recordingId, StringComparison.OrdinalIgnoreCase)); + if (remove != null) + { + if (!string.IsNullOrWhiteSpace(remove.TimerId)) + { + var enableDelay = _activeRecordings.ContainsKey(remove.TimerId); + + CancelTimerInternal(remove.TimerId); + + if (enableDelay) + { + // A hack yes, but need to make sure the file is closed before attempting to delete it + await Task.Delay(3000, cancellationToken).ConfigureAwait(false); + } + } + + try + { + File.Delete(remove.Path); + } + catch (DirectoryNotFoundException) + { + + } + catch (FileNotFoundException) + { + + } + _recordingProvider.Delete(remove); + } + } + + public Task CreateTimerAsync(TimerInfo info, CancellationToken cancellationToken) + { + info.Id = Guid.NewGuid().ToString("N"); + _timerProvider.Add(info); + return Task.FromResult(0); + } + + public async Task CreateSeriesTimerAsync(SeriesTimerInfo info, CancellationToken cancellationToken) + { + info.Id = Guid.NewGuid().ToString("N"); + + List<ProgramInfo> 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"); + } + + _seriesTimerProvider.Add(info); + await UpdateTimersForSeriesTimer(epgData, info).ConfigureAwait(false); + } + + public async Task UpdateSeriesTimerAsync(SeriesTimerInfo info, CancellationToken cancellationToken) + { + _seriesTimerProvider.Update(info); + List<ProgramInfo> 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); + } + + await UpdateTimersForSeriesTimer(epgData, info).ConfigureAwait(false); + } + + public Task UpdateTimerAsync(TimerInfo info, CancellationToken cancellationToken) + { + _timerProvider.Update(info); + return Task.FromResult(true); + } + + public Task<ImageStream> GetChannelImageAsync(string channelId, CancellationToken cancellationToken) + { + throw new NotImplementedException(); + } + + public Task<ImageStream> GetRecordingImageAsync(string recordingId, CancellationToken cancellationToken) + { + throw new NotImplementedException(); + } + + public Task<ImageStream> GetProgramImageAsync(string programId, string channelId, CancellationToken cancellationToken) + { + throw new NotImplementedException(); + } + + public Task<IEnumerable<RecordingInfo>> GetRecordingsAsync(CancellationToken cancellationToken) + { + return Task.FromResult((IEnumerable<RecordingInfo>)_recordingProvider.GetAll()); + } + + public Task<IEnumerable<TimerInfo>> GetTimersAsync(CancellationToken cancellationToken) + { + return Task.FromResult((IEnumerable<TimerInfo>)_timerProvider.GetAll()); + } + + public Task<SeriesTimerInfo> GetNewTimerDefaultsAsync(CancellationToken cancellationToken, ProgramInfo program = null) + { + var config = GetConfiguration(); + + var defaults = new SeriesTimerInfo() + { + PostPaddingSeconds = Math.Max(config.PostPaddingSeconds, 0), + PrePaddingSeconds = Math.Max(config.PrePaddingSeconds, 0), + RecordAnyChannel = false, + RecordAnyTime = false, + RecordNewOnly = false + }; + + if (program != null) + { + defaults.SeriesId = program.SeriesId; + defaults.ProgramId = program.Id; + } + + return Task.FromResult(defaults); + } + + public Task<IEnumerable<SeriesTimerInfo>> GetSeriesTimersAsync(CancellationToken cancellationToken) + { + return Task.FromResult((IEnumerable<SeriesTimerInfo>)_seriesTimerProvider.GetAll()); + } + + public async Task<IEnumerable<ProgramInfo>> GetProgramsAsync(string channelId, DateTime startDateUtc, DateTime endDateUtc, CancellationToken cancellationToken) + { + var channels = await GetChannelsAsync(true, cancellationToken).ConfigureAwait(false); + var channel = channels.First(i => string.Equals(i.Id, channelId, StringComparison.OrdinalIgnoreCase)); + + foreach (var provider in GetListingProviders()) + { + var programs = await provider.Item1.GetProgramsAsync(provider.Item2, channel.Number, startDateUtc, endDateUtc, cancellationToken) + .ConfigureAwait(false); + var list = programs.ToList(); + + // Replace the value that came from the provider with a normalized value + foreach (var program in list) + { + program.ChannelId = channelId; + } + + if (list.Count > 0) + { + SaveEpgDataForChannel(channelId, list); + + return list; + } + } + + return new List<ProgramInfo>(); + } + + private List<Tuple<IListingsProvider, ListingsProviderInfo>> GetListingProviders() + { + return GetConfiguration().ListingProviders + .Select(i => + { + var provider = _liveTvManager.ListingProviders.FirstOrDefault(l => string.Equals(l.Type, i.Type, StringComparison.OrdinalIgnoreCase)); + + return provider == null ? null : new Tuple<IListingsProvider, ListingsProviderInfo>(provider, i); + }) + .Where(i => i != null) + .ToList(); + } + + public Task<MediaSourceInfo> GetRecordingStream(string recordingId, string streamId, CancellationToken cancellationToken) + { + throw new NotImplementedException(); + } + + public async Task<MediaSourceInfo> GetChannelStream(string channelId, string streamId, CancellationToken cancellationToken) + { + _logger.Info("Streaming Channel " + channelId); + + foreach (var hostInstance in _liveTvManager.TunerHosts) + { + MediaSourceInfo mediaSourceInfo = null; + try + { + mediaSourceInfo = await hostInstance.GetChannelStream(channelId, streamId, cancellationToken).ConfigureAwait(false); + } + catch (Exception e) + { + _logger.ErrorException("Error getting channel stream", e); + } + + if (mediaSourceInfo != null) + { + mediaSourceInfo.Id = Guid.NewGuid().ToString("N"); + return mediaSourceInfo; + } + } + + throw new ApplicationException("Tuner not found."); + } + + public async Task<List<MediaSourceInfo>> GetChannelStreamMediaSources(string channelId, CancellationToken cancellationToken) + { + foreach (var hostInstance in _liveTvManager.TunerHosts) + { + try + { + var sources = await hostInstance.GetChannelStreamMediaSources(channelId, cancellationToken).ConfigureAwait(false); + + if (sources.Count > 0) + { + return sources; + } + } + catch (NotImplementedException) + { + + } + } + + throw new NotImplementedException(); + } + + public Task<List<MediaSourceInfo>> GetRecordingStreamMediaSources(string recordingId, CancellationToken cancellationToken) + { + throw new NotImplementedException(); + } + + public Task CloseLiveStream(string id, CancellationToken cancellationToken) + { + return Task.FromResult(0); + } + + 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<TimerInfo> e) + { + var timer = e.Argument; + + _logger.Info("Recording timer fired."); + + try + { + var cancellationTokenSource = new CancellationTokenSource(); + + if (_activeRecordings.TryAdd(timer.Id, cancellationTokenSource)) + { + await RecordStream(timer, cancellationTokenSource.Token).ConfigureAwait(false); + } + } + catch (OperationCanceledException) + { + + } + catch (Exception ex) + { + _logger.ErrorException("Error recording stream", ex); + + if (DateTime.UtcNow < timer.EndDate) + { + const int retryIntervalSeconds = 60; + _logger.Info("Retrying recording in {0} seconds.", retryIntervalSeconds); + + _timerProvider.StartTimer(timer, TimeSpan.FromSeconds(retryIntervalSeconds)); + } + } + } + + private async Task RecordStream(TimerInfo timer, CancellationToken cancellationToken) + { + if (timer == null) + { + throw new ArgumentNullException("timer"); + } + + var mediaStreamInfo = await GetChannelStream(timer.ChannelId, null, CancellationToken.None); + var duration = (timer.EndDate - DateTime.UtcNow).Add(TimeSpan.FromSeconds(timer.PostPaddingSeconds)); + + HttpRequestOptions httpRequestOptions = new HttpRequestOptions() + { + Url = mediaStreamInfo.Path + }; + + var info = GetProgramInfoFromCache(timer.ChannelId, timer.ProgramId); + var recordPath = RecordingPath; + + if (info.IsMovie) + { + recordPath = Path.Combine(recordPath, "Movies", _fileSystem.GetValidFilename(info.Name)); + } + else if (info.IsSeries) + { + recordPath = Path.Combine(recordPath, "Series", _fileSystem.GetValidFilename(info.Name)); + } + else if (info.IsKids) + { + recordPath = Path.Combine(recordPath, "Kids", _fileSystem.GetValidFilename(info.Name)); + } + else if (info.IsSports) + { + recordPath = Path.Combine(recordPath, "Sports", _fileSystem.GetValidFilename(info.Name)); + } + else + { + recordPath = Path.Combine(recordPath, "Other", _fileSystem.GetValidFilename(info.Name)); + } + + var recordingFileName = _fileSystem.GetValidFilename(RecordingHelper.GetRecordingName(timer, info)) + ".ts"; + + recordPath = Path.Combine(recordPath, recordingFileName); + Directory.CreateDirectory(Path.GetDirectoryName(recordPath)); + + var recording = _recordingProvider.GetAll().FirstOrDefault(x => string.Equals(x.ProgramId, info.Id, StringComparison.OrdinalIgnoreCase)); + + if (recording == null) + { + recording = new RecordingInfo + { + ChannelId = info.ChannelId, + Id = Guid.NewGuid().ToString("N"), + StartDate = info.StartDate, + EndDate = info.EndDate, + Genres = info.Genres, + IsKids = info.IsKids, + IsLive = info.IsLive, + IsMovie = info.IsMovie, + IsHD = info.IsHD, + IsNews = info.IsNews, + IsPremiere = info.IsPremiere, + IsSeries = info.IsSeries, + IsSports = info.IsSports, + IsRepeat = !info.IsPremiere, + Name = info.Name, + EpisodeTitle = info.EpisodeTitle, + ProgramId = info.Id, + HasImage = info.HasImage, + ImagePath = info.ImagePath, + ImageUrl = info.ImageUrl, + OriginalAirDate = info.OriginalAirDate, + Status = RecordingStatus.Scheduled, + Overview = info.Overview, + SeriesTimerId = timer.SeriesTimerId, + TimerId = timer.Id, + ShowId = info.ShowId + }; + _recordingProvider.Add(recording); + } + + recording.Path = recordPath; + recording.Status = RecordingStatus.InProgress; + recording.DateLastUpdated = DateTime.UtcNow; + _recordingProvider.Update(recording); + + _logger.Info("Beginning recording."); + + try + { + httpRequestOptions.BufferContent = false; + var durationToken = new CancellationTokenSource(duration); + var linkedToken = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, durationToken.Token).Token; + httpRequestOptions.CancellationToken = linkedToken; + _logger.Info("Writing file to path: " + recordPath); + using (var response = await _httpClient.SendAsync(httpRequestOptions, "GET")) + { + using (var output = File.Open(recordPath, FileMode.Create, FileAccess.Write, FileShare.Read)) + { + await response.Content.CopyToAsync(output, StreamDefaults.DefaultCopyToBufferSize, linkedToken); + } + } + + recording.Status = RecordingStatus.Completed; + _logger.Info("Recording completed"); + } + catch (OperationCanceledException) + { + _logger.Info("Recording stopped"); + recording.Status = RecordingStatus.Completed; + } + catch (Exception ex) + { + _logger.ErrorException("Error recording", ex); + recording.Status = RecordingStatus.Error; + } + + recording.DateLastUpdated = DateTime.UtcNow; + _recordingProvider.Update(recording); + _timerProvider.Delete(timer); + _logger.Info("Recording was a success"); + + if (recording.Status == RecordingStatus.Completed) + { + OnSuccessfulRecording(recording); + } + } + + private async void OnSuccessfulRecording(RecordingInfo recording) + { + if (GetConfiguration().EnableAutoOrganize) + { + if (recording.IsSeries) + { + try + { + var organize = new EpisodeFileOrganizer(_organizationService, _config, _fileSystem, _logger, _libraryManager, _libraryMonitor, _providerManager); + + var result = await organize.OrganizeEpisodeFile(recording.Path, CancellationToken.None).ConfigureAwait(false); + + if (result.Status == FileSortingStatus.Success) + { + _recordingProvider.Delete(recording); + } + } + catch (Exception ex) + { + _logger.ErrorException("Error processing new recording", ex); + } + } + } + } + + private ProgramInfo GetProgramInfoFromCache(string channelId, string programId) + { + var epgData = GetEpgDataForChannel(channelId); + return epgData.FirstOrDefault(p => string.Equals(p.Id, programId, StringComparison.OrdinalIgnoreCase)); + } + + private string RecordingPath + { + get + { + var path = GetConfiguration().RecordingPath; + + return string.IsNullOrWhiteSpace(path) + ? Path.Combine(DataPath, "recordings") + : path; + } + } + + private LiveTvOptions GetConfiguration() + { + return _config.GetConfiguration<LiveTvOptions>("livetv"); + } + + private async Task UpdateTimersForSeriesTimer(List<ProgramInfo> epgData, SeriesTimerInfo seriesTimer) + { + var registration = await GetRegistrationInfo("seriesrecordings").ConfigureAwait(false); + + if (registration.IsValid) + { + var newTimers = GetTimersForSeries(seriesTimer, epgData, _recordingProvider.GetAll()).ToList(); + + foreach (var timer in newTimers) + { + _timerProvider.AddOrUpdate(timer); + } + } + } + + private IEnumerable<TimerInfo> GetTimersForSeries(SeriesTimerInfo seriesTimer, IEnumerable<ProgramInfo> allPrograms, IReadOnlyList<RecordingInfo> currentRecordings) + { + // Exclude programs that have already ended + allPrograms = allPrograms.Where(i => i.EndDate > DateTime.UtcNow); + + allPrograms = GetProgramsForSeries(seriesTimer, allPrograms); + + var recordingShowIds = currentRecordings.Select(i => i.ProgramId).Where(i => !string.IsNullOrWhiteSpace(i)).ToList(); + + allPrograms = allPrograms.Where(i => !recordingShowIds.Contains(i.Id, StringComparer.OrdinalIgnoreCase)); + + return allPrograms.Select(i => RecordingHelper.CreateTimer(i, seriesTimer)); + } + + private IEnumerable<ProgramInfo> GetProgramsForSeries(SeriesTimerInfo seriesTimer, IEnumerable<ProgramInfo> allPrograms) + { + if (!seriesTimer.RecordAnyTime) + { + allPrograms = allPrograms.Where(epg => (seriesTimer.StartDate.TimeOfDay == epg.StartDate.TimeOfDay)); + } + + if (seriesTimer.RecordNewOnly) + { + allPrograms = allPrograms.Where(epg => !epg.IsRepeat); + } + + if (!seriesTimer.RecordAnyChannel) + { + allPrograms = allPrograms.Where(epg => string.Equals(epg.ChannelId, seriesTimer.ChannelId, StringComparison.OrdinalIgnoreCase)); + } + + allPrograms = allPrograms.Where(i => seriesTimer.Days.Contains(i.StartDate.ToLocalTime().DayOfWeek)); + + if (string.IsNullOrWhiteSpace(seriesTimer.SeriesId)) + { + _logger.Error("seriesTimer.SeriesId is null. Cannot find programs for series"); + return new List<ProgramInfo>(); + } + + return allPrograms.Where(i => string.Equals(i.SeriesId, seriesTimer.SeriesId, StringComparison.OrdinalIgnoreCase)); + } + + private string GetChannelEpgCachePath(string channelId) + { + return Path.Combine(DataPath, "epg", channelId + ".json"); + } + + private readonly object _epgLock = new object(); + private void SaveEpgDataForChannel(string channelId, List<ProgramInfo> epgData) + { + var path = GetChannelEpgCachePath(channelId); + Directory.CreateDirectory(Path.GetDirectoryName(path)); + lock (_epgLock) + { + _jsonSerializer.SerializeToFile(epgData, path); + } + } + private List<ProgramInfo> GetEpgDataForChannel(string channelId) + { + try + { + lock (_epgLock) + { + return _jsonSerializer.DeserializeFromFile<List<ProgramInfo>>(GetChannelEpgCachePath(channelId)); + } + } + catch + { + return new List<ProgramInfo>(); + } + } + private List<ProgramInfo> GetEpgDataForChannels(List<string> channelIds) + { + return channelIds.SelectMany(GetEpgDataForChannel).ToList(); + } + + public void Dispose() + { + foreach (var pair in _activeRecordings.ToList()) + { + pair.Value.Cancel(); + } + } + + public Task<MBRegistrationRecord> GetRegistrationInfo(string feature) + { + if (string.Equals(feature, "seriesrecordings", StringComparison.OrdinalIgnoreCase)) + { + return _security.GetRegistrationStatus("embytvseriesrecordings"); + } + + return Task.FromResult(new MBRegistrationRecord + { + IsValid = true, + IsRegistered = true + }); + } + } +} diff --git a/MediaBrowser.Server.Implementations/LiveTv/EmbyTV/EntryPoint.cs b/MediaBrowser.Server.Implementations/LiveTv/EmbyTV/EntryPoint.cs new file mode 100644 index 000000000..713cb9cd3 --- /dev/null +++ b/MediaBrowser.Server.Implementations/LiveTv/EmbyTV/EntryPoint.cs @@ -0,0 +1,16 @@ +using MediaBrowser.Controller.Plugins; + +namespace MediaBrowser.Server.Implementations.LiveTv.EmbyTV +{ + public class EntryPoint : IServerEntryPoint + { + public void Run() + { + EmbyTV.Current.Start(); + } + + public void Dispose() + { + } + } +} diff --git a/MediaBrowser.Server.Implementations/LiveTv/EmbyTV/ItemDataProvider.cs b/MediaBrowser.Server.Implementations/LiveTv/EmbyTV/ItemDataProvider.cs new file mode 100644 index 000000000..75dec5f97 --- /dev/null +++ b/MediaBrowser.Server.Implementations/LiveTv/EmbyTV/ItemDataProvider.cs @@ -0,0 +1,118 @@ +using MediaBrowser.Model.Logging; +using MediaBrowser.Model.Serialization; +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; + +namespace MediaBrowser.Server.Implementations.LiveTv.EmbyTV +{ + public class ItemDataProvider<T> + where T : class + { + private readonly object _fileDataLock = new object(); + private List<T> _items; + private readonly IJsonSerializer _jsonSerializer; + protected readonly ILogger Logger; + private readonly string _dataPath; + protected readonly Func<T, T, bool> EqualityComparer; + + public ItemDataProvider(IJsonSerializer jsonSerializer, ILogger logger, string dataPath, Func<T, T, bool> equalityComparer) + { + Logger = logger; + _dataPath = dataPath; + EqualityComparer = equalityComparer; + _jsonSerializer = jsonSerializer; + } + + public IReadOnlyList<T> GetAll() + { + if (_items == null) + { + lock (_fileDataLock) + { + if (_items == null) + { + _items = GetItemsFromFile(_dataPath); + } + } + } + return _items; + } + + private List<T> GetItemsFromFile(string path) + { + var jsonFile = path + ".json"; + + try + { + return _jsonSerializer.DeserializeFromFile<List<T>>(jsonFile); + } + catch (FileNotFoundException) + { + } + catch (DirectoryNotFoundException ex) + { + } + catch (IOException ex) + { + Logger.ErrorException("Error deserializing {0}", ex, jsonFile); + throw; + } + catch (Exception ex) + { + Logger.ErrorException("Error deserializing {0}", ex, jsonFile); + } + return new List<T>(); + } + + private void UpdateList(List<T> newList) + { + var file = _dataPath + ".json"; + Directory.CreateDirectory(Path.GetDirectoryName(file)); + + lock (_fileDataLock) + { + _jsonSerializer.SerializeToFile(newList, file); + _items = newList; + } + } + + public virtual void Update(T item) + { + var list = GetAll().ToList(); + + var index = list.FindIndex(i => EqualityComparer(i, item)); + + if (index == -1) + { + throw new ArgumentException("item not found"); + } + + list[index] = item; + + UpdateList(list); + } + + public virtual void Add(T item) + { + var list = GetAll().ToList(); + + if (list.Any(i => EqualityComparer(i, item))) + { + throw new ArgumentException("item already exists"); + } + + list.Add(item); + + UpdateList(list); + } + + public virtual void Delete(T item) + { + var list = GetAll().Where(i => !EqualityComparer(i, item)).ToList(); + + UpdateList(list); + } + } +} diff --git a/MediaBrowser.Server.Implementations/LiveTv/EmbyTV/RecordingHelper.cs b/MediaBrowser.Server.Implementations/LiveTv/EmbyTV/RecordingHelper.cs new file mode 100644 index 000000000..5b83d63b1 --- /dev/null +++ b/MediaBrowser.Server.Implementations/LiveTv/EmbyTV/RecordingHelper.cs @@ -0,0 +1,78 @@ +using MediaBrowser.Common.Extensions; +using MediaBrowser.Controller.LiveTv; +using System; +using System.Globalization; + +namespace MediaBrowser.Server.Implementations.LiveTv.EmbyTV +{ + internal class RecordingHelper + { + public static DateTime GetStartTime(TimerInfo timer) + { + return timer.StartDate.AddSeconds(-timer.PrePaddingSeconds); + } + + public static TimerInfo CreateTimer(ProgramInfo parent, SeriesTimerInfo series) + { + var timer = new TimerInfo(); + + timer.ChannelId = parent.ChannelId; + timer.Id = (series.Id + parent.Id).GetMD5().ToString("N"); + timer.StartDate = parent.StartDate; + timer.EndDate = parent.EndDate; + timer.ProgramId = parent.Id; + timer.PrePaddingSeconds = series.PrePaddingSeconds; + timer.PostPaddingSeconds = series.PostPaddingSeconds; + timer.IsPostPaddingRequired = series.IsPostPaddingRequired; + timer.IsPrePaddingRequired = series.IsPrePaddingRequired; + timer.Priority = series.Priority; + timer.Name = parent.Name; + timer.Overview = parent.Overview; + timer.SeriesTimerId = series.Id; + + return timer; + } + + public static string GetRecordingName(TimerInfo timer, ProgramInfo info) + { + if (info == null) + { + return timer.ProgramId; + } + + var name = info.Name; + + if (info.IsSeries) + { + var addHyphen = true; + + if (info.SeasonNumber.HasValue && info.EpisodeNumber.HasValue) + { + name += string.Format(" S{0}E{1}", info.SeasonNumber.Value.ToString("00", CultureInfo.InvariantCulture), info.EpisodeNumber.Value.ToString("00", CultureInfo.InvariantCulture)); + addHyphen = false; + } + else if (info.OriginalAirDate.HasValue) + { + name += " " + info.OriginalAirDate.Value.ToString("yyyy-MM-dd"); + } + + if (addHyphen) + { + name += " -"; + } + + if (!string.IsNullOrWhiteSpace(info.EpisodeTitle)) + { + name += " " + info.EpisodeTitle; + } + } + + else if (info.ProductionYear != null) + { + name += " (" + info.ProductionYear + ")"; + } + + return name; + } + } +} diff --git a/MediaBrowser.Server.Implementations/LiveTv/EmbyTV/SeriesTimerManager.cs b/MediaBrowser.Server.Implementations/LiveTv/EmbyTV/SeriesTimerManager.cs new file mode 100644 index 000000000..eab278eb4 --- /dev/null +++ b/MediaBrowser.Server.Implementations/LiveTv/EmbyTV/SeriesTimerManager.cs @@ -0,0 +1,25 @@ +using MediaBrowser.Controller.LiveTv; +using MediaBrowser.Model.Logging; +using MediaBrowser.Model.Serialization; +using System; + +namespace MediaBrowser.Server.Implementations.LiveTv.EmbyTV +{ + public class SeriesTimerManager : ItemDataProvider<SeriesTimerInfo> + { + public SeriesTimerManager(IJsonSerializer jsonSerializer, ILogger logger, string dataPath) + : base(jsonSerializer, logger, dataPath, (r1, r2) => string.Equals(r1.Id, r2.Id, StringComparison.OrdinalIgnoreCase)) + { + } + + public override void Add(SeriesTimerInfo item) + { + if (string.IsNullOrWhiteSpace(item.Id)) + { + throw new ArgumentException("SeriesTimerInfo.Id cannot be null or empty."); + } + + base.Add(item); + } + } +} diff --git a/MediaBrowser.Server.Implementations/LiveTv/EmbyTV/TimerManager.cs b/MediaBrowser.Server.Implementations/LiveTv/EmbyTV/TimerManager.cs new file mode 100644 index 000000000..3ae38f382 --- /dev/null +++ b/MediaBrowser.Server.Implementations/LiveTv/EmbyTV/TimerManager.cs @@ -0,0 +1,138 @@ +using MediaBrowser.Common.Events; +using MediaBrowser.Controller.LiveTv; +using MediaBrowser.Model.Events; +using MediaBrowser.Model.Logging; +using MediaBrowser.Model.Serialization; +using System; +using System.Collections.Concurrent; +using System.Linq; +using System.Threading; + +namespace MediaBrowser.Server.Implementations.LiveTv.EmbyTV +{ + public class TimerManager : ItemDataProvider<TimerInfo> + { + private readonly ConcurrentDictionary<string, Timer> _timers = new ConcurrentDictionary<string, Timer>(StringComparer.OrdinalIgnoreCase); + + public event EventHandler<GenericEventArgs<TimerInfo>> TimerFired; + + public TimerManager(IJsonSerializer jsonSerializer, ILogger logger, string dataPath) + : base(jsonSerializer, logger, dataPath, (r1, r2) => string.Equals(r1.Id, r2.Id, StringComparison.OrdinalIgnoreCase)) + { + } + + public void RestartTimers() + { + StopTimers(); + + foreach (var item in GetAll().ToList()) + { + AddTimer(item); + } + } + + public void StopTimers() + { + foreach (var pair in _timers.ToList()) + { + pair.Value.Dispose(); + } + + _timers.Clear(); + } + + public override void Delete(TimerInfo item) + { + base.Delete(item); + StopTimer(item); + } + + public override void Update(TimerInfo item) + { + base.Update(item); + + Timer timer; + if (_timers.TryGetValue(item.Id, out timer)) + { + var timespan = RecordingHelper.GetStartTime(item) - DateTime.UtcNow; + timer.Change(timespan, TimeSpan.Zero); + } + else + { + AddTimer(item); + } + } + + public override void Add(TimerInfo item) + { + if (string.IsNullOrWhiteSpace(item.Id)) + { + throw new ArgumentException("TimerInfo.Id cannot be null or empty."); + } + + base.Add(item); + AddTimer(item); + } + + public void AddOrUpdate(TimerInfo item) + { + var list = GetAll().ToList(); + + if (!list.Any(i => EqualityComparer(i, item))) + { + Add(item); + } + else + { + Update(item); + } + } + + private void AddTimer(TimerInfo item) + { + var startDate = RecordingHelper.GetStartTime(item); + var now = DateTime.UtcNow; + + if (startDate < now) + { + EventHelper.FireEventIfNotNull(TimerFired, this, new GenericEventArgs<TimerInfo> { Argument = item }, Logger); + return; + } + + var timerLength = startDate - now; + StartTimer(item, timerLength); + } + + public void StartTimer(TimerInfo item, TimeSpan length) + { + StopTimer(item); + + var timer = new Timer(TimerCallback, item.Id, length, TimeSpan.Zero); + + if (!_timers.TryAdd(item.Id, timer)) + { + timer.Dispose(); + } + } + + private void StopTimer(TimerInfo item) + { + Timer timer; + if (_timers.TryRemove(item.Id, out timer)) + { + timer.Dispose(); + } + } + + private void TimerCallback(object state) + { + var timerId = (string)state; + + var timer = GetAll().FirstOrDefault(i => string.Equals(i.Id, timerId, StringComparison.OrdinalIgnoreCase)); + if (timer != null) + { + EventHelper.FireEventIfNotNull(TimerFired, this, new GenericEventArgs<TimerInfo> { Argument = timer }, Logger); + } + } + } +} diff --git a/MediaBrowser.Server.Implementations/LiveTv/Listings/Emby/EmbyListings.cs b/MediaBrowser.Server.Implementations/LiveTv/Listings/Emby/EmbyListings.cs new file mode 100644 index 000000000..e446ff469 --- /dev/null +++ b/MediaBrowser.Server.Implementations/LiveTv/Listings/Emby/EmbyListings.cs @@ -0,0 +1,59 @@ +using MediaBrowser.Common.Net; +using MediaBrowser.Controller.LiveTv; +using MediaBrowser.Model.Dto; +using MediaBrowser.Model.LiveTv; +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using MediaBrowser.Model.Serialization; + +namespace MediaBrowser.Server.Implementations.LiveTv.Listings.Emby +{ + public class EmbyGuide : IListingsProvider + { + private readonly IHttpClient _httpClient; + private readonly IJsonSerializer _jsonSerializer; + + public EmbyGuide(IHttpClient httpClient, IJsonSerializer jsonSerializer) + { + _httpClient = httpClient; + _jsonSerializer = jsonSerializer; + } + + public string Name + { + get { return "Emby Guide"; } + } + + public string Type + { + get { return "emby"; } + } + + public Task<IEnumerable<ProgramInfo>> GetProgramsAsync(ListingsProviderInfo info, string channelNumber, DateTime startDateUtc, DateTime endDateUtc, CancellationToken cancellationToken) + { + return GetListingsProvider(info.Country).GetProgramsAsync(info, channelNumber, startDateUtc, endDateUtc, cancellationToken); + } + + public Task AddMetadata(ListingsProviderInfo info, List<ChannelInfo> channels, CancellationToken cancellationToken) + { + return GetListingsProvider(info.Country).AddMetadata(info, channels, cancellationToken); + } + + public Task Validate(ListingsProviderInfo info, bool validateLogin, bool validateListings) + { + return GetListingsProvider(info.Country).Validate(info, validateLogin, validateListings); + } + + public Task<List<NameIdPair>> GetLineups(ListingsProviderInfo info, string country, string location) + { + return GetListingsProvider(country).GetLineups(country, location); + } + + private IEmbyListingProvider GetListingsProvider(string country) + { + return new EmbyListingsNorthAmerica(_httpClient, _jsonSerializer); + } + } +} diff --git a/MediaBrowser.Server.Implementations/LiveTv/Listings/Emby/EmbyListingsNorthAmerica.cs b/MediaBrowser.Server.Implementations/LiveTv/Listings/Emby/EmbyListingsNorthAmerica.cs new file mode 100644 index 000000000..2993740d6 --- /dev/null +++ b/MediaBrowser.Server.Implementations/LiveTv/Listings/Emby/EmbyListingsNorthAmerica.cs @@ -0,0 +1,366 @@ +using MediaBrowser.Common.Net; +using MediaBrowser.Controller.LiveTv; +using MediaBrowser.Model.Dto; +using MediaBrowser.Model.LiveTv; +using MediaBrowser.Model.Serialization; +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +namespace MediaBrowser.Server.Implementations.LiveTv.Listings.Emby +{ + public class EmbyListingsNorthAmerica : IEmbyListingProvider + { + private readonly IHttpClient _httpClient; + private readonly IJsonSerializer _jsonSerializer; + + public EmbyListingsNorthAmerica(IHttpClient httpClient, IJsonSerializer jsonSerializer) + { + _httpClient = httpClient; + _jsonSerializer = jsonSerializer; + } + + public async Task<IEnumerable<ProgramInfo>> GetProgramsAsync(ListingsProviderInfo info, string channelNumber, DateTime startDateUtc, DateTime endDateUtc, CancellationToken cancellationToken) + { + channelNumber = NormalizeNumber(channelNumber); + + var url = "https://data.emby.media/service/listings?id=" + info.ListingsId; + + // Normalize + startDateUtc = startDateUtc.Date; + endDateUtc = startDateUtc.AddDays(7); + + url += "&start=" + startDateUtc.ToString("s", CultureInfo.InvariantCulture) + "Z"; + url += "&end=" + endDateUtc.ToString("s", CultureInfo.InvariantCulture) + "Z"; + + var response = await GetResponse<ListingInfo[]>(url).ConfigureAwait(false); + + return response.Where(i => IncludeInResults(i, channelNumber)).Select(GetProgramInfo).OrderBy(i => i.StartDate); + } + + private ProgramInfo GetProgramInfo(ListingInfo info) + { + var showType = info.showType ?? string.Empty; + + var program = new ProgramInfo + { + Id = info.listingID.ToString(CultureInfo.InvariantCulture), + Name = GetStringValue(info.showName), + HomePageUrl = GetStringValue(info.webLink), + Overview = info.description, + IsHD = info.hd, + IsLive = info.live, + IsPremiere = info.seasonPremiere || info.seriesPremiere, + IsMovie = showType.IndexOf("Movie", StringComparison.OrdinalIgnoreCase) != -1, + IsKids = showType.IndexOf("Children", StringComparison.OrdinalIgnoreCase) != -1, + IsNews = showType.IndexOf("News", StringComparison.OrdinalIgnoreCase) != -1, + IsSports = showType.IndexOf("Sports", StringComparison.OrdinalIgnoreCase) != -1 + }; + + if (!string.IsNullOrWhiteSpace(info.listDateTime)) + { + program.StartDate = DateTime.ParseExact(info.listDateTime, "yyyy'-'MM'-'dd' 'HH':'mm':'ss", CultureInfo.InvariantCulture); + program.StartDate = DateTime.SpecifyKind(program.StartDate, DateTimeKind.Utc); + program.EndDate = program.StartDate.AddMinutes(info.duration); + } + + if (info.starRating > 0) + { + program.CommunityRating = info.starRating*2; + } + + if (!string.IsNullOrWhiteSpace(info.rating)) + { + // They don't have dashes so try to normalize + program.OfficialRating = info.rating.Replace("TV", "TV-").Replace("--", "-"); + + var invalid = new[] { "N/A", "Approved", "Not Rated" }; + if (invalid.Contains(program.OfficialRating, StringComparer.OrdinalIgnoreCase)) + { + program.OfficialRating = null; + } + } + + if (!string.IsNullOrWhiteSpace(info.year)) + { + program.ProductionYear = int.Parse(info.year, CultureInfo.InvariantCulture); + } + + if (info.showID > 0) + { + program.ShowId = info.showID.ToString(CultureInfo.InvariantCulture); + } + + if (info.seriesID > 0) + { + program.SeriesId = info.seriesID.ToString(CultureInfo.InvariantCulture); + program.IsSeries = true; + program.IsRepeat = info.repeat; + + program.EpisodeTitle = GetStringValue(info.episodeTitle); + + if (string.Equals(program.Name, program.EpisodeTitle, StringComparison.OrdinalIgnoreCase)) + { + program.EpisodeTitle = null; + } + } + + if (info.starRating > 0) + { + program.CommunityRating = info.starRating * 2; + } + + if (string.Equals(info.showName, "Movie", StringComparison.OrdinalIgnoreCase)) + { + // Sometimes the movie title will be in here + if (!string.IsNullOrWhiteSpace(info.episodeTitle)) + { + program.Name = info.episodeTitle; + } + } + + return program; + } + + private string GetStringValue(string s) + { + return string.IsNullOrWhiteSpace(s) ? null : s; + } + + private bool IncludeInResults(ListingInfo info, string itemNumber) + { + if (string.Equals(itemNumber, NormalizeNumber(info.number), StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + var channelNumber = info.channelNumber.ToString(CultureInfo.InvariantCulture); + if (info.subChannelNumber > 0) + { + channelNumber += "." + info.subChannelNumber.ToString(CultureInfo.InvariantCulture); + } + + return string.Equals(channelNumber, itemNumber, StringComparison.OrdinalIgnoreCase); + } + + public async Task AddMetadata(ListingsProviderInfo info, List<ChannelInfo> channels, CancellationToken cancellationToken) + { + var response = await GetResponse<LineupDetailResponse>("https://data.emby.media/service/lineups?id=" + info.ListingsId).ConfigureAwait(false); + + foreach (var channel in channels) + { + var station = response.stations.FirstOrDefault(i => + { + var itemNumber = NormalizeNumber(channel.Number); + + if (string.Equals(itemNumber, NormalizeNumber(i.number), StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + var channelNumber = i.channelNumber.ToString(CultureInfo.InvariantCulture); + if (i.subChannelNumber > 0) + { + channelNumber += "." + i.subChannelNumber.ToString(CultureInfo.InvariantCulture); + } + + return string.Equals(channelNumber, itemNumber, StringComparison.OrdinalIgnoreCase); + }); + + if (station != null) + { + //channel.Name = station.name; + + if (!string.IsNullOrWhiteSpace(station.logoFilename)) + { + channel.HasImage = true; + channel.ImageUrl = "http://cdn.tvpassport.com/image/station/100x100/" + station.logoFilename; + } + } + } + } + + private string NormalizeNumber(string number) + { + return number.Replace('-', '.'); + } + + public Task Validate(ListingsProviderInfo info, bool validateLogin, bool validateListings) + { + return Task.FromResult(true); + } + + public async Task<List<NameIdPair>> GetLineups(string country, string location) + { + // location = postal code + var response = await GetResponse<LineupInfo[]>("https://data.emby.media/service/lineups?postalCode=" + location).ConfigureAwait(false); + + return response.Select(i => new NameIdPair + { + Name = GetName(i), + Id = i.lineupID + + }).OrderBy(i => i.Name).ToList(); + } + + private string GetName(LineupInfo info) + { + var name = info.lineupName; + + if (string.Equals(info.lineupType, "cab", StringComparison.OrdinalIgnoreCase)) + { + name += " - Cable"; + } + else if (string.Equals(info.lineupType, "sat", StringComparison.OrdinalIgnoreCase)) + { + name += " - SAT"; + } + else if (string.Equals(info.lineupType, "ota", StringComparison.OrdinalIgnoreCase)) + { + name += " - OTA"; + } + + return name; + } + + private async Task<T> GetResponse<T>(string url, Func<string, string> filter = null) + where T : class + { + using (var stream = await _httpClient.Get(new HttpRequestOptions + { + Url = url, + CacheLength = TimeSpan.FromDays(1), + CacheMode = CacheMode.Unconditional + + }).ConfigureAwait(false)) + { + using (var reader = new StreamReader(stream)) + { + var path = await reader.ReadToEndAsync().ConfigureAwait(false); + + using (var secondStream = await _httpClient.Get(new HttpRequestOptions + { + Url = "https://www.mb3admin.com" + path, + CacheLength = TimeSpan.FromDays(1), + CacheMode = CacheMode.Unconditional + + }).ConfigureAwait(false)) + { + return ParseResponse<T>(secondStream, filter); + } + } + } + } + + private T ParseResponse<T>(Stream response, Func<string,string> filter) + { + using (var reader = new StreamReader(response)) + { + var json = reader.ReadToEnd(); + + if (filter != null) + { + json = filter(json); + } + + return _jsonSerializer.DeserializeFromString<T>(json); + } + } + + private class LineupInfo + { + public string lineupID { get; set; } + public string lineupName { get; set; } + public string lineupType { get; set; } + public string providerID { get; set; } + public string providerName { get; set; } + public string serviceArea { get; set; } + public string country { get; set; } + } + + private class Station + { + public string number { get; set; } + public int channelNumber { get; set; } + public int subChannelNumber { get; set; } + public int stationID { get; set; } + public string name { get; set; } + public string callsign { get; set; } + public string network { get; set; } + public string stationType { get; set; } + public int NTSC_TSID { get; set; } + public int DTV_TSID { get; set; } + public string webLink { get; set; } + public string logoFilename { get; set; } + } + + private class LineupDetailResponse + { + public string lineupID { get; set; } + public string lineupName { get; set; } + public string lineupType { get; set; } + public string providerID { get; set; } + public string providerName { get; set; } + public string serviceArea { get; set; } + public string country { get; set; } + public List<Station> stations { get; set; } + } + + private class ListingInfo + { + public string number { get; set; } + public int channelNumber { get; set; } + public int subChannelNumber { get; set; } + public int stationID { get; set; } + public string name { get; set; } + public string callsign { get; set; } + public string network { get; set; } + public string stationType { get; set; } + public string webLink { get; set; } + public string logoFilename { get; set; } + public int listingID { get; set; } + public string listDateTime { get; set; } + public int duration { get; set; } + public int showID { get; set; } + public int seriesID { get; set; } + public string showName { get; set; } + public string episodeTitle { get; set; } + public string episodeNumber { get; set; } + public int parts { get; set; } + public int partNum { get; set; } + public bool seriesPremiere { get; set; } + public bool seasonPremiere { get; set; } + public bool seriesFinale { get; set; } + public bool seasonFinale { get; set; } + public bool repeat { get; set; } + public bool @new { get; set; } + public string rating { get; set; } + public bool captioned { get; set; } + public bool educational { get; set; } + public bool blackWhite { get; set; } + public bool subtitled { get; set; } + public bool live { get; set; } + public bool hd { get; set; } + public bool descriptiveVideo { get; set; } + public bool inProgress { get; set; } + public string showTypeID { get; set; } + public int breakoutLevel { get; set; } + public string showType { get; set; } + public string year { get; set; } + public string guest { get; set; } + public string cast { get; set; } + public string director { get; set; } + public int starRating { get; set; } + public string description { get; set; } + public string league { get; set; } + public string team1 { get; set; } + public string team2 { get; set; } + public string @event { get; set; } + public string location { get; set; } + } + } +} diff --git a/MediaBrowser.Server.Implementations/LiveTv/Listings/Emby/IEmbyListingProvider.cs b/MediaBrowser.Server.Implementations/LiveTv/Listings/Emby/IEmbyListingProvider.cs new file mode 100644 index 000000000..95c22b986 --- /dev/null +++ b/MediaBrowser.Server.Implementations/LiveTv/Listings/Emby/IEmbyListingProvider.cs @@ -0,0 +1,18 @@ +using MediaBrowser.Controller.LiveTv; +using MediaBrowser.Model.Dto; +using MediaBrowser.Model.LiveTv; +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace MediaBrowser.Server.Implementations.LiveTv.Listings.Emby +{ + public interface IEmbyListingProvider + { + Task<IEnumerable<ProgramInfo>> GetProgramsAsync(ListingsProviderInfo info, string channelNumber, DateTime startDateUtc, DateTime endDateUtc, CancellationToken cancellationToken); + Task AddMetadata(ListingsProviderInfo info, List<ChannelInfo> channels, CancellationToken cancellationToken); + Task Validate(ListingsProviderInfo info, bool validateLogin, bool validateListings); + Task<List<NameIdPair>> GetLineups(string country, string location); + } +} diff --git a/MediaBrowser.Server.Implementations/LiveTv/Listings/SchedulesDirect.cs b/MediaBrowser.Server.Implementations/LiveTv/Listings/SchedulesDirect.cs new file mode 100644 index 000000000..d53c08150 --- /dev/null +++ b/MediaBrowser.Server.Implementations/LiveTv/Listings/SchedulesDirect.cs @@ -0,0 +1,982 @@ +using System.Net; +using MediaBrowser.Common; +using MediaBrowser.Common.Net; +using MediaBrowser.Controller.LiveTv; +using MediaBrowser.Model.Dto; +using MediaBrowser.Model.LiveTv; +using MediaBrowser.Model.Logging; +using MediaBrowser.Model.Net; +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.Threading; +using System.Threading.Tasks; + +namespace MediaBrowser.Server.Implementations.LiveTv.Listings +{ + public class SchedulesDirect : IListingsProvider + { + private readonly ILogger _logger; + private readonly IJsonSerializer _jsonSerializer; + private readonly IHttpClient _httpClient; + private readonly SemaphoreSlim _tokenSemaphore = new SemaphoreSlim(1, 1); + private readonly IApplicationHost _appHost; + + private const string ApiUrl = "https://json.schedulesdirect.org/20141201"; + + private readonly ConcurrentDictionary<string, ScheduleDirect.Station> _channelPair = + new ConcurrentDictionary<string, ScheduleDirect.Station>(); + + public SchedulesDirect(ILogger logger, IJsonSerializer jsonSerializer, IHttpClient httpClient, IApplicationHost appHost) + { + _logger = logger; + _jsonSerializer = jsonSerializer; + _httpClient = httpClient; + _appHost = appHost; + } + + private string UserAgent + { + get { return "Emby/" + _appHost.ApplicationVersion; } + } + + private List<string> GetScheduleRequestDates(DateTime startDateUtc, DateTime endDateUtc) + { + List<string> dates = new List<string>(); + + var start = new List<DateTime> { startDateUtc, startDateUtc.ToLocalTime() }.Min(); + var end = new List<DateTime> { endDateUtc, endDateUtc.ToLocalTime() }.Max(); + + while (start.DayOfYear <= end.Day) + { + dates.Add(start.ToString("yyyy-MM-dd")); + start = start.AddDays(1); + } + + return dates; + } + + public async Task<IEnumerable<ProgramInfo>> GetProgramsAsync(ListingsProviderInfo info, string channelNumber, DateTime startDateUtc, DateTime endDateUtc, CancellationToken cancellationToken) + { + List<ProgramInfo> programsInfo = new List<ProgramInfo>(); + + var token = await GetToken(info, cancellationToken).ConfigureAwait(false); + + if (string.IsNullOrWhiteSpace(token)) + { + return programsInfo; + } + + if (string.IsNullOrWhiteSpace(info.ListingsId)) + { + return programsInfo; + } + + var httpOptions = new HttpRequestOptions() + { + Url = ApiUrl + "/schedules", + UserAgent = UserAgent, + CancellationToken = cancellationToken, + // The data can be large so give it some extra time + TimeoutMs = 60000 + }; + + httpOptions.RequestHeaders["token"] = token; + + var dates = GetScheduleRequestDates(startDateUtc, endDateUtc); + + ScheduleDirect.Station station = null; + + if (!_channelPair.TryGetValue(channelNumber, out station)) + { + return programsInfo; + } + string stationID = station.stationID; + + _logger.Info("Channel Station ID is: " + stationID); + List<ScheduleDirect.RequestScheduleForChannel> requestList = + new List<ScheduleDirect.RequestScheduleForChannel>() + { + new ScheduleDirect.RequestScheduleForChannel() + { + stationID = stationID, + date = dates + } + }; + + var requestString = _jsonSerializer.SerializeToString(requestList); + _logger.Debug("Request string for schedules is: " + requestString); + httpOptions.RequestContent = requestString; + using (var response = await _httpClient.Post(httpOptions)) + { + StreamReader reader = new StreamReader(response.Content); + string responseString = reader.ReadToEnd(); + var dailySchedules = _jsonSerializer.DeserializeFromString<List<ScheduleDirect.Day>>(responseString); + _logger.Debug("Found " + dailySchedules.Count() + " programs on " + channelNumber + " ScheduleDirect"); + + httpOptions = new HttpRequestOptions() + { + Url = ApiUrl + "/programs", + UserAgent = UserAgent, + CancellationToken = cancellationToken + }; + + httpOptions.RequestHeaders["token"] = token; + + List<string> programsID = new List<string>(); + programsID = dailySchedules.SelectMany(d => d.programs.Select(s => s.programID)).Distinct().ToList(); + var requestBody = "[\"" + string.Join("\", \"", programsID) + "\"]"; + httpOptions.RequestContent = requestBody; + + using (var innerResponse = await _httpClient.Post(httpOptions)) + { + StreamReader innerReader = new StreamReader(innerResponse.Content); + responseString = innerReader.ReadToEnd(); + + var programDetails = + _jsonSerializer.DeserializeFromString<List<ScheduleDirect.ProgramDetails>>( + responseString); + var programDict = programDetails.ToDictionary(p => p.programID, y => y); + + var images = await GetImageForPrograms(programDetails.Where(p => p.hasImageArtwork).Select(p => p.programID).ToList(), cancellationToken); + + var schedules = dailySchedules.SelectMany(d => d.programs); + foreach (ScheduleDirect.Program schedule in schedules) + { + //_logger.Debug("Proccesing Schedule for statio ID " + stationID + + // " which corresponds to channel " + channelNumber + " and program id " + + // schedule.programID + " which says it has images? " + + // programDict[schedule.programID].hasImageArtwork); + + var imageIndex = images.FindIndex(i => i.programID == schedule.programID.Substring(0, 10)); + if (imageIndex > -1) + { + programDict[schedule.programID].images = GetProgramLogo(ApiUrl, images[imageIndex]); + } + + programsInfo.Add(GetProgram(channelNumber, schedule, programDict[schedule.programID])); + } + _logger.Info("Finished with EPGData"); + } + } + + return programsInfo; + } + + public async Task AddMetadata(ListingsProviderInfo info, List<ChannelInfo> channels, + CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(info.ListingsId)) + { + throw new Exception("ListingsId required"); + } + + var token = await GetToken(info, cancellationToken); + + if (string.IsNullOrWhiteSpace(token)) + { + throw new Exception("token required"); + } + + _channelPair.Clear(); + + var httpOptions = new HttpRequestOptions() + { + Url = ApiUrl + "/lineups/" + info.ListingsId, + UserAgent = UserAgent, + CancellationToken = cancellationToken + }; + + httpOptions.RequestHeaders["token"] = token; + + using (var response = await _httpClient.Get(httpOptions)) + { + var root = _jsonSerializer.DeserializeFromStream<ScheduleDirect.Channel>(response); + _logger.Info("Found " + root.map.Count() + " channels on the lineup on ScheduleDirect"); + _logger.Info("Mapping Stations to Channel"); + foreach (ScheduleDirect.Map map in root.map) + { + var channel = (map.channel ?? (map.atscMajor + "." + map.atscMinor)).TrimStart('0'); + _logger.Debug("Found channel: " + channel + " in Schedules Direct"); + var schChannel = root.stations.FirstOrDefault(item => item.stationID == map.stationID); + + if (!_channelPair.ContainsKey(channel) && channel != "0.0" && schChannel != null) + { + _channelPair.TryAdd(channel, schChannel); + } + } + _logger.Info("Added " + _channelPair.Count() + " channels to the dictionary"); + + foreach (ChannelInfo channel in channels) + { + // Helper.logger.Info("Modifyin channel " + channel.Number); + if (_channelPair.ContainsKey(channel.Number)) + { + string channelName; + if (_channelPair[channel.Number].logo != null) + { + channel.ImageUrl = _channelPair[channel.Number].logo.URL; + channel.HasImage = true; + } + if (_channelPair[channel.Number].affiliate != null) + { + channelName = _channelPair[channel.Number].affiliate; + } + else + { + channelName = _channelPair[channel.Number].name; + } + channel.Name = channelName; + } + else + { + _logger.Info("Schedules Direct doesnt have data for channel: " + channel.Number + " " + + channel.Name); + } + } + } + } + + private ProgramInfo GetProgram(string channel, ScheduleDirect.Program programInfo, + ScheduleDirect.ProgramDetails details) + { + //_logger.Debug("Show type is: " + (details.showType ?? "No ShowType")); + DateTime startAt = DateTime.ParseExact(programInfo.airDateTime, "yyyy'-'MM'-'dd'T'HH':'mm':'ss'Z'", + CultureInfo.InvariantCulture); + DateTime endAt = startAt.AddSeconds(programInfo.duration); + ProgramAudio audioType = ProgramAudio.Stereo; + + bool repeat = (programInfo.@new == null); + string newID = programInfo.programID + "T" + startAt.Ticks + "C" + channel; + + if (programInfo.audioProperties != null) + { + if (programInfo.audioProperties.Exists(item => string.Equals(item, "dd 5.1", StringComparison.OrdinalIgnoreCase))) + { + audioType = ProgramAudio.DolbyDigital; + } + else if (programInfo.audioProperties.Exists(item => string.Equals(item, "dd", StringComparison.OrdinalIgnoreCase))) + { + audioType = ProgramAudio.DolbyDigital; + } + else if (programInfo.audioProperties.Exists(item => string.Equals(item, "stereo", StringComparison.OrdinalIgnoreCase))) + { + audioType = ProgramAudio.Stereo; + } + else + { + audioType = ProgramAudio.Mono; + } + } + + string episodeTitle = null; + if (details.episodeTitle150 != null) + { + episodeTitle = details.episodeTitle150; + } + + string imageUrl = null; + + if (details.hasImageArtwork) + { + imageUrl = details.images; + } + + var showType = details.showType ?? string.Empty; + + var info = new ProgramInfo + { + ChannelId = channel, + Id = newID, + StartDate = startAt, + EndDate = endAt, + Name = details.titles[0].title120 ?? "Unkown", + OfficialRating = null, + CommunityRating = null, + EpisodeTitle = episodeTitle, + Audio = audioType, + IsRepeat = repeat, + IsSeries = showType.IndexOf("series", StringComparison.OrdinalIgnoreCase) != -1, + ImageUrl = imageUrl, + HasImage = details.hasImageArtwork, + IsKids = string.Equals(details.audience, "children", StringComparison.OrdinalIgnoreCase), + IsSports = showType.IndexOf("sports", StringComparison.OrdinalIgnoreCase) != -1, + IsMovie = showType.IndexOf("movie", StringComparison.OrdinalIgnoreCase) != -1 || showType.IndexOf("film", StringComparison.OrdinalIgnoreCase) != -1, + ShowId = programInfo.programID, + Etag = programInfo.md5 + }; + + if (programInfo.videoProperties != null) + { + info.IsHD = programInfo.videoProperties.Contains("hdtv", StringComparer.OrdinalIgnoreCase); + } + + if (details.contentRating != null && details.contentRating.Count > 0) + { + info.OfficialRating = details.contentRating[0].code.Replace("TV", "TV-").Replace("--", "-"); + + var invalid = new[] { "N/A", "Approved", "Not Rated", "Passed" }; + if (invalid.Contains(info.OfficialRating, StringComparer.OrdinalIgnoreCase)) + { + info.OfficialRating = null; + } + } + + if (details.descriptions != null) + { + if (details.descriptions.description1000 != null) + { + info.Overview = details.descriptions.description1000[0].description; + } + else if (details.descriptions.description100 != null) + { + info.ShortOverview = details.descriptions.description100[0].description; + } + } + + if (info.IsSeries) + { + info.SeriesId = programInfo.programID.Substring(0, 10); + + if (details.metadata != null) + { + var gracenote = details.metadata.Find(x => x.Gracenote != null).Gracenote; + info.SeasonNumber = gracenote.season; + info.EpisodeNumber = gracenote.episode; + } + } + + if (!string.IsNullOrWhiteSpace(details.originalAirDate)) + { + info.OriginalAirDate = DateTime.Parse(details.originalAirDate); + } + + if (details.genres != null) + { + info.Genres = details.genres.Where(g => !string.IsNullOrWhiteSpace(g)).ToList(); + info.IsNews = details.genres.Contains("news", StringComparer.OrdinalIgnoreCase); + + if (info.Genres.Contains("children", StringComparer.OrdinalIgnoreCase)) + { + info.IsKids = true; + } + } + + return info; + } + + private string GetProgramLogo(string apiUrl, ScheduleDirect.ShowImages images) + { + string url = ""; + if (images.data != null) + { + var smallImages = images.data.Where(i => i.size == "Sm").ToList(); + if (smallImages.Any()) + { + images.data = smallImages; + } + var logoIndex = images.data.FindIndex(i => i.category == "Logo"); + if (logoIndex == -1) + { + logoIndex = 0; + } + if (images.data[logoIndex].uri.Contains("http")) + { + url = images.data[logoIndex].uri; + } + else + { + url = apiUrl + "/image/" + images.data[logoIndex].uri; + } + //_logger.Debug("URL for image is : " + url); + } + return url; + } + + private async Task<List<ScheduleDirect.ShowImages>> GetImageForPrograms(List<string> programIds, + CancellationToken cancellationToken) + { + var imageIdString = "["; + + programIds.ForEach(i => + { + if (!imageIdString.Contains(i.Substring(0, 10))) + { + imageIdString += "\"" + i.Substring(0, 10) + "\","; + } + ; + }); + imageIdString = imageIdString.TrimEnd(',') + "]"; + + var httpOptions = new HttpRequestOptions() + { + Url = ApiUrl + "/metadata/programs", + UserAgent = UserAgent, + CancellationToken = cancellationToken, + RequestContent = imageIdString + }; + List<ScheduleDirect.ShowImages> images; + using (var innerResponse2 = await _httpClient.Post(httpOptions)) + { + images = _jsonSerializer.DeserializeFromStream<List<ScheduleDirect.ShowImages>>( + innerResponse2.Content); + } + + return images; + } + + public async Task<List<NameIdPair>> GetHeadends(ListingsProviderInfo info, string country, string location, CancellationToken cancellationToken) + { + var token = await GetToken(info, cancellationToken); + + var lineups = new List<NameIdPair>(); + + if (string.IsNullOrWhiteSpace(token)) + { + return lineups; + } + + var options = new HttpRequestOptions() + { + Url = ApiUrl + "/headends?country=" + country + "&postalcode=" + location, + UserAgent = UserAgent, + CancellationToken = cancellationToken + }; + + options.RequestHeaders["token"] = token; + + try + { + using (Stream responce = await _httpClient.Get(options).ConfigureAwait(false)) + { + var root = _jsonSerializer.DeserializeFromStream<List<ScheduleDirect.Headends>>(responce); + + if (root != null) + { + foreach (ScheduleDirect.Headends headend in root) + { + foreach (ScheduleDirect.Lineup lineup in headend.lineups) + { + lineups.Add(new NameIdPair + { + Name = string.IsNullOrWhiteSpace(lineup.name) ? lineup.lineup : lineup.name, + Id = lineup.uri.Substring(18) + }); + } + } + } + else + { + _logger.Info("No lineups available"); + } + } + } + catch (Exception ex) + { + _logger.Error("Error getting headends", ex); + } + + return lineups; + } + + private readonly ConcurrentDictionary<string, NameValuePair> _tokens = new ConcurrentDictionary<string, NameValuePair>(); + private DateTime _lastErrorResponse; + private async Task<string> GetToken(ListingsProviderInfo info, CancellationToken cancellationToken) + { + var username = info.Username; + + // Reset the token if there's no username + if (string.IsNullOrWhiteSpace(username)) + { + return null; + } + + var password = info.Password; + if (string.IsNullOrWhiteSpace(password)) + { + return null; + } + + // Avoid hammering SD + if ((DateTime.UtcNow - _lastErrorResponse).TotalMinutes < 1) + { + return null; + } + + NameValuePair savedToken = null; + if (!_tokens.TryGetValue(username, out savedToken)) + { + savedToken = new NameValuePair(); + _tokens.TryAdd(username, savedToken); + } + + if (!string.IsNullOrWhiteSpace(savedToken.Name) && !string.IsNullOrWhiteSpace(savedToken.Value)) + { + long ticks; + if (long.TryParse(savedToken.Value, NumberStyles.Any, CultureInfo.InvariantCulture, out ticks)) + { + // If it's under 24 hours old we can still use it + if ((DateTime.UtcNow.Ticks - ticks) < TimeSpan.FromHours(24).Ticks) + { + return savedToken.Name; + } + } + } + + await _tokenSemaphore.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + var result = await GetTokenInternal(username, password, cancellationToken).ConfigureAwait(false); + savedToken.Name = result; + savedToken.Value = DateTime.UtcNow.Ticks.ToString(CultureInfo.InvariantCulture); + return result; + } + catch (HttpException ex) + { + if (ex.StatusCode.HasValue) + { + if ((int)ex.StatusCode.Value == 400) + { + _tokens.Clear(); + _lastErrorResponse = DateTime.UtcNow; + } + } + throw; + } + finally + { + _tokenSemaphore.Release(); + } + } + + private async Task<string> GetTokenInternal(string username, string password, + CancellationToken cancellationToken) + { + var httpOptions = new HttpRequestOptions() + { + Url = ApiUrl + "/token", + UserAgent = UserAgent, + RequestContent = "{\"username\":\"" + username + "\",\"password\":\"" + password + "\"}", + CancellationToken = cancellationToken + }; + //_logger.Info("Obtaining token from Schedules Direct from addres: " + httpOptions.Url + " with body " + + // httpOptions.RequestContent); + + using (var responce = await _httpClient.Post(httpOptions)) + { + var root = _jsonSerializer.DeserializeFromStream<ScheduleDirect.Token>(responce.Content); + if (root.message == "OK") + { + _logger.Info("Authenticated with Schedules Direct token: " + root.token); + return root.token; + } + + throw new ApplicationException("Could not authenticate with Schedules Direct Error: " + root.message); + } + } + + private async Task AddLineupToAccount(ListingsProviderInfo info, CancellationToken cancellationToken) + { + var token = await GetToken(info, cancellationToken); + + if (string.IsNullOrWhiteSpace(token)) + { + throw new ArgumentException("Authentication required."); + } + + if (string.IsNullOrWhiteSpace(info.ListingsId)) + { + throw new ArgumentException("Listings Id required"); + } + + _logger.Info("Adding new LineUp "); + + var httpOptions = new HttpRequestOptions() + { + Url = ApiUrl + "/lineups/" + info.ListingsId, + UserAgent = UserAgent, + CancellationToken = cancellationToken + }; + + httpOptions.RequestHeaders["token"] = token; + + using (var response = await _httpClient.SendAsync(httpOptions, "PUT")) + { + } + } + + public string Name + { + get { return "Schedules Direct"; } + } + + public string Type + { + get { return "SchedulesDirect"; } + } + + private async Task<bool> HasLineup(ListingsProviderInfo info, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(info.ListingsId)) + { + throw new ArgumentException("Listings Id required"); + } + + var token = await GetToken(info, cancellationToken); + + if (string.IsNullOrWhiteSpace(token)) + { + throw new Exception("token required"); + } + + _logger.Info("Headends on account "); + + var options = new HttpRequestOptions() + { + Url = ApiUrl + "/lineups", + UserAgent = UserAgent, + CancellationToken = cancellationToken + }; + + options.RequestHeaders["token"] = token; + + try + { + using (var response = await _httpClient.Get(options).ConfigureAwait(false)) + { + var root = _jsonSerializer.DeserializeFromStream<ScheduleDirect.Lineups>(response); + + return root.lineups.Any(i => string.Equals(info.ListingsId, i.lineup, StringComparison.OrdinalIgnoreCase)); + } + } + catch (HttpException ex) + { + // Apparently we're supposed to swallow this + if (ex.StatusCode.HasValue && ex.StatusCode.Value == HttpStatusCode.BadRequest) + { + return false; + } + + throw; + } + } + + public async Task Validate(ListingsProviderInfo info, bool validateLogin, bool validateListings) + { + if (validateLogin) + { + if (string.IsNullOrWhiteSpace(info.Username)) + { + throw new ArgumentException("Username is required"); + } + if (string.IsNullOrWhiteSpace(info.Password)) + { + throw new ArgumentException("Password is required"); + } + } + if (validateListings) + { + if (string.IsNullOrWhiteSpace(info.ListingsId)) + { + throw new ArgumentException("Listings Id required"); + } + + var hasLineup = await HasLineup(info, CancellationToken.None).ConfigureAwait(false); + + if (!hasLineup) + { + await AddLineupToAccount(info, CancellationToken.None).ConfigureAwait(false); + } + } + } + + public Task<List<NameIdPair>> GetLineups(ListingsProviderInfo info, string country, string location) + { + return GetHeadends(info, country, location, CancellationToken.None); + } + + public class ScheduleDirect + { + public class Token + { + public int code { get; set; } + public string message { get; set; } + public string serverID { get; set; } + public string token { get; set; } + } + public class Lineup + { + public string lineup { get; set; } + public string name { get; set; } + public string transport { get; set; } + public string location { get; set; } + public string uri { get; set; } + } + + public class Lineups + { + public int code { get; set; } + public string serverID { get; set; } + public string datetime { get; set; } + public List<Lineup> lineups { get; set; } + } + + + public class Headends + { + public string headend { get; set; } + public string transport { get; set; } + public string location { get; set; } + public List<Lineup> lineups { get; set; } + } + + + + public class Map + { + public string stationID { get; set; } + public string channel { get; set; } + public int uhfVhf { get; set; } + public int atscMajor { get; set; } + public int atscMinor { get; set; } + } + + public class Broadcaster + { + public string city { get; set; } + public string state { get; set; } + public string postalcode { get; set; } + public string country { get; set; } + } + + public class Logo + { + public string URL { get; set; } + public int height { get; set; } + public int width { get; set; } + public string md5 { get; set; } + } + + public class Station + { + public string stationID { get; set; } + public string name { get; set; } + public string callsign { get; set; } + public List<string> broadcastLanguage { get; set; } + public List<string> descriptionLanguage { get; set; } + public Broadcaster broadcaster { get; set; } + public string affiliate { get; set; } + public Logo logo { get; set; } + public bool? isCommercialFree { get; set; } + } + + public class Metadata + { + public string lineup { get; set; } + public string modified { get; set; } + public string transport { get; set; } + } + + public class Channel + { + public List<Map> map { get; set; } + public List<Station> stations { get; set; } + public Metadata metadata { get; set; } + } + + public class RequestScheduleForChannel + { + public string stationID { get; set; } + public List<string> date { get; set; } + } + + + + + public class Rating + { + public string body { get; set; } + public string code { get; set; } + } + + public class Multipart + { + public int partNumber { get; set; } + public int totalParts { get; set; } + } + + public class Program + { + public string programID { get; set; } + public string airDateTime { get; set; } + public int duration { get; set; } + public string md5 { get; set; } + public List<string> audioProperties { get; set; } + public List<string> videoProperties { get; set; } + public List<Rating> ratings { get; set; } + public bool? @new { get; set; } + public Multipart multipart { get; set; } + } + + + + public class MetadataSchedule + { + public string modified { get; set; } + public string md5 { get; set; } + public string startDate { get; set; } + public string endDate { get; set; } + public int days { get; set; } + } + + public class Day + { + public string stationID { get; set; } + public List<Program> programs { get; set; } + public MetadataSchedule metadata { get; set; } + } + + // + public class Title + { + public string title120 { get; set; } + } + + public class EventDetails + { + public string subType { get; set; } + } + + public class Description100 + { + public string descriptionLanguage { get; set; } + public string description { get; set; } + } + + public class Description1000 + { + public string descriptionLanguage { get; set; } + public string description { get; set; } + } + + public class DescriptionsProgram + { + public List<Description100> description100 { get; set; } + public List<Description1000> description1000 { get; set; } + } + + public class Gracenote + { + public int season { get; set; } + public int episode { get; set; } + } + + public class MetadataPrograms + { + public Gracenote Gracenote { get; set; } + } + + public class ContentRating + { + public string body { get; set; } + public string code { get; set; } + } + + public class Cast + { + public string billingOrder { get; set; } + public string role { get; set; } + public string nameId { get; set; } + public string personId { get; set; } + public string name { get; set; } + public string characterName { get; set; } + } + + public class Crew + { + public string billingOrder { get; set; } + public string role { get; set; } + public string nameId { get; set; } + public string personId { get; set; } + public string name { get; set; } + } + + public class QualityRating + { + public string ratingsBody { get; set; } + public string rating { get; set; } + public string minRating { get; set; } + public string maxRating { get; set; } + public string increment { get; set; } + } + + public class Movie + { + public string year { get; set; } + public int duration { get; set; } + public List<QualityRating> qualityRating { get; set; } + } + + public class Recommendation + { + public string programID { get; set; } + public string title120 { get; set; } + } + + public class ProgramDetails + { + public string audience { get; set; } + public string programID { get; set; } + public List<Title> titles { get; set; } + public EventDetails eventDetails { get; set; } + public DescriptionsProgram descriptions { get; set; } + public string originalAirDate { get; set; } + public List<string> genres { get; set; } + public string episodeTitle150 { get; set; } + public List<MetadataPrograms> metadata { get; set; } + public List<ContentRating> contentRating { get; set; } + public List<Cast> cast { get; set; } + public List<Crew> crew { get; set; } + public string showType { get; set; } + public bool hasImageArtwork { get; set; } + public string images { get; set; } + public string imageID { get; set; } + public string md5 { get; set; } + public List<string> contentAdvisory { get; set; } + public Movie movie { get; set; } + public List<Recommendation> recommendations { get; set; } + } + + public class Caption + { + public string content { get; set; } + public string lang { get; set; } + } + + public class ImageData + { + public string width { get; set; } + public string height { get; set; } + public string uri { get; set; } + public string size { get; set; } + public string aspect { get; set; } + public string category { get; set; } + public string text { get; set; } + public string primary { get; set; } + public string tier { get; set; } + public Caption caption { get; set; } + } + + public class ShowImages + { + public string programID { get; set; } + public List<ImageData> data { get; set; } + } + + } + } +} diff --git a/MediaBrowser.Server.Implementations/LiveTv/Listings/XmlTv.cs b/MediaBrowser.Server.Implementations/LiveTv/Listings/XmlTv.cs new file mode 100644 index 000000000..de107ced8 --- /dev/null +++ b/MediaBrowser.Server.Implementations/LiveTv/Listings/XmlTv.cs @@ -0,0 +1,44 @@ +using MediaBrowser.Controller.LiveTv; +using MediaBrowser.Model.Dto; +using MediaBrowser.Model.LiveTv; +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace MediaBrowser.Server.Implementations.LiveTv.Listings +{ + public class XmlTv : IListingsProvider + { + public string Name + { + get { return "XmlTV"; } + } + + public string Type + { + get { return "xmltv"; } + } + + public Task<IEnumerable<ProgramInfo>> GetProgramsAsync(ListingsProviderInfo info, string channelNumber, DateTime startDateUtc, DateTime endDateUtc, CancellationToken cancellationToken) + { + throw new NotImplementedException(); + } + + public async Task AddMetadata(ListingsProviderInfo info, List<ChannelInfo> channels, CancellationToken cancellationToken) + { + // Might not be needed + } + + public async Task Validate(ListingsProviderInfo info, bool validateLogin, bool validateListings) + { + // Check that the path or url is valid. If not, throw a file not found exception + } + + public Task<List<NameIdPair>> GetLineups(ListingsProviderInfo info, string country, string location) + { + // In theory this should never be called because there is always only one lineup + throw new NotImplementedException(); + } + } +} diff --git a/MediaBrowser.Server.Implementations/LiveTv/LiveTvDtoService.cs b/MediaBrowser.Server.Implementations/LiveTv/LiveTvDtoService.cs index 72fea2c79..9ffd8a500 100644 --- a/MediaBrowser.Server.Implementations/LiveTv/LiveTvDtoService.cs +++ b/MediaBrowser.Server.Implementations/LiveTv/LiveTvDtoService.cs @@ -198,10 +198,11 @@ namespace MediaBrowser.Server.Implementations.LiveTv /// Gets the channel info dto. /// </summary> /// <param name="info">The info.</param> + /// <param name="options">The options.</param> /// <param name="currentProgram">The current program.</param> /// <param name="user">The user.</param> /// <returns>ChannelInfoDto.</returns> - public ChannelInfoDto GetChannelInfoDto(LiveTvChannel info, LiveTvProgram currentProgram, User user = null) + public ChannelInfoDto GetChannelInfoDto(LiveTvChannel info, DtoOptions options, LiveTvProgram currentProgram, User user = null) { var dto = new ChannelInfoDto { @@ -238,7 +239,7 @@ namespace MediaBrowser.Server.Implementations.LiveTv if (currentProgram != null) { - dto.CurrentProgram = _dtoService.GetBaseItemDto(currentProgram, new DtoOptions(), user); + dto.CurrentProgram = _dtoService.GetBaseItemDto(currentProgram, options, user); } return dto; diff --git a/MediaBrowser.Server.Implementations/LiveTv/LiveTvManager.cs b/MediaBrowser.Server.Implementations/LiveTv/LiveTvManager.cs index f6c69d8d6..d73b144b8 100644 --- a/MediaBrowser.Server.Implementations/LiveTv/LiveTvManager.cs +++ b/MediaBrowser.Server.Implementations/LiveTv/LiveTvManager.cs @@ -1,6 +1,7 @@ using MediaBrowser.Common; using MediaBrowser.Common.Configuration; using MediaBrowser.Common.Extensions; +using MediaBrowser.Common.IO; using MediaBrowser.Common.Progress; using MediaBrowser.Common.ScheduledTasks; using MediaBrowser.Controller.Configuration; @@ -13,15 +14,18 @@ using MediaBrowser.Controller.Localization; using MediaBrowser.Controller.Persistence; using MediaBrowser.Controller.Providers; using MediaBrowser.Controller.Sorting; +using MediaBrowser.Model.Configuration; using MediaBrowser.Model.Dto; using MediaBrowser.Model.Entities; using MediaBrowser.Model.LiveTv; using MediaBrowser.Model.Logging; using MediaBrowser.Model.Querying; using MediaBrowser.Model.Serialization; +using MoreLinq; using System; using System.Collections.Concurrent; using System.Collections.Generic; +using System.IO; using System.Linq; using System.Threading; using System.Threading.Tasks; @@ -55,7 +59,11 @@ namespace MediaBrowser.Server.Implementations.LiveTv private readonly SemaphoreSlim _refreshRecordingsLock = new SemaphoreSlim(1, 1); - public LiveTvManager(IApplicationHost appHost, IServerConfigurationManager config, ILogger logger, IItemRepository itemRepo, IImageProcessor imageProcessor, IUserDataManager userDataManager, IDtoService dtoService, IUserManager userManager, ILibraryManager libraryManager, ITaskManager taskManager, ILocalizationManager localization, IJsonSerializer jsonSerializer, IProviderManager providerManager) + private readonly List<ITunerHost> _tunerHosts = new List<ITunerHost>(); + private readonly List<IListingsProvider> _listingProviders = new List<IListingsProvider>(); + private readonly IFileSystem _fileSystem; + + public LiveTvManager(IApplicationHost appHost, IServerConfigurationManager config, ILogger logger, IItemRepository itemRepo, IImageProcessor imageProcessor, IUserDataManager userDataManager, IDtoService dtoService, IUserManager userManager, ILibraryManager libraryManager, ITaskManager taskManager, ILocalizationManager localization, IJsonSerializer jsonSerializer, IProviderManager providerManager, IFileSystem fileSystem) { _config = config; _logger = logger; @@ -66,6 +74,7 @@ namespace MediaBrowser.Server.Implementations.LiveTv _localization = localization; _jsonSerializer = jsonSerializer; _providerManager = providerManager; + _fileSystem = fileSystem; _dtoService = dtoService; _userDataManager = userDataManager; @@ -90,9 +99,13 @@ namespace MediaBrowser.Server.Implementations.LiveTv /// Adds the parts. /// </summary> /// <param name="services">The services.</param> - public void AddParts(IEnumerable<ILiveTvService> services) + /// <param name="tunerHosts">The tuner hosts.</param> + /// <param name="listingProviders">The listing providers.</param> + public void AddParts(IEnumerable<ILiveTvService> services, IEnumerable<ITunerHost> tunerHosts, IEnumerable<IListingsProvider> listingProviders) { _services.AddRange(services); + _tunerHosts.AddRange(tunerHosts); + _listingProviders.AddRange(listingProviders); foreach (var service in _services) { @@ -100,6 +113,16 @@ namespace MediaBrowser.Server.Implementations.LiveTv } } + public List<ITunerHost> TunerHosts + { + get { return _tunerHosts; } + } + + public List<IListingsProvider> ListingProviders + { + get { return _listingProviders; } + } + void service_DataSourceChanged(object sender, EventArgs e) { _taskManager.CancelIfRunningAndQueue<RefreshChannelsScheduledTask>(); @@ -218,7 +241,7 @@ namespace MediaBrowser.Server.Implementations.LiveTv return result; } - public async Task<QueryResult<ChannelInfoDto>> GetChannels(LiveTvChannelQuery query, CancellationToken cancellationToken) + public async Task<QueryResult<ChannelInfoDto>> GetChannels(LiveTvChannelQuery query, DtoOptions options, CancellationToken cancellationToken) { var user = string.IsNullOrEmpty(query.UserId) ? null : _userManager.GetUserById(query.UserId); @@ -228,20 +251,21 @@ namespace MediaBrowser.Server.Implementations.LiveTv var now = DateTime.UtcNow; - var programs = _libraryManager.GetItems(new InternalItemsQuery + var programs = query.AddCurrentProgram ? _libraryManager.QueryItems(new InternalItemsQuery { IncludeItemTypes = new[] { typeof(LiveTvProgram).Name }, MaxStartDate = now, - MinEndDate = now + MinEndDate = now, + ChannelIds = internalResult.Items.Select(i => i.Id.ToString("N")).ToArray() - }).Items.Cast<LiveTvProgram>().OrderBy(i => i.StartDate).ToList(); + }).Items.Cast<LiveTvProgram>().OrderBy(i => i.StartDate).ToList() : new List<LiveTvProgram>(); foreach (var channel in internalResult.Items) { var channelIdString = channel.Id.ToString("N"); var currentProgram = programs.FirstOrDefault(i => string.Equals(i.ChannelId, channelIdString, StringComparison.OrdinalIgnoreCase)); - returnList.Add(_tvDtoService.GetChannelInfoDto(channel, currentProgram, user)); + returnList.Add(_tvDtoService.GetChannelInfoDto(channel, options, currentProgram, user)); } var result = new QueryResult<ChannelInfoDto> @@ -310,6 +334,12 @@ namespace MediaBrowser.Server.Implementations.LiveTv var service = GetService(item); var sources = await service.GetChannelStreamMediaSources(item.ExternalId, cancellationToken).ConfigureAwait(false); + + if (sources.Count == 0) + { + throw new NotImplementedException(); + } + var list = sources.ToList(); foreach (var source in list) @@ -334,6 +364,11 @@ namespace MediaBrowser.Server.Implementations.LiveTv { await _liveStreamSemaphore.WaitAsync(cancellationToken).ConfigureAwait(false); + if (string.Equals(id, mediaSourceId, StringComparison.OrdinalIgnoreCase)) + { + mediaSourceId = null; + } + try { MediaSourceInfo info; @@ -525,16 +560,16 @@ namespace MediaBrowser.Server.Implementations.LiveTv var replaceImages = new List<ImageType>(); - if (!string.Equals(item.ProviderImageUrl, channelInfo.ImageUrl, StringComparison.OrdinalIgnoreCase)) - { - isNew = true; - replaceImages.Add(ImageType.Primary); - } - if (!string.Equals(item.ProviderImagePath, channelInfo.ImagePath, StringComparison.OrdinalIgnoreCase)) - { - isNew = true; - replaceImages.Add(ImageType.Primary); - } + //if (!string.Equals(item.ProviderImageUrl, channelInfo.ImageUrl, StringComparison.OrdinalIgnoreCase)) + //{ + // isNew = true; + // replaceImages.Add(ImageType.Primary); + //} + //if (!string.Equals(item.ProviderImagePath, channelInfo.ImagePath, StringComparison.OrdinalIgnoreCase)) + //{ + // isNew = true; + // replaceImages.Add(ImageType.Primary); + //} item.ProviderImageUrl = channelInfo.ImageUrl; item.HasProviderImage = channelInfo.HasImage; @@ -570,7 +605,8 @@ namespace MediaBrowser.Server.Implementations.LiveTv Name = info.Name, Id = id, DateCreated = DateTime.UtcNow, - DateModified = DateTime.UtcNow + DateModified = DateTime.UtcNow, + Etag = info.Etag }; } @@ -602,17 +638,25 @@ namespace MediaBrowser.Server.Implementations.LiveTv item.ProviderImageUrl = info.ImageUrl; item.RunTimeTicks = (info.EndDate - info.StartDate).Ticks; item.StartDate = info.StartDate; + item.HomePageUrl = info.HomePageUrl; item.ProductionYear = info.ProductionYear; item.PremiereDate = item.PremiereDate ?? info.OriginalAirDate; + item.IndexNumber = info.EpisodeNumber; + item.ParentIndexNumber = info.SeasonNumber; + if (isNew) { await _libraryManager.CreateItem(item, cancellationToken).ConfigureAwait(false); } else { - await _libraryManager.UpdateItem(item, ItemUpdateType.MetadataImport, cancellationToken).ConfigureAwait(false); + if (string.IsNullOrWhiteSpace(info.Etag) || !string.Equals(info.Etag, item.Etag, StringComparison.OrdinalIgnoreCase)) + { + item.Etag = info.Etag; + await _libraryManager.UpdateItem(item, ItemUpdateType.MetadataImport, cancellationToken).ConfigureAwait(false); + } } _providerManager.QueueRefresh(item.Id, new MetadataRefreshOptions()); @@ -694,6 +738,10 @@ namespace MediaBrowser.Server.Implementations.LiveTv if (!string.IsNullOrEmpty(info.Path)) { item.Path = info.Path; + var fileInfo = new FileInfo(info.Path); + + recording.DateCreated = _fileSystem.GetCreationTimeUtc(fileInfo); + recording.DateModified = _fileSystem.GetLastWriteTimeUtc(fileInfo); } else if (!string.IsNullOrEmpty(info.Url)) { @@ -706,7 +754,7 @@ namespace MediaBrowser.Server.Implementations.LiveTv { await _libraryManager.CreateItem(item, cancellationToken).ConfigureAwait(false); } - else if (pathChanged) + else if (pathChanged || info.DateLastUpdated > recording.DateLastSaved) { await _libraryManager.UpdateItem(item, ItemUpdateType.MetadataImport, cancellationToken).ConfigureAwait(false); } @@ -727,7 +775,7 @@ namespace MediaBrowser.Server.Implementations.LiveTv return dto; } - public async Task<QueryResult<BaseItemDto>> GetPrograms(ProgramQuery query, CancellationToken cancellationToken) + public async Task<QueryResult<BaseItemDto>> GetPrograms(ProgramQuery query, DtoOptions options, CancellationToken cancellationToken) { var internalQuery = new InternalItemsQuery { @@ -738,9 +786,26 @@ namespace MediaBrowser.Server.Implementations.LiveTv MaxStartDate = query.MaxStartDate, ChannelIds = query.ChannelIds, IsMovie = query.IsMovie, - IsSports = query.IsSports + IsSports = query.IsSports, + IsKids = query.IsKids, + Genres = query.Genres, + StartIndex = query.StartIndex, + Limit = query.Limit, + SortBy = query.SortBy, + SortOrder = query.SortOrder ?? SortOrder.Ascending }; + var user = string.IsNullOrEmpty(query.UserId) ? null : _userManager.GetUserById(query.UserId); + if (user != null) + { + internalQuery.MaxParentalRating = user.Policy.MaxParentalRating; + + if (user.Policy.BlockUnratedItems.Contains(UnratedItem.LiveTvProgram)) + { + internalQuery.HasParentalRating = true; + } + } + if (query.HasAired.HasValue) { if (query.HasAired.Value) @@ -753,40 +818,10 @@ namespace MediaBrowser.Server.Implementations.LiveTv } } - IEnumerable<LiveTvProgram> programs = _libraryManager.GetItems(internalQuery).Items.Cast<LiveTvProgram>(); - - // Apply genre filter - if (query.Genres.Length > 0) - { - programs = programs.Where(p => p.Genres.Any(g => query.Genres.Contains(g, StringComparer.OrdinalIgnoreCase))); - } - - var user = string.IsNullOrEmpty(query.UserId) ? null : _userManager.GetUserById(query.UserId); - if (user != null) - { - // Avoid implicitly captured closure - var currentUser = user; - programs = programs.Where(i => i.IsVisible(currentUser)); - } - - programs = _libraryManager.Sort(programs, user, query.SortBy, query.SortOrder ?? SortOrder.Ascending) - .Cast<LiveTvProgram>(); - - var programList = programs.ToList(); - IEnumerable<LiveTvProgram> returnPrograms = programList; - - if (query.StartIndex.HasValue) - { - returnPrograms = returnPrograms.Skip(query.StartIndex.Value); - } - - if (query.Limit.HasValue) - { - returnPrograms = returnPrograms.Take(query.Limit.Value); - } + var queryResult = _libraryManager.QueryItems(internalQuery); - var returnArray = returnPrograms - .Select(i => _dtoService.GetBaseItemDto(i, new DtoOptions(), user)) + var returnArray = queryResult.Items + .Select(i => _dtoService.GetBaseItemDto(i, options, user)) .ToArray(); await AddRecordingInfo(returnArray, cancellationToken).ConfigureAwait(false); @@ -794,7 +829,7 @@ namespace MediaBrowser.Server.Implementations.LiveTv var result = new QueryResult<BaseItemDto> { Items = returnArray, - TotalRecordCount = programList.Count + TotalRecordCount = queryResult.TotalRecordCount }; return result; @@ -807,7 +842,8 @@ namespace MediaBrowser.Server.Implementations.LiveTv IncludeItemTypes = new[] { typeof(LiveTvProgram).Name }, IsAiring = query.IsAiring, IsMovie = query.IsMovie, - IsSports = query.IsSports + IsSports = query.IsSports, + IsKids = query.IsKids }; if (query.HasAired.HasValue) @@ -822,19 +858,26 @@ namespace MediaBrowser.Server.Implementations.LiveTv } } - IEnumerable<LiveTvProgram> programs = _libraryManager.GetItems(internalQuery).Items.Cast<LiveTvProgram>(); - var user = _userManager.GetUserById(query.UserId); + if (user != null) + { + internalQuery.MaxParentalRating = user.Policy.MaxParentalRating; + + if (user.Policy.BlockUnratedItems.Contains(UnratedItem.LiveTvProgram)) + { + internalQuery.HasParentalRating = true; + } + } - // Avoid implicitly captured closure - var currentUser = user; - programs = programs.Where(i => i.IsVisible(currentUser)); + IEnumerable<LiveTvProgram> programs = _libraryManager.QueryItems(internalQuery).Items.Cast<LiveTvProgram>(); var programList = programs.ToList(); var genres = programList.SelectMany(i => i.Genres) + .Where(i => !string.IsNullOrWhiteSpace(i)) .DistinctNames() .Select(i => _libraryManager.GetGenre(i)) + .DistinctBy(i => i.Id) .ToDictionary(i => i.Name, StringComparer.OrdinalIgnoreCase); programs = programList.OrderBy(i => i.HasImage(ImageType.Primary) ? 0 : 1) @@ -843,8 +886,7 @@ namespace MediaBrowser.Server.Implementations.LiveTv if (query.Limit.HasValue) { - programs = programs.Take(query.Limit.Value) - .OrderBy(i => i.StartDate); + programs = programs.Take(query.Limit.Value); } programList = programs.ToList(); @@ -860,14 +902,14 @@ namespace MediaBrowser.Server.Implementations.LiveTv return result; } - public async Task<QueryResult<BaseItemDto>> GetRecommendedPrograms(RecommendedProgramQuery query, CancellationToken cancellationToken) + public async Task<QueryResult<BaseItemDto>> GetRecommendedPrograms(RecommendedProgramQuery query, DtoOptions options, CancellationToken cancellationToken) { var internalResult = await GetRecommendedProgramsInternal(query, cancellationToken).ConfigureAwait(false); var user = _userManager.GetUserById(query.UserId); var returnArray = internalResult.Items - .Select(i => _dtoService.GetBaseItemDto(i, new DtoOptions(), user)) + .Select(i => _dtoService.GetBaseItemDto(i, options, user)) .ToArray(); await AddRecordingInfo(returnArray, cancellationToken).ConfigureAwait(false); @@ -1009,6 +1051,8 @@ namespace MediaBrowser.Server.Implementations.LiveTv { cancellationToken.ThrowIfCancellationRequested(); + _logger.Debug("Refreshing guide from {0}", service.Name); + try { var innerProgress = new ActionableProgress<double>(); @@ -1340,21 +1384,45 @@ namespace MediaBrowser.Server.Implementations.LiveTv dto.Id = _tvDtoService.GetInternalProgramId(service.Name, program.ExternalId).ToString("N"); - dto.ChannelId = item.ChannelId; - dto.StartDate = program.StartDate; dto.IsRepeat = program.IsRepeat; dto.EpisodeTitle = program.EpisodeTitle; dto.ChannelType = program.ChannelType; dto.Audio = program.Audio; - dto.IsHD = program.IsHD; - dto.IsMovie = program.IsMovie; - dto.IsSeries = program.IsSeries; - dto.IsSports = program.IsSports; - dto.IsLive = program.IsLive; - dto.IsNews = program.IsNews; - dto.IsKids = program.IsKids; - dto.IsPremiere = program.IsPremiere; + + if (program.IsHD.HasValue && program.IsHD.Value) + { + dto.IsHD = program.IsHD; + } + if (program.IsMovie) + { + dto.IsMovie = program.IsMovie; + } + if (program.IsSeries) + { + dto.IsSeries = program.IsSeries; + } + if (program.IsSports) + { + dto.IsSports = program.IsSports; + } + if (program.IsLive) + { + dto.IsLive = program.IsLive; + } + if (program.IsNews) + { + dto.IsNews = program.IsNews; + } + if (program.IsKids) + { + dto.IsKids = program.IsKids; + } + if (program.IsPremiere) + { + dto.IsPremiere = program.IsPremiere; + } + dto.OriginalAirDate = program.OriginalAirDate; if (channel != null) @@ -1382,8 +1450,6 @@ namespace MediaBrowser.Server.Implementations.LiveTv ? null : _tvDtoService.GetInternalSeriesTimerId(service.Name, info.SeriesTimerId).ToString("N"); - dto.ChannelId = item.ChannelId; - dto.StartDate = info.StartDate; dto.RecordingStatus = info.Status; dto.IsRepeat = info.IsRepeat; @@ -1663,11 +1729,37 @@ namespace MediaBrowser.Server.Implementations.LiveTv .OrderBy(i => i.StartDate) .FirstOrDefault(); - var dto = _tvDtoService.GetChannelInfoDto(channel, currentProgram, user); + var dto = _tvDtoService.GetChannelInfoDto(channel, new DtoOptions(), currentProgram, user); return dto; } + public void AddChannelInfo(BaseItemDto dto, LiveTvChannel channel, DtoOptions options, User user) + { + dto.MediaSources = channel.GetMediaSources(true).ToList(); + + var now = DateTime.UtcNow; + + var programs = _libraryManager.GetItems(new InternalItemsQuery + { + IncludeItemTypes = new[] { typeof(LiveTvProgram).Name }, + ChannelIds = new[] { channel.Id.ToString("N") }, + MaxStartDate = now, + MinEndDate = now, + Limit = 1 + + }).Items.Cast<LiveTvProgram>(); + + var currentProgram = programs + .OrderBy(i => i.StartDate) + .FirstOrDefault(); + + if (currentProgram != null) + { + dto.CurrentProgram = _dtoService.GetBaseItemDto(currentProgram, options, user); + } + } + private async Task<Tuple<SeriesTimerInfo, ILiveTvService>> GetNewTimerDefaultsInternal(CancellationToken cancellationToken, LiveTvProgram program = null) { var service = program != null && !string.IsNullOrWhiteSpace(program.ServiceName) ? @@ -2029,6 +2121,7 @@ namespace MediaBrowser.Server.Implementations.LiveTv info.Version = statusInfo.Version; info.HasUpdateAvailable = statusInfo.HasUpdateAvailable; info.HomePageUrl = service.HomePageUrl; + info.IsVisible = statusInfo.IsVisible; info.Tuners = statusInfo.Tuners.Select(i => { @@ -2081,7 +2174,7 @@ namespace MediaBrowser.Server.Implementations.LiveTv private bool IsLiveTvEnabled(User user) { - return user.Policy.EnableLiveTvAccess && Services.Count > 0; + return user.Policy.EnableLiveTvAccess && (Services.Count > 1 || GetConfiguration().TunerHosts.Count(i => i.IsEnabled) > 0); } public IEnumerable<User> GetEnabledUsers() @@ -2114,16 +2207,142 @@ namespace MediaBrowser.Server.Implementations.LiveTv { var user = string.IsNullOrEmpty(userId) ? null : _userManager.GetUserById(userId); - var folder = await GetInternalLiveTvFolder(userId, cancellationToken).ConfigureAwait(false); + var folder = await GetInternalLiveTvFolder(cancellationToken).ConfigureAwait(false); return _dtoService.GetBaseItemDto(folder, new DtoOptions(), user); } - public async Task<Folder> GetInternalLiveTvFolder(string userId, CancellationToken cancellationToken) + public async Task<Folder> GetInternalLiveTvFolder(CancellationToken cancellationToken) { var name = _localization.GetLocalizedString("ViewTypeLiveTV"); - var user = _userManager.GetUserById(userId); - return await _libraryManager.GetNamedView(user, name, "livetv", "zz_" + name, cancellationToken).ConfigureAwait(false); + return await _libraryManager.GetNamedView(name, "livetv", name, cancellationToken).ConfigureAwait(false); + } + + public async Task<TunerHostInfo> SaveTunerHost(TunerHostInfo info) + { + info = (TunerHostInfo)_jsonSerializer.DeserializeFromString(_jsonSerializer.SerializeToString(info), typeof(TunerHostInfo)); + + var provider = _tunerHosts.FirstOrDefault(i => string.Equals(info.Type, i.Type, StringComparison.OrdinalIgnoreCase)); + + if (provider == null) + { + throw new ResourceNotFoundException(); + } + + await provider.Validate(info).ConfigureAwait(false); + + var config = GetConfiguration(); + + var index = config.TunerHosts.FindIndex(i => string.Equals(i.Id, info.Id, StringComparison.OrdinalIgnoreCase)); + + if (index == -1 || string.IsNullOrWhiteSpace(info.Id)) + { + info.Id = Guid.NewGuid().ToString("N"); + config.TunerHosts.Add(info); + } + else + { + config.TunerHosts[index] = info; + } + + _config.SaveConfiguration("livetv", config); + + _taskManager.CancelIfRunningAndQueue<RefreshChannelsScheduledTask>(); + + return info; + } + + public async Task<ListingsProviderInfo> SaveListingProvider(ListingsProviderInfo info, bool validateLogin, bool validateListings) + { + info = (ListingsProviderInfo)_jsonSerializer.DeserializeFromString(_jsonSerializer.SerializeToString(info), typeof(ListingsProviderInfo)); + + var provider = _listingProviders.FirstOrDefault(i => string.Equals(info.Type, i.Type, StringComparison.OrdinalIgnoreCase)); + + if (provider == null) + { + throw new ResourceNotFoundException(); + } + + await provider.Validate(info, validateLogin, validateListings).ConfigureAwait(false); + + var config = GetConfiguration(); + + var index = config.ListingProviders.FindIndex(i => string.Equals(i.Id, info.Id, StringComparison.OrdinalIgnoreCase)); + + if (index == -1 || string.IsNullOrWhiteSpace(info.Id)) + { + info.Id = Guid.NewGuid().ToString("N"); + config.ListingProviders.Add(info); + } + else + { + config.ListingProviders[index] = info; + } + + _config.SaveConfiguration("livetv", config); + + _taskManager.CancelIfRunningAndQueue<RefreshChannelsScheduledTask>(); + + return info; + } + + public Task<List<NameIdPair>> GetLineups(string providerType, string providerId, string country, string location) + { + var config = GetConfiguration(); + + if (string.IsNullOrWhiteSpace(providerId)) + { + var provider = _listingProviders.FirstOrDefault(i => string.Equals(providerType, i.Type, StringComparison.OrdinalIgnoreCase)); + + if (provider == null) + { + throw new ResourceNotFoundException(); + } + + return provider.GetLineups(null, country, location); + } + else + { + var info = config.ListingProviders.FirstOrDefault(i => string.Equals(i.Id, providerId, StringComparison.OrdinalIgnoreCase)); + + var provider = _listingProviders.FirstOrDefault(i => string.Equals(info.Type, i.Type, StringComparison.OrdinalIgnoreCase)); + + if (provider == null) + { + throw new ResourceNotFoundException(); + } + + return provider.GetLineups(info, country, location); + } + } + + public Task<MBRegistrationRecord> GetRegistrationInfo(string channelId, string programId, string feature) + { + ILiveTvService service; + + if (string.IsNullOrWhiteSpace(programId)) + { + var channel = GetInternalChannel(channelId); + service = GetService(channel); + } + else + { + var program = GetInternalProgram(programId); + service = GetService(program); + } + + var hasRegistration = service as IHasRegistrationInfo; + + if (hasRegistration != null) + { + return hasRegistration.GetRegistrationInfo(feature); + } + + return Task.FromResult(new MBRegistrationRecord + { + IsValid = true, + IsRegistered = true + }); } } } diff --git a/MediaBrowser.Server.Implementations/LiveTv/LiveTvMediaSourceProvider.cs b/MediaBrowser.Server.Implementations/LiveTv/LiveTvMediaSourceProvider.cs index b26732441..ff102b0f7 100644 --- a/MediaBrowser.Server.Implementations/LiveTv/LiveTvMediaSourceProvider.cs +++ b/MediaBrowser.Server.Implementations/LiveTv/LiveTvMediaSourceProvider.cs @@ -51,10 +51,16 @@ namespace MediaBrowser.Server.Implementations.LiveTv return Task.FromResult<IEnumerable<MediaSourceInfo>>(new List<MediaSourceInfo>()); } + // Do not use a pipe here because Roku http requests to the server will fail, without any explicit error message. + private const char StreamIdDelimeter = '_'; + private const string StreamIdDelimeterString = "_"; + private async Task<IEnumerable<MediaSourceInfo>> GetMediaSourcesInternal(ILiveTvItem item, CancellationToken cancellationToken) { IEnumerable<MediaSourceInfo> sources; + var forceRequireOpening = false; + try { if (item is ILiveTvRecording) @@ -74,6 +80,8 @@ namespace MediaBrowser.Server.Implementations.LiveTv sources = _mediaSourceManager.GetStaticMediaSources(hasMediaSources, false) .ToList(); + + forceRequireOpening = true; } var list = sources.ToList(); @@ -82,14 +90,21 @@ namespace MediaBrowser.Server.Implementations.LiveTv foreach (var source in list) { source.Type = MediaSourceType.Default; - source.RequiresOpening = true; source.BufferMs = source.BufferMs ?? 1500; - var openKeys = new List<string>(); - openKeys.Add(item.GetType().Name); - openKeys.Add(item.Id.ToString("N")); - openKeys.Add(source.Id ?? string.Empty); - source.OpenToken = string.Join("|", openKeys.ToArray()); + if (source.RequiresOpening || forceRequireOpening) + { + source.RequiresOpening = true; + } + + if (source.RequiresOpening) + { + var openKeys = new List<string>(); + openKeys.Add(item.GetType().Name); + openKeys.Add(item.Id.ToString("N")); + openKeys.Add(source.Id ?? string.Empty); + source.OpenToken = string.Join(StreamIdDelimeterString, openKeys.ToArray()); + } // Dummy this up so that direct play checks can still run if (string.IsNullOrEmpty(source.Path) && source.Protocol == MediaProtocol.Http) @@ -108,7 +123,7 @@ namespace MediaBrowser.Server.Implementations.LiveTv MediaSourceInfo stream; const bool isAudio = false; - var keys = openToken.Split(new[] { '|' }, 3); + var keys = openToken.Split(new[] { StreamIdDelimeter }, 3); var mediaSourceId = keys.Length >= 3 ? keys[2] : null; if (string.Equals(keys[0], typeof(LiveTvChannel).Name, StringComparison.OrdinalIgnoreCase)) diff --git a/MediaBrowser.Server.Implementations/LiveTv/RefreshChannelsScheduledTask.cs b/MediaBrowser.Server.Implementations/LiveTv/RefreshChannelsScheduledTask.cs index de2351434..d8d91c2f9 100644 --- a/MediaBrowser.Server.Implementations/LiveTv/RefreshChannelsScheduledTask.cs +++ b/MediaBrowser.Server.Implementations/LiveTv/RefreshChannelsScheduledTask.cs @@ -1,8 +1,10 @@ -using MediaBrowser.Common.ScheduledTasks; +using MediaBrowser.Common.Configuration; +using MediaBrowser.Common.ScheduledTasks; using MediaBrowser.Controller.LiveTv; -using MediaBrowser.Model.Tasks; +using MediaBrowser.Model.LiveTv; using System; using System.Collections.Generic; +using System.Linq; using System.Threading.Tasks; namespace MediaBrowser.Server.Implementations.LiveTv @@ -10,10 +12,12 @@ namespace MediaBrowser.Server.Implementations.LiveTv class RefreshChannelsScheduledTask : IScheduledTask, IConfigurableScheduledTask, IHasKey { private readonly ILiveTvManager _liveTvManager; + private readonly IConfigurationManager _config; - public RefreshChannelsScheduledTask(ILiveTvManager liveTvManager) + public RefreshChannelsScheduledTask(ILiveTvManager liveTvManager, IConfigurationManager config) { _liveTvManager = liveTvManager; + _config = config; } public string Name @@ -42,17 +46,18 @@ namespace MediaBrowser.Server.Implementations.LiveTv { return new ITaskTrigger[] { - new StartupTrigger(), - - new SystemEventTrigger{ SystemEvent = SystemEvent.WakeFromSleep}, - - new IntervalTrigger{ Interval = TimeSpan.FromHours(4)} + new IntervalTrigger{ Interval = TimeSpan.FromHours(12)} }; } + private LiveTvOptions GetConfiguration() + { + return _config.GetConfiguration<LiveTvOptions>("livetv"); + } + public bool IsHidden { - get { return _liveTvManager.Services.Count == 0; } + get { return _liveTvManager.Services.Count == 1 && GetConfiguration().TunerHosts.Count(i => i.IsEnabled) == 0; } } public bool IsEnabled diff --git a/MediaBrowser.Server.Implementations/LiveTv/TunerHosts/BaseTunerHost.cs b/MediaBrowser.Server.Implementations/LiveTv/TunerHosts/BaseTunerHost.cs new file mode 100644 index 000000000..909e2bba5 --- /dev/null +++ b/MediaBrowser.Server.Implementations/LiveTv/TunerHosts/BaseTunerHost.cs @@ -0,0 +1,218 @@ +using MediaBrowser.Common.Configuration; +using MediaBrowser.Controller.LiveTv; +using MediaBrowser.Model.Dto; +using MediaBrowser.Model.LiveTv; +using MediaBrowser.Model.Logging; +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +namespace MediaBrowser.Server.Implementations.LiveTv.TunerHosts +{ + public abstract class BaseTunerHost + { + protected readonly IConfigurationManager Config; + protected readonly ILogger Logger; + + private readonly ConcurrentDictionary<string, ChannelCache> _channelCache = + new ConcurrentDictionary<string, ChannelCache>(StringComparer.OrdinalIgnoreCase); + + public BaseTunerHost(IConfigurationManager config, ILogger logger) + { + Config = config; + Logger = logger; + } + + protected abstract Task<IEnumerable<ChannelInfo>> GetChannelsInternal(TunerHostInfo tuner, CancellationToken cancellationToken); + public abstract string Type { get; } + + public async Task<IEnumerable<ChannelInfo>> GetChannels(TunerHostInfo tuner, bool enableCache, CancellationToken cancellationToken) + { + ChannelCache cache = null; + var key = tuner.Id; + + if (enableCache && !string.IsNullOrWhiteSpace(key) && _channelCache.TryGetValue(key, out cache)) + { + if ((DateTime.UtcNow - cache.Date) < TimeSpan.FromMinutes(60)) + { + return cache.Channels.ToList(); + } + } + + var result = await GetChannelsInternal(tuner, cancellationToken).ConfigureAwait(false); + var list = result.ToList(); + + if (!string.IsNullOrWhiteSpace(key)) + { + cache = cache ?? new ChannelCache(); + cache.Date = DateTime.UtcNow; + cache.Channels = list; + _channelCache.AddOrUpdate(key, cache, (k, v) => cache); + } + + return list; + } + + private List<TunerHostInfo> GetTunerHosts() + { + return GetConfiguration().TunerHosts + .Where(i => i.IsEnabled && string.Equals(i.Type, Type, StringComparison.OrdinalIgnoreCase)) + .ToList(); + } + + public async Task<IEnumerable<ChannelInfo>> GetChannels(CancellationToken cancellationToken) + { + var list = new List<ChannelInfo>(); + + var hosts = GetTunerHosts(); + + foreach (var host in hosts) + { + try + { + var channels = await GetChannels(host, true, cancellationToken).ConfigureAwait(false); + var newChannels = channels.Where(i => !list.Any(l => string.Equals(i.Id, l.Id, StringComparison.OrdinalIgnoreCase))).ToList(); + + list.AddRange(newChannels); + } + catch (Exception ex) + { + Logger.ErrorException("Error getting channel list", ex); + } + } + + return list; + } + + protected abstract Task<List<MediaSourceInfo>> GetChannelStreamMediaSources(TunerHostInfo tuner, string channelId, CancellationToken cancellationToken); + + public async Task<List<MediaSourceInfo>> GetChannelStreamMediaSources(string channelId, CancellationToken cancellationToken) + { + if (IsValidChannelId(channelId)) + { + var hosts = GetTunerHosts(); + + var hostsWithChannel = new List<TunerHostInfo>(); + + foreach (var host in hosts) + { + var channels = await GetChannels(host, true, cancellationToken).ConfigureAwait(false); + + if (channels.Any(i => string.Equals(i.Id, channelId, StringComparison.OrdinalIgnoreCase))) + { + hostsWithChannel.Add(host); + } + } + + foreach (var host in hostsWithChannel) + { + // Check to make sure the tuner is available + // If there's only one tuner, don't bother with the check and just let the tuner be the one to throw an error + if (hostsWithChannel.Count > 1 && !await IsAvailable(host, channelId, cancellationToken).ConfigureAwait(false)) + { + Logger.Error("Tuner is not currently available"); + continue; + } + + var mediaSources = await GetChannelStreamMediaSources(host, channelId, cancellationToken).ConfigureAwait(false); + + // Prefix the id with the host Id so that we can easily find it + foreach (var mediaSource in mediaSources) + { + mediaSource.Id = host.Id + mediaSource.Id; + } + + return mediaSources; + } + } + + return new List<MediaSourceInfo>(); + } + + protected abstract Task<MediaSourceInfo> GetChannelStream(TunerHostInfo tuner, string channelId, string streamId, CancellationToken cancellationToken); + + public async Task<MediaSourceInfo> GetChannelStream(string channelId, string streamId, CancellationToken cancellationToken) + { + if (IsValidChannelId(channelId)) + { + var hosts = GetTunerHosts(); + + var hostsWithChannel = new List<TunerHostInfo>(); + + foreach (var host in hosts) + { + if (string.IsNullOrWhiteSpace(streamId)) + { + var channels = await GetChannels(host, true, cancellationToken).ConfigureAwait(false); + + if (channels.Any(i => string.Equals(i.Id, channelId, StringComparison.OrdinalIgnoreCase))) + { + hostsWithChannel.Add(host); + } + } + else if (streamId.StartsWith(host.Id, StringComparison.OrdinalIgnoreCase)) + { + hostsWithChannel = new List<TunerHostInfo> { host }; + streamId = streamId.Substring(host.Id.Length); + break; + } + } + + foreach (var host in hostsWithChannel) + { + // Check to make sure the tuner is available + // If there's only one tuner, don't bother with the check and just let the tuner be the one to throw an error + // If a streamId is specified then availibility has already been checked in GetChannelStreamMediaSources + if (string.IsNullOrWhiteSpace(streamId) && hostsWithChannel.Count > 1) + { + if (!await IsAvailable(host, channelId, cancellationToken).ConfigureAwait(false)) + { + Logger.Error("Tuner is not currently available"); + continue; + } + } + + var stream = await GetChannelStream(host, channelId, streamId, cancellationToken).ConfigureAwait(false); + + if (stream != null) + { + return stream; + } + } + } + + throw new LiveTvConflictException(); + } + + protected async Task<bool> IsAvailable(TunerHostInfo tuner, string channelId, CancellationToken cancellationToken) + { + try + { + return await IsAvailableInternal(tuner, channelId, cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) + { + Logger.ErrorException("Error checking tuner availability", ex); + return false; + } + } + + protected abstract Task<bool> IsAvailableInternal(TunerHostInfo tuner, string channelId, CancellationToken cancellationToken); + + protected abstract bool IsValidChannelId(string channelId); + + protected LiveTvOptions GetConfiguration() + { + return Config.GetConfiguration<LiveTvOptions>("livetv"); + } + + private class ChannelCache + { + public DateTime Date; + public List<ChannelInfo> Channels; + } + } +} diff --git a/MediaBrowser.Server.Implementations/LiveTv/TunerHosts/HdHomerun/HdHomerunDiscovery.cs b/MediaBrowser.Server.Implementations/LiveTv/TunerHosts/HdHomerun/HdHomerunDiscovery.cs new file mode 100644 index 000000000..c74220137 --- /dev/null +++ b/MediaBrowser.Server.Implementations/LiveTv/TunerHosts/HdHomerun/HdHomerunDiscovery.cs @@ -0,0 +1,122 @@ +using MediaBrowser.Common.Configuration; +using MediaBrowser.Controller.Configuration; +using MediaBrowser.Controller.Dlna; +using MediaBrowser.Controller.LiveTv; +using MediaBrowser.Controller.Plugins; +using MediaBrowser.Model.Extensions; +using MediaBrowser.Model.LiveTv; +using MediaBrowser.Model.Logging; +using System; +using System.Linq; +using System.Threading; + +namespace MediaBrowser.Server.Implementations.LiveTv.TunerHosts.HdHomerun +{ + public class HdHomerunDiscovery : IServerEntryPoint + { + private readonly IDeviceDiscovery _deviceDiscovery; + private readonly IServerConfigurationManager _config; + private readonly ILogger _logger; + private readonly ILiveTvManager _liveTvManager; + private readonly SemaphoreSlim _semaphore = new SemaphoreSlim(1, 1); + + public HdHomerunDiscovery(IDeviceDiscovery deviceDiscovery, IServerConfigurationManager config, ILogger logger, ILiveTvManager liveTvManager) + { + _deviceDiscovery = deviceDiscovery; + _config = config; + _logger = logger; + _liveTvManager = liveTvManager; + } + + public void Run() + { + _deviceDiscovery.DeviceDiscovered += _deviceDiscovery_DeviceDiscovered; + } + + void _deviceDiscovery_DeviceDiscovered(object sender, SsdpMessageEventArgs e) + { + string server = null; + if (e.Headers.TryGetValue("SERVER", out server) && server.IndexOf("HDHomeRun", StringComparison.OrdinalIgnoreCase) != -1) + { + string location; + if (e.Headers.TryGetValue("Location", out location)) + { + //_logger.Debug("HdHomerun found at {0}", location); + + // Just get the beginning of the url + Uri uri; + if (Uri.TryCreate(location, UriKind.Absolute, out uri)) + { + var apiUrl = location.Replace(uri.LocalPath, String.Empty, StringComparison.OrdinalIgnoreCase) + .TrimEnd('/'); + + //_logger.Debug("HdHomerun api url: {0}", apiUrl); + AddDevice(apiUrl); + } + } + } + } + + private async void AddDevice(string url) + { + await _semaphore.WaitAsync().ConfigureAwait(false); + + try + { + var options = GetConfiguration(); + + if (options.TunerHosts.Any(i => + string.Equals(i.Type, HdHomerunHost.DeviceType, StringComparison.OrdinalIgnoreCase) && + UriEquals(i.Url, url))) + { + return; + } + + // Strip off the port + url = new Uri(url).GetComponents(UriComponents.AbsoluteUri & ~UriComponents.Port, UriFormat.UriEscaped).TrimEnd('/'); + + await _liveTvManager.SaveTunerHost(new TunerHostInfo + { + Type = HdHomerunHost.DeviceType, + Url = url + + }).ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.ErrorException("Error saving device", ex); + } + finally + { + _semaphore.Release(); + } + } + + private bool UriEquals(string savedUri, string location) + { + return string.Equals(NormalizeUrl(location), NormalizeUrl(savedUri), StringComparison.OrdinalIgnoreCase); + } + + private string NormalizeUrl(string url) + { + if (!url.StartsWith("http", StringComparison.OrdinalIgnoreCase)) + { + url = "http://" + url; + } + + url = url.TrimEnd('/'); + + // Strip off the port + return new Uri(url).GetComponents(UriComponents.AbsoluteUri & ~UriComponents.Port, UriFormat.UriEscaped); + } + + private LiveTvOptions GetConfiguration() + { + return _config.GetConfiguration<LiveTvOptions>("livetv"); + } + + public void Dispose() + { + } + } +} diff --git a/MediaBrowser.Server.Implementations/LiveTv/TunerHosts/HdHomerun/HdHomerunHost.cs b/MediaBrowser.Server.Implementations/LiveTv/TunerHosts/HdHomerun/HdHomerunHost.cs new file mode 100644 index 000000000..bccb0db0a --- /dev/null +++ b/MediaBrowser.Server.Implementations/LiveTv/TunerHosts/HdHomerun/HdHomerunHost.cs @@ -0,0 +1,406 @@ +using MediaBrowser.Common.Configuration; +using MediaBrowser.Common.Net; +using MediaBrowser.Controller.LiveTv; +using MediaBrowser.Model.Dto; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.LiveTv; +using MediaBrowser.Model.Logging; +using MediaBrowser.Model.MediaInfo; +using MediaBrowser.Model.Serialization; +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +namespace MediaBrowser.Server.Implementations.LiveTv.TunerHosts.HdHomerun +{ + public class HdHomerunHost : BaseTunerHost, ITunerHost + { + private readonly IHttpClient _httpClient; + private readonly IJsonSerializer _jsonSerializer; + + public HdHomerunHost(IConfigurationManager config, ILogger logger, IHttpClient httpClient, IJsonSerializer jsonSerializer) + : base(config, logger) + { + _httpClient = httpClient; + _jsonSerializer = jsonSerializer; + } + + public string Name + { + get { return "HD Homerun"; } + } + + public override string Type + { + get { return DeviceType; } + } + + public static string DeviceType + { + get { return "hdhomerun"; } + } + + private const string ChannelIdPrefix = "hdhr_"; + + protected override async Task<IEnumerable<ChannelInfo>> GetChannelsInternal(TunerHostInfo info, CancellationToken cancellationToken) + { + var options = new HttpRequestOptions + { + Url = string.Format("{0}/lineup.json", GetApiUrl(info, false)), + CancellationToken = cancellationToken + }; + using (var stream = await _httpClient.Get(options)) + { + var root = _jsonSerializer.DeserializeFromStream<List<Channels>>(stream); + + if (root != null) + { + var result = root.Select(i => new ChannelInfo + { + Name = i.GuideName, + Number = i.GuideNumber.ToString(CultureInfo.InvariantCulture), + Id = ChannelIdPrefix + i.GuideNumber.ToString(CultureInfo.InvariantCulture), + IsFavorite = i.Favorite + + }); + + if (info.ImportFavoritesOnly) + { + result = result.Where(i => (i.IsFavorite ?? true)).ToList(); + } + + return result; + } + return new List<ChannelInfo>(); + } + } + + private async Task<string> GetModelInfo(TunerHostInfo info, CancellationToken cancellationToken) + { + string model = null; + + using (var stream = await _httpClient.Get(new HttpRequestOptions() + { + Url = string.Format("{0}/", GetApiUrl(info, false)), + CancellationToken = cancellationToken, + CacheLength = TimeSpan.FromDays(1), + CacheMode = CacheMode.Unconditional + })) + { + using (var sr = new StreamReader(stream, System.Text.Encoding.UTF8)) + { + while (!sr.EndOfStream) + { + string line = StripXML(sr.ReadLine()); + if (line.StartsWith("Model:")) { model = line.Replace("Model: ", ""); } + //if (line.StartsWith("Device ID:")) { deviceID = line.Replace("Device ID: ", ""); } + //if (line.StartsWith("Firmware:")) { firmware = line.Replace("Firmware: ", ""); } + } + } + } + + return null; + } + + public async Task<List<LiveTvTunerInfo>> GetTunerInfos(TunerHostInfo info, CancellationToken cancellationToken) + { + var model = await GetModelInfo(info, cancellationToken).ConfigureAwait(false); + + using (var stream = await _httpClient.Get(new HttpRequestOptions() + { + Url = string.Format("{0}/tuners.html", GetApiUrl(info, false)), + CancellationToken = cancellationToken + })) + { + var tuners = new List<LiveTvTunerInfo>(); + using (var sr = new StreamReader(stream, System.Text.Encoding.UTF8)) + { + while (!sr.EndOfStream) + { + string line = StripXML(sr.ReadLine()); + if (line.Contains("Channel")) + { + LiveTvTunerStatus status; + var index = line.IndexOf("Channel", StringComparison.OrdinalIgnoreCase); + var name = line.Substring(0, index - 1); + var currentChannel = line.Substring(index + 7); + if (currentChannel != "none") { status = LiveTvTunerStatus.LiveTv; } else { status = LiveTvTunerStatus.Available; } + tuners.Add(new LiveTvTunerInfo + { + Name = name, + SourceType = string.IsNullOrWhiteSpace(model) ? Name : model, + ProgramName = currentChannel, + Status = status + }); + } + } + } + return tuners; + } + } + + public async Task<List<LiveTvTunerInfo>> GetTunerInfos(CancellationToken cancellationToken) + { + var list = new List<LiveTvTunerInfo>(); + + foreach (var host in GetConfiguration().TunerHosts + .Where(i => i.IsEnabled && string.Equals(i.Type, Type, StringComparison.OrdinalIgnoreCase))) + { + try + { + list.AddRange(await GetTunerInfos(host, cancellationToken).ConfigureAwait(false)); + } + catch (Exception ex) + { + Logger.ErrorException("Error getting tuner info", ex); + } + } + + return list; + } + + private string GetApiUrl(TunerHostInfo info, bool isPlayback) + { + var url = info.Url; + + if (string.IsNullOrWhiteSpace(url)) + { + throw new ArgumentException("Invalid tuner info"); + } + + if (!url.StartsWith("http", StringComparison.OrdinalIgnoreCase)) + { + url = "http://" + url; + } + + var uri = new Uri(url); + + if (isPlayback) + { + var builder = new UriBuilder(uri); + builder.Port = 5004; + uri = builder.Uri; + } + + return uri.AbsoluteUri.TrimEnd('/'); + } + + private static string StripXML(string source) + { + char[] buffer = new char[source.Length]; + int bufferIndex = 0; + bool inside = false; + + for (int i = 0; i < source.Length; i++) + { + char let = source[i]; + if (let == '<') + { + inside = true; + continue; + } + if (let == '>') + { + inside = false; + continue; + } + if (!inside) + { + buffer[bufferIndex] = let; + bufferIndex++; + } + } + return new string(buffer, 0, bufferIndex); + } + + private class Channels + { + public string GuideNumber { get; set; } + public string GuideName { get; set; } + public string URL { get; set; } + public bool Favorite { get; set; } + public bool DRM { get; set; } + } + + private MediaSourceInfo GetMediaSource(TunerHostInfo info, string channelId, string profile) + { + int? width = null; + int? height = null; + bool isInterlaced = true; + var videoCodec = "mpeg2video"; + int? videoBitrate = null; + + if (string.Equals(profile, "mobile", StringComparison.OrdinalIgnoreCase)) + { + width = 1280; + height = 720; + isInterlaced = false; + videoCodec = "h264"; + videoBitrate = 2000000; + } + else if (string.Equals(profile, "heavy", StringComparison.OrdinalIgnoreCase)) + { + width = 1920; + height = 1080; + isInterlaced = false; + videoCodec = "h264"; + videoBitrate = 8000000; + } + else if (string.Equals(profile, "internet720", StringComparison.OrdinalIgnoreCase)) + { + width = 1280; + height = 720; + isInterlaced = false; + videoCodec = "h264"; + videoBitrate = 5000000; + } + else if (string.Equals(profile, "internet540", StringComparison.OrdinalIgnoreCase)) + { + width = 1280; + height = 720; + isInterlaced = false; + videoCodec = "h264"; + videoBitrate = 2500000; + } + else if (string.Equals(profile, "internet480", StringComparison.OrdinalIgnoreCase)) + { + width = 848; + height = 480; + isInterlaced = false; + videoCodec = "h264"; + videoBitrate = 2000000; + } + else if (string.Equals(profile, "internet360", StringComparison.OrdinalIgnoreCase)) + { + width = 640; + height = 360; + isInterlaced = false; + videoCodec = "h264"; + videoBitrate = 1500000; + } + else if (string.Equals(profile, "internet240", StringComparison.OrdinalIgnoreCase)) + { + width = 432; + height = 240; + isInterlaced = false; + videoCodec = "h264"; + videoBitrate = 1000000; + } + + var url = GetApiUrl(info, true) + "/auto/v" + channelId; + + if (!string.IsNullOrWhiteSpace(profile) && !string.Equals(profile, "native", StringComparison.OrdinalIgnoreCase)) + { + url += "?transcode=" + profile; + } + + var mediaSource = new MediaSourceInfo + { + Path = url, + Protocol = MediaProtocol.Http, + MediaStreams = new List<MediaStream> + { + new MediaStream + { + Type = MediaStreamType.Video, + // Set the index to -1 because we don't know the exact index of the video stream within the container + Index = -1, + IsInterlaced = isInterlaced, + Codec = videoCodec, + Width = width, + Height = height, + BitRate = videoBitrate + + }, + new MediaStream + { + Type = MediaStreamType.Audio, + // Set the index to -1 because we don't know the exact index of the audio stream within the container + Index = -1, + Codec = "ac3", + BitRate = 128000 + } + }, + RequiresOpening = false, + RequiresClosing = false, + BufferMs = 1000, + Container = "ts", + Id = profile + }; + + return mediaSource; + } + + protected override async Task<List<MediaSourceInfo>> GetChannelStreamMediaSources(TunerHostInfo info, string channelId, CancellationToken cancellationToken) + { + var list = new List<MediaSourceInfo>(); + + if (!channelId.StartsWith(ChannelIdPrefix, StringComparison.OrdinalIgnoreCase)) + { + return list; + } + channelId = channelId.Substring(ChannelIdPrefix.Length); + + list.Add(GetMediaSource(info, channelId, "native")); + + try + { + string model = await GetModelInfo(info, cancellationToken).ConfigureAwait(false); + model = model ?? string.Empty; + + if (model.IndexOf("hdtc", StringComparison.OrdinalIgnoreCase) != -1) + { + list.Insert(0, GetMediaSource(info, channelId, "heavy")); + + list.Add(GetMediaSource(info, channelId, "internet480")); + list.Add(GetMediaSource(info, channelId, "internet360")); + list.Add(GetMediaSource(info, channelId, "internet240")); + list.Add(GetMediaSource(info, channelId, "mobile")); + } + } + catch (Exception ex) + { + + } + + return list; + } + + protected override bool IsValidChannelId(string channelId) + { + return channelId.StartsWith(ChannelIdPrefix, StringComparison.OrdinalIgnoreCase); + } + + protected override async Task<MediaSourceInfo> GetChannelStream(TunerHostInfo info, string channelId, string streamId, CancellationToken cancellationToken) + { + Logger.Debug("GetChannelStream: channel id: {0}. stream id: {1}", channelId, streamId ?? string.Empty); + + if (!channelId.StartsWith(ChannelIdPrefix, StringComparison.OrdinalIgnoreCase)) + { + return null; + } + channelId = channelId.Substring(ChannelIdPrefix.Length); + + return GetMediaSource(info, channelId, streamId); + } + + public async Task Validate(TunerHostInfo info) + { + if (info.IsEnabled) + { + await GetChannels(info, false, CancellationToken.None).ConfigureAwait(false); + } + } + + protected override async Task<bool> IsAvailableInternal(TunerHostInfo tuner, string channelId, CancellationToken cancellationToken) + { + var info = await GetTunerInfos(tuner, cancellationToken).ConfigureAwait(false); + + return info.Any(i => i.Status == LiveTvTunerStatus.Available || string.Equals(i.ChannelId, channelId, StringComparison.OrdinalIgnoreCase)); + } + } +} diff --git a/MediaBrowser.Server.Implementations/LiveTv/TunerHosts/M3UTunerHost.cs b/MediaBrowser.Server.Implementations/LiveTv/TunerHosts/M3UTunerHost.cs new file mode 100644 index 000000000..3783e4b08 --- /dev/null +++ b/MediaBrowser.Server.Implementations/LiveTv/TunerHosts/M3UTunerHost.cs @@ -0,0 +1,199 @@ +using MediaBrowser.Common.Configuration; +using MediaBrowser.Common.Extensions; +using MediaBrowser.Controller.LiveTv; +using MediaBrowser.Model.Dto; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.LiveTv; +using MediaBrowser.Model.Logging; +using MediaBrowser.Model.MediaInfo; +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +namespace MediaBrowser.Server.Implementations.LiveTv.TunerHosts +{ + public class M3UTunerHost : BaseTunerHost, ITunerHost + { + public M3UTunerHost(IConfigurationManager config, ILogger logger) + : base(config, logger) + { + } + + public override string Type + { + get { return "m3u"; } + } + + public string Name + { + get { return "M3U Tuner"; } + } + + private const string ChannelIdPrefix = "m3u_"; + + protected override async Task<IEnumerable<ChannelInfo>> GetChannelsInternal(TunerHostInfo info, CancellationToken cancellationToken) + { + var url = info.Url; + var urlHash = url.GetMD5().ToString("N"); + + string line; + // Read the file and display it line by line. + var file = new StreamReader(url); + var channels = new List<M3UChannel>(); + + string channnelName = null; + string channelNumber = null; + + while ((line = file.ReadLine()) != null) + { + line = line.Trim(); + if (string.IsNullOrWhiteSpace(line)) + { + continue; + } + + if (line.StartsWith("#EXTM3U", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + if (line.StartsWith("#EXTINF:", StringComparison.OrdinalIgnoreCase)) + { + var parts = line.Split(new[] { ':' }, 2).Last().Split(new[] { ',' }, 2); + channelNumber = parts[0]; + channnelName = parts[1]; + } + else if (!string.IsNullOrWhiteSpace(channelNumber)) + { + channels.Add(new M3UChannel + { + Name = channnelName, + Number = channelNumber, + Id = ChannelIdPrefix + urlHash + channelNumber, + Path = line + }); + + channelNumber = null; + channnelName = null; + } + } + file.Close(); + return channels; + } + + public Task<List<LiveTvTunerInfo>> GetTunerInfos(CancellationToken cancellationToken) + { + var list = GetConfiguration().TunerHosts + .Where(i => i.IsEnabled && string.Equals(i.Type, Type, StringComparison.OrdinalIgnoreCase)) + .Select(i => new LiveTvTunerInfo() + { + Name = Name, + SourceType = Type, + Status = LiveTvTunerStatus.Available, + Id = i.Url.GetMD5().ToString("N"), + Url = i.Url + }) + .ToList(); + + return Task.FromResult(list); + } + + protected override async Task<MediaSourceInfo> GetChannelStream(TunerHostInfo info, string channelId, string streamId, CancellationToken cancellationToken) + { + var sources = await GetChannelStreamMediaSources(info, channelId, cancellationToken).ConfigureAwait(false); + + return sources.First(); + } + + class M3UChannel : ChannelInfo + { + public string Path { get; set; } + + public M3UChannel() + { + } + } + + public async Task Validate(TunerHostInfo info) + { + if (!File.Exists(info.Url)) + { + throw new FileNotFoundException(); + } + } + + protected override bool IsValidChannelId(string channelId) + { + return channelId.StartsWith(ChannelIdPrefix, StringComparison.OrdinalIgnoreCase); + } + + protected override async Task<List<MediaSourceInfo>> GetChannelStreamMediaSources(TunerHostInfo info, string channelId, CancellationToken cancellationToken) + { + var urlHash = info.Url.GetMD5().ToString("N"); + var prefix = ChannelIdPrefix + urlHash; + if (!channelId.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + { + return null; + } + + //channelId = channelId.Substring(prefix.Length); + + var channels = await GetChannels(info, true, cancellationToken).ConfigureAwait(false); + var m3uchannels = channels.Cast<M3UChannel>(); + var channel = m3uchannels.FirstOrDefault(c => string.Equals(c.Id, channelId, StringComparison.OrdinalIgnoreCase)); + if (channel != null) + { + var path = channel.Path; + MediaProtocol protocol = MediaProtocol.File; + if (path.StartsWith("http", StringComparison.OrdinalIgnoreCase)) + { + protocol = MediaProtocol.Http; + } + else if (path.StartsWith("rtmp", StringComparison.OrdinalIgnoreCase)) + { + protocol = MediaProtocol.Rtmp; + } + else if (path.StartsWith("rtsp", StringComparison.OrdinalIgnoreCase)) + { + protocol = MediaProtocol.Rtsp; + } + + var mediaSource = new MediaSourceInfo + { + Path = channel.Path, + Protocol = protocol, + MediaStreams = new List<MediaStream> + { + new MediaStream + { + Type = MediaStreamType.Video, + // Set the index to -1 because we don't know the exact index of the video stream within the container + Index = -1, + IsInterlaced = true + }, + new MediaStream + { + Type = MediaStreamType.Audio, + // Set the index to -1 because we don't know the exact index of the audio stream within the container + Index = -1 + + } + }, + RequiresOpening = false, + RequiresClosing = false + }; + + return new List<MediaSourceInfo> { mediaSource }; + } + return new List<MediaSourceInfo> { }; + } + + protected override Task<bool> IsAvailableInternal(TunerHostInfo tuner, string channelId, CancellationToken cancellationToken) + { + return Task.FromResult(true); + } + } +} |
