diff --git a/MediaBrowser.Common.Implementations/HttpClientManager/HttpClientManager.cs b/MediaBrowser.Common.Implementations/HttpClientManager/HttpClientManager.cs index ae2148f08..7ca6f43d6 100644 --- a/MediaBrowser.Common.Implementations/HttpClientManager/HttpClientManager.cs +++ b/MediaBrowser.Common.Implementations/HttpClientManager/HttpClientManager.cs @@ -432,7 +432,7 @@ namespace MediaBrowser.Common.Implementations.HttpClientManager var httpResponse = (HttpWebResponse)response; - EnsureSuccessStatusCode(httpResponse, options); + EnsureSuccessStatusCode(client, httpResponse, options); options.CancellationToken.ThrowIfCancellationRequested(); @@ -443,7 +443,7 @@ namespace MediaBrowser.Common.Implementations.HttpClientManager { var httpResponse = (HttpWebResponse)response; - EnsureSuccessStatusCode(httpResponse, options); + EnsureSuccessStatusCode(client, httpResponse, options); options.CancellationToken.ThrowIfCancellationRequested(); @@ -629,7 +629,8 @@ namespace MediaBrowser.Common.Implementations.HttpClientManager { var httpResponse = (HttpWebResponse)response; - EnsureSuccessStatusCode(httpResponse, options); + var client = GetHttpClient(GetHostFromUrl(options.Url), options.EnableHttpCompression); + EnsureSuccessStatusCode(client, httpResponse, options); options.CancellationToken.ThrowIfCancellationRequested(); @@ -803,13 +804,20 @@ namespace MediaBrowser.Common.Implementations.HttpClientManager return exception; } - private void EnsureSuccessStatusCode(HttpWebResponse response, HttpRequestOptions options) + private void EnsureSuccessStatusCode(HttpClientInfo client, HttpWebResponse response, HttpRequestOptions options) { var statusCode = response.StatusCode; + var isSuccessful = statusCode >= HttpStatusCode.OK && statusCode <= (HttpStatusCode)299; if (!isSuccessful) { + if ((int) statusCode == 429) + { + client.LastTimeout = DateTime.UtcNow; + } + + if (statusCode == HttpStatusCode.RequestEntityTooLarge) if (options.LogErrorResponseBody) { try diff --git a/MediaBrowser.Controller/LiveTv/ChannelInfo.cs b/MediaBrowser.Controller/LiveTv/ChannelInfo.cs index cdc9c76c8..32b8abdc5 100644 --- a/MediaBrowser.Controller/LiveTv/ChannelInfo.cs +++ b/MediaBrowser.Controller/LiveTv/ChannelInfo.cs @@ -48,6 +48,10 @@ namespace MediaBrowser.Controller.LiveTv /// /// null if [has image] contains no value, true if [has image]; otherwise, false. public bool? HasImage { get; set; } - + /// + /// Gets or sets a value indicating whether this instance is favorite. + /// + /// null if [is favorite] contains no value, true if [is favorite]; otherwise, false. + public bool? IsFavorite { get; set; } } } diff --git a/MediaBrowser.Controller/LiveTv/IListingsProvider.cs b/MediaBrowser.Controller/LiveTv/IListingsProvider.cs new file mode 100644 index 000000000..2cef455e8 --- /dev/null +++ b/MediaBrowser.Controller/LiveTv/IListingsProvider.cs @@ -0,0 +1,7 @@ + +namespace MediaBrowser.Controller.LiveTv +{ + public interface IListingsProvider + { + } +} diff --git a/MediaBrowser.Controller/LiveTv/ITunerHost.cs b/MediaBrowser.Controller/LiveTv/ITunerHost.cs new file mode 100644 index 000000000..2fa538c60 --- /dev/null +++ b/MediaBrowser.Controller/LiveTv/ITunerHost.cs @@ -0,0 +1,50 @@ +using MediaBrowser.Model.Dto; +using MediaBrowser.Model.LiveTv; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace MediaBrowser.Controller.LiveTv +{ + public interface ITunerHost + { + /// + /// Gets the name. + /// + /// The name. + string Name { get; } + /// + /// Gets the type. + /// + /// The type. + string Type { get; } + /// + /// Gets the tuner hosts. + /// + /// List<TunerHostInfo>. + List GetTunerHosts(); + /// + /// Gets the channels. + /// + /// The information. + /// The cancellation token. + /// Task<IEnumerable<ChannelInfo>>. + Task> GetChannels(TunerHostInfo info, CancellationToken cancellationToken); + /// + /// Gets the tuner infos. + /// + /// The information. + /// The cancellation token. + /// Task<List<LiveTvTunerInfo>>. + Task> GetTunerInfos(TunerHostInfo info, CancellationToken cancellationToken); + /// + /// Gets the channel stream. + /// + /// The information. + /// The channel identifier. + /// The stream identifier. + /// The cancellation token. + /// Task<MediaSourceInfo>. + Task GetChannelStream(TunerHostInfo info, string channelId, string streamId, CancellationToken cancellationToken); + } +} diff --git a/MediaBrowser.Controller/MediaBrowser.Controller.csproj b/MediaBrowser.Controller/MediaBrowser.Controller.csproj index fcde6d8c0..603f756af 100644 --- a/MediaBrowser.Controller/MediaBrowser.Controller.csproj +++ b/MediaBrowser.Controller/MediaBrowser.Controller.csproj @@ -198,7 +198,9 @@ + + diff --git a/MediaBrowser.Model/LiveTv/LiveTvOptions.cs b/MediaBrowser.Model/LiveTv/LiveTvOptions.cs index c6f6ed84c..303b12af7 100644 --- a/MediaBrowser.Model/LiveTv/LiveTvOptions.cs +++ b/MediaBrowser.Model/LiveTv/LiveTvOptions.cs @@ -1,13 +1,24 @@ -namespace MediaBrowser.Model.LiveTv +using System.Collections.Generic; + +namespace MediaBrowser.Model.LiveTv { public class LiveTvOptions { public int? GuideDays { get; set; } public bool EnableMovieProviders { get; set; } + public List TunerHosts { get; set; } + public string RecordingPath { get; set; } public LiveTvOptions() { EnableMovieProviders = true; + TunerHosts = new List(); } } + + public class TunerHostInfo + { + public string Url { get; set; } + public string Type { get; set; } + } } \ No newline at end of file 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 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..d0a271260 --- /dev/null +++ b/MediaBrowser.Server.Implementations/LiveTv/EmbyTV/EmbyTV.cs @@ -0,0 +1,507 @@ +using MediaBrowser.Common; +using MediaBrowser.Common.Configuration; +using MediaBrowser.Common.Net; +using MediaBrowser.Controller.Drawing; +using MediaBrowser.Controller.LiveTv; +using MediaBrowser.Model.Dto; +using MediaBrowser.Model.Events; +using MediaBrowser.Model.LiveTv; +using MediaBrowser.Model.Logging; +using MediaBrowser.Model.Serialization; +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +namespace MediaBrowser.Server.Implementations.LiveTv.EmbyTV +{ + public class EmbyTV : ILiveTvService, IDisposable + { + private readonly ILogger _logger; + private readonly IHttpClient _httpClient; + private readonly IConfigurationManager _config; + private readonly IJsonSerializer _jsonSerializer; + + private readonly List _tunerHosts = new List(); + private readonly ItemDataProvider _recordingProvider; + private readonly ItemDataProvider _seriesTimerProvider; + private readonly TimerManager _timerProvider; + + public EmbyTV(IApplicationHost appHost, ILogger logger, IJsonSerializer jsonSerializer, IHttpClient httpClient, IConfigurationManager config) + { + _logger = logger; + _httpClient = httpClient; + _config = config; + _jsonSerializer = jsonSerializer; + _tunerHosts.AddRange(appHost.GetExports()); + + _recordingProvider = new ItemDataProvider(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 event EventHandler DataSourceChanged; + + public event EventHandler RecordingStatusChanged; + + private readonly ConcurrentDictionary _activeRecordings = + new ConcurrentDictionary(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 GetStatusInfoAsync(CancellationToken cancellationToken) + { + var status = new LiveTvServiceStatusInfo(); + var list = new List(); + + foreach (var host in _tunerHosts) + { + foreach (var hostInstance in host.GetTunerHosts()) + { + try + { + var tuners = await host.GetTunerInfos(hostInstance, cancellationToken).ConfigureAwait(false); + + list.AddRange(tuners); + } + catch (Exception ex) + { + _logger.ErrorException("Error getting tuners", ex); + } + } + } + + status.Tuners = list; + status.Status = LiveTvServiceStatus.Ok; + return status; + } + + public async Task> GetChannelsAsync(CancellationToken cancellationToken) + { + var list = new List(); + + foreach (var host in _tunerHosts) + { + foreach (var hostInstance in host.GetTunerHosts()) + { + try + { + var channels = await host.GetChannels(hostInstance, cancellationToken).ConfigureAwait(false); + + list.AddRange(channels); + } + catch (Exception ex) + { + _logger.ErrorException("Error getting channels", ex); + } + } + } + + return list; + } + + public Task CancelSeriesTimerAsync(string timerId, CancellationToken cancellationToken) + { + var remove = _seriesTimerProvider.GetAll().SingleOrDefault(r => r.Id == timerId); + if (remove != null) + { + _seriesTimerProvider.Delete(remove); + } + return Task.FromResult(true); + } + + private void CancelTimerInternal(string timerId) + { + var remove = _timerProvider.GetAll().SingleOrDefault(r => r.Id == timerId); + 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 Task DeleteRecordingAsync(string recordingId, CancellationToken cancellationToken) + { + var remove = _recordingProvider.GetAll().FirstOrDefault(i => string.Equals(i.Id, recordingId, StringComparison.OrdinalIgnoreCase)); + if (remove != null) + { + try + { + File.Delete(remove.Path); + } + catch (DirectoryNotFoundException) + { + + } + catch (FileNotFoundException) + { + + } + _recordingProvider.Delete(remove); + } + return Task.FromResult(true); + } + + public Task CreateTimerAsync(TimerInfo info, CancellationToken cancellationToken) + { + info.Id = Guid.NewGuid().ToString("N"); + _timerProvider.Add(info); + return Task.FromResult(0); + } + + public Task CreateSeriesTimerAsync(SeriesTimerInfo info, CancellationToken cancellationToken) + { + info.Id = info.ProgramId.Substring(0, 10); + + UpdateTimersForSeriesTimer(info); + _seriesTimerProvider.Add(info); + return Task.FromResult(true); + } + + public Task UpdateSeriesTimerAsync(SeriesTimerInfo info, CancellationToken cancellationToken) + { + _seriesTimerProvider.Update(info); + UpdateTimersForSeriesTimer(info); + return Task.FromResult(true); + } + + public Task UpdateTimerAsync(TimerInfo info, CancellationToken cancellationToken) + { + _timerProvider.Update(info); + return Task.FromResult(true); + } + + public Task GetChannelImageAsync(string channelId, CancellationToken cancellationToken) + { + throw new NotImplementedException(); + } + + public Task GetRecordingImageAsync(string recordingId, CancellationToken cancellationToken) + { + throw new NotImplementedException(); + } + + public Task GetProgramImageAsync(string programId, string channelId, CancellationToken cancellationToken) + { + throw new NotImplementedException(); + } + + public Task> GetRecordingsAsync(CancellationToken cancellationToken) + { + return Task.FromResult((IEnumerable)_recordingProvider.GetAll()); + } + + public Task> GetTimersAsync(CancellationToken cancellationToken) + { + return Task.FromResult((IEnumerable)_timerProvider.GetAll()); + } + + public Task GetNewTimerDefaultsAsync(CancellationToken cancellationToken, ProgramInfo program = null) + { + var defaults = new SeriesTimerInfo() + { + PostPaddingSeconds = 60, + PrePaddingSeconds = 60, + RecordAnyChannel = false, + RecordAnyTime = false, + RecordNewOnly = false + }; + return Task.FromResult(defaults); + } + + public Task> GetSeriesTimersAsync(CancellationToken cancellationToken) + { + return Task.FromResult((IEnumerable)_seriesTimerProvider.GetAll()); + } + + public Task> GetProgramsAsync(string channelId, DateTime startDateUtc, DateTime endDateUtc, CancellationToken cancellationToken) + { + throw new NotImplementedException(); + } + + public Task GetRecordingStream(string recordingId, string streamId, CancellationToken cancellationToken) + { + throw new NotImplementedException(); + } + + public Task GetChannelStream(string channelId, string streamId, CancellationToken cancellationToken) + { + throw new NotImplementedException(); + } + + public Task> GetChannelStreamMediaSources(string channelId, CancellationToken cancellationToken) + { + throw new NotImplementedException(); + } + + public Task> 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 e) + { + try + { + var cancellationTokenSource = new CancellationTokenSource(); + + if (_activeRecordings.TryAdd(e.Argument.Id, cancellationTokenSource)) + { + await RecordStream(e.Argument, cancellationTokenSource.Token).ConfigureAwait(false); + } + } + catch (OperationCanceledException) + { + + } + catch (Exception ex) + { + _logger.ErrorException("Error recording stream", ex); + } + } + + private async Task RecordStream(TimerInfo timer, CancellationToken cancellationToken) + { + var mediaStreamInfo = await GetChannelStream(timer.ChannelId, "none", CancellationToken.None); + var duration = (timer.EndDate - RecordingHelper.GetStartTime(timer)).TotalSeconds + timer.PrePaddingSeconds; + + HttpRequestOptions httpRequestOptions = new HttpRequestOptions() + { + Url = mediaStreamInfo.Path + "?duration=" + duration + }; + + var info = GetProgramInfoFromCache(timer.ChannelId, timer.ProgramId); + var recordPath = RecordingPath; + if (info.IsMovie) + { + recordPath = Path.Combine(recordPath, "Movies", RecordingHelper.RemoveSpecialCharacters(info.Name)); + } + else + { + recordPath = Path.Combine(recordPath, "TV", RecordingHelper.RemoveSpecialCharacters(info.Name)); + } + + recordPath = Path.Combine(recordPath, RecordingHelper.GetRecordingName(timer, info)); + Directory.CreateDirectory(Path.GetDirectoryName(recordPath)); + + var recording = _recordingProvider.GetAll().FirstOrDefault(x => string.Equals(x.Id, info.Id, StringComparison.OrdinalIgnoreCase)); + + if (recording == null) + { + recording = new RecordingInfo() + { + ChannelId = info.ChannelId, + Id = info.Id, + StartDate = info.StartDate, + EndDate = info.EndDate, + Genres = info.Genres ?? null, + 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 ?? false, + ImagePath = info.ImagePath ?? null, + ImageUrl = info.ImageUrl, + OriginalAirDate = info.OriginalAirDate, + Status = RecordingStatus.Scheduled, + Overview = info.Overview, + SeriesTimerId = info.Id.Substring(0, 10) + }; + _recordingProvider.Add(recording); + } + + recording.Path = recordPath; + recording.Status = RecordingStatus.InProgress; + _recordingProvider.Update(recording); + + try + { + httpRequestOptions.BufferContent = false; + httpRequestOptions.CancellationToken = cancellationToken; + _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, 4096, cancellationToken); + } + } + + recording.Status = RecordingStatus.Completed; + } + catch (OperationCanceledException) + { + recording.Status = RecordingStatus.Cancelled; + } + catch + { + recording.Status = RecordingStatus.Error; + } + + _recordingProvider.Update(recording); + _timerProvider.Delete(timer); + _logger.Info("Recording was a success"); + } + + private ProgramInfo GetProgramInfoFromCache(string channelId, string programId) + { + var epgData = GetEpgDataForChannel(channelId); + if (epgData.Any()) + { + return epgData.FirstOrDefault(p => p.Id == programId); + } + return null; + } + + private string RecordingPath + { + get + { + var path = GetConfiguration().RecordingPath; + + return string.IsNullOrWhiteSpace(path) + ? Path.Combine(DataPath, "recordings") + : path; + } + } + + private LiveTvOptions GetConfiguration() + { + return _config.GetConfiguration("livetv"); + } + + private void UpdateTimersForSeriesTimer(SeriesTimerInfo seriesTimer) + { + List epgData; + if (seriesTimer.RecordAnyChannel) + { + epgData = GetEpgDataForAllChannels(); + } + else + { + epgData = GetEpgDataForChannel(seriesTimer.ChannelId); + } + + var newTimers = RecordingHelper.GetTimersForSeries(seriesTimer, epgData, _recordingProvider.GetAll(), _logger); + + var existingTimers = _timerProvider.GetAll() + .Where(i => string.Equals(i.SeriesTimerId, seriesTimer.Id, StringComparison.OrdinalIgnoreCase)) + .ToList(); + + foreach (var timer in newTimers) + { + _timerProvider.AddOrUpdate(timer); + } + + var newTimerIds = newTimers.Select(i => i.Id).ToList(); + + foreach (var timer in existingTimers) + { + if (!newTimerIds.Contains(timer.Id, StringComparer.OrdinalIgnoreCase)) + { + CancelTimerInternal(timer.Id); + } + } + } + + private string GetChannelEpgCachePath(string channelId) + { + return Path.Combine(DataPath, "epg", channelId + ".json"); + } + + private readonly object _epgLock = new object(); + private void SaveEpgDataForChannel(string channelId, List epgData) + { + var path = GetChannelEpgCachePath(channelId); + Directory.CreateDirectory(Path.GetDirectoryName(path)); + lock (_epgLock) + { + _jsonSerializer.SerializeToFile(epgData, path); + } + } + private List GetEpgDataForChannel(string channelId) + { + try + { + lock (_epgLock) + { + return _jsonSerializer.DeserializeFromFile>(GetChannelEpgCachePath(channelId)); + } + } + catch + { + return new List(); + } + } + private List GetEpgDataForAllChannels() + { + List channelEpg = new List(); + DirectoryInfo dir = new DirectoryInfo(Path.Combine(DataPath, "epg")); + List channels = dir.GetFiles("*").Where(i => string.Equals(i.Extension, ".json", StringComparison.OrdinalIgnoreCase)).Select(f => f.Name).ToList(); + foreach (var channel in channels) + { + channelEpg.AddRange(GetEpgDataForChannel(channel)); + } + return channelEpg; + } + + public void Dispose() + { + foreach (var pair in _activeRecordings.ToList()) + { + pair.Value.Cancel(); + } + } + } +} diff --git a/MediaBrowser.Server.Implementations/LiveTv/EmbyTV/ItemDataProvider.cs b/MediaBrowser.Server.Implementations/LiveTv/EmbyTV/ItemDataProvider.cs new file mode 100644 index 000000000..e7feeee5a --- /dev/null +++ b/MediaBrowser.Server.Implementations/LiveTv/EmbyTV/ItemDataProvider.cs @@ -0,0 +1,115 @@ +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 + where T : class + { + private readonly object _fileDataLock = new object(); + private List _items; + private readonly IJsonSerializer _jsonSerializer; + protected readonly ILogger Logger; + private readonly string _dataPath; + protected readonly Func EqualityComparer; + + public ItemDataProvider(IJsonSerializer jsonSerializer, ILogger logger, string dataPath, Func equalityComparer) + { + Logger = logger; + _dataPath = dataPath; + EqualityComparer = equalityComparer; + _jsonSerializer = jsonSerializer; + } + + public IReadOnlyList GetAll() + { + if (_items == null) + { + lock (_fileDataLock) + { + if (_items == null) + { + _items = GetItemsFromFile(_dataPath); + } + } + } + return _items; + } + + private List GetItemsFromFile(string path) + { + var jsonFile = path + ".json"; + + try + { + return _jsonSerializer.DeserializeFromFile>(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(); + } + + private void UpdateList(List newList) + { + lock (_fileDataLock) + { + _jsonSerializer.SerializeToFile(newList, _dataPath + ".json"); + _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..0aa1cb244 --- /dev/null +++ b/MediaBrowser.Server.Implementations/LiveTv/EmbyTV/RecordingHelper.cs @@ -0,0 +1,119 @@ +using System.Text; +using MediaBrowser.Common.Extensions; +using MediaBrowser.Controller.LiveTv; +using MediaBrowser.Model.Extensions; +using MediaBrowser.Model.Logging; +using System; +using System.Collections.Generic; +using System.Linq; + +namespace MediaBrowser.Server.Implementations.LiveTv.EmbyTV +{ + internal class RecordingHelper + { + public static List GetTimersForSeries(SeriesTimerInfo seriesTimer, IEnumerable epgData, IReadOnlyList currentRecordings, ILogger logger) + { + List timers = new List(); + + // Filtered Per Show + var filteredEpg = epgData.Where(epg => epg.Id.Substring(0, 10) == seriesTimer.Id); + + if (!seriesTimer.RecordAnyTime) + { + filteredEpg = filteredEpg.Where(epg => (seriesTimer.StartDate.TimeOfDay == epg.StartDate.TimeOfDay)); + } + + if (seriesTimer.RecordNewOnly) + { + filteredEpg = filteredEpg.Where(epg => !epg.IsRepeat); //Filtered by New only + } + + if (!seriesTimer.RecordAnyChannel) + { + filteredEpg = filteredEpg.Where(epg => string.Equals(epg.ChannelId, seriesTimer.ChannelId, StringComparison.OrdinalIgnoreCase)); + } + + filteredEpg = filteredEpg.Where(epg => seriesTimer.Days.Contains(epg.StartDate.DayOfWeek)); + + filteredEpg = filteredEpg.Where(epg => currentRecordings.All(r => r.Id.Substring(0, 14) != epg.Id.Substring(0, 14))); //filtered recordings already running + + filteredEpg = filteredEpg.GroupBy(epg => epg.Id.Substring(0, 14)).Select(g => g.First()).ToList(); + + foreach (var epg in filteredEpg) + { + timers.Add(CreateTimer(epg, seriesTimer)); + } + + return timers; + } + + public static DateTime GetStartTime(TimerInfo timer) + { + if (timer.StartDate.AddSeconds(-timer.PrePaddingSeconds + 1) < DateTime.UtcNow) + { + return DateTime.UtcNow.AddSeconds(1); + } + 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 + ".ts"); + } + var fancyName = info.Name; + if (info.ProductionYear != null) + { + fancyName += "_(" + info.ProductionYear + ")"; + } + if (info.IsSeries) + { + fancyName += "_" + info.EpisodeTitle.Replace("Season: ", "S").Replace(" Episode: ", "E"); + } + if (info.IsHD ?? false) + { + fancyName += "_HD"; + } + if (info.OriginalAirDate != null) + { + fancyName += "_" + info.OriginalAirDate.Value.ToString("yyyy-MM-dd"); + } + return RemoveSpecialCharacters(fancyName) + ".ts"; + } + + public static string RemoveSpecialCharacters(string str) + { + StringBuilder sb = new StringBuilder(); + foreach (char c in str) + { + if ((c >= '0' && c <= '9') || (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || c == '.' || c == '_' || c == '-' || c == ' ') + { + sb.Append(c); + } + } + return sb.ToString(); + } + } +} 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 + { + 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..323197aa5 --- /dev/null +++ b/MediaBrowser.Server.Implementations/LiveTv/EmbyTV/TimerManager.cs @@ -0,0 +1,114 @@ +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 + { + private readonly ConcurrentDictionary _timers = new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase); + + public event EventHandler> 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(); + } + + public void StopTimers() + { + foreach (var pair in _timers.ToList()) + { + pair.Value.Dispose(); + } + + _timers.Clear(); + } + + public override void Delete(TimerInfo item) + { + base.Delete(item); + + Timer timer; + if (_timers.TryRemove(item.Id, out timer)) + { + timer.Dispose(); + } + } + + 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 timespan = RecordingHelper.GetStartTime(item) - DateTime.UtcNow; + + var timer = new Timer(TimerCallback, item.Id, timespan, TimeSpan.Zero); + + if (!_timers.TryAdd(item.Id, 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 { Argument = timer }, Logger); + } + } + } +} diff --git a/MediaBrowser.Server.Implementations/LiveTv/LiveTvManager.cs b/MediaBrowser.Server.Implementations/LiveTv/LiveTvManager.cs index 9fef0560d..80ec2a036 100644 --- a/MediaBrowser.Server.Implementations/LiveTv/LiveTvManager.cs +++ b/MediaBrowser.Server.Implementations/LiveTv/LiveTvManager.cs @@ -55,7 +55,7 @@ namespace MediaBrowser.Server.Implementations.LiveTv private readonly SemaphoreSlim _refreshRecordingsLock = new SemaphoreSlim(1, 1); - private ConcurrentDictionary _refreshedPrograms = new ConcurrentDictionary(); + private readonly ConcurrentDictionary _refreshedPrograms = new ConcurrentDictionary(); 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) { diff --git a/MediaBrowser.Server.Implementations/LiveTv/TunerHosts/HdHomerun.cs b/MediaBrowser.Server.Implementations/LiveTv/TunerHosts/HdHomerun.cs new file mode 100644 index 000000000..cadbe7bc3 --- /dev/null +++ b/MediaBrowser.Server.Implementations/LiveTv/TunerHosts/HdHomerun.cs @@ -0,0 +1,205 @@ +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 +{ + public class HdHomerun : ITunerHost + { + private readonly IHttpClient _httpClient; + private readonly ILogger _logger; + private readonly IJsonSerializer _jsonSerializer; + private readonly IConfigurationManager _config; + + public HdHomerun(IHttpClient httpClient, ILogger logger, IJsonSerializer jsonSerializer, IConfigurationManager config) + { + _httpClient = httpClient; + _logger = logger; + _jsonSerializer = jsonSerializer; + _config = config; + } + + public string Name + { + get { return "HD Homerun"; } + } + + public string Type + { + get { return "hdhomerun"; } + } + + public async Task> GetChannels(TunerHostInfo info, CancellationToken cancellationToken) + { + var options = new HttpRequestOptions + { + Url = string.Format("{0}/lineup.json", GetApiUrl(info)), + CancellationToken = cancellationToken + }; + using (var stream = await _httpClient.Get(options)) + { + var root = _jsonSerializer.DeserializeFromStream>(stream); + + if (root != null) + { + return root.Select(i => new ChannelInfo + { + Name = i.GuideName, + Number = i.GuideNumber.ToString(CultureInfo.InvariantCulture), + Id = i.GuideNumber.ToString(CultureInfo.InvariantCulture), + IsFavorite = i.Favorite + + }); + } + return new List(); + } + } + + public async Task> GetTunerInfos(TunerHostInfo info, CancellationToken cancellationToken) + { + var httpOptions = new HttpRequestOptions() + { + Url = string.Format("{0}/tuners.html", GetApiUrl(info)), + CancellationToken = cancellationToken + }; + using (var stream = await _httpClient.Get(httpOptions)) + { + var tuners = new List(); + 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 = Name, + ProgramName = currentChannel, + Status = status + }); + } + } + } + return tuners; + } + } + + public string GetApiUrl(TunerHostInfo info) + { + var url = info.Url; + + if (!url.StartsWith("http", StringComparison.OrdinalIgnoreCase)) + { + url = "http://" + url; + } + + return url.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 LiveTvOptions GetConfiguration() + { + return _config.GetConfiguration("livetv"); + } + + public List GetTunerHosts() + { + return GetConfiguration().TunerHosts.Where(i => string.Equals(i.Type, Type, StringComparison.OrdinalIgnoreCase)).ToList(); + } + + public async Task GetChannelStream(TunerHostInfo info, string channelId, string streamId, CancellationToken cancellationToken) + { + var channels = await GetChannels(info, cancellationToken).ConfigureAwait(false); + var tuners = await GetTunerInfos(info, cancellationToken).ConfigureAwait(false); + + var channel = channels.FirstOrDefault(c => string.Equals(c.Id, channelId, StringComparison.OrdinalIgnoreCase)); + if (channel != null) + { + if (tuners.FindIndex(t => t.Status == LiveTvTunerStatus.Available) >= 0) + { + return new MediaSourceInfo + { + Path = GetApiUrl(info) + "/auto/v" + channelId, + Protocol = MediaProtocol.Http, + MediaStreams = new List + { + 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 + + } + } + }; + } + + throw new ApplicationException("No tuners avaliable."); + } + throw new ApplicationException("Channel not found."); + } + } +} diff --git a/MediaBrowser.Server.Implementations/LiveTv/TunerHosts/M3UTunerHost.cs b/MediaBrowser.Server.Implementations/LiveTv/TunerHosts/M3UTunerHost.cs new file mode 100644 index 000000000..615e07a82 --- /dev/null +++ b/MediaBrowser.Server.Implementations/LiveTv/TunerHosts/M3UTunerHost.cs @@ -0,0 +1,189 @@ +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.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 : ITunerHost + { + public string Type + { + get { return "m3u"; } + } + + public string Name + { + get { return "M3U Tuner"; } + } + + private readonly IConfigurationManager _config; + + public M3UTunerHost(IConfigurationManager config) + { + _config = config; + } + + public Task> GetChannels(TunerHostInfo info, CancellationToken cancellationToken) + { + int position = 0; + string line; + // Read the file and display it line by line. + var file = new StreamReader(info.Url); + var channels = new List(); + while ((line = file.ReadLine()) != null) + { + line = line.Trim(); + if (!String.IsNullOrWhiteSpace(line)) + { + if (position == 0 && !line.StartsWith("#EXTM3U")) + { + throw new ApplicationException("wrong file"); + } + if (position % 2 == 0) + { + if (position != 0) + { + channels.Last().Path = line; + } + else + { + line = line.Replace("#EXTM3U", ""); + line = line.Trim(); + var vars = line.Split(' ').ToList(); + foreach (var variable in vars) + { + var list = variable.Replace('"', ' ').Split('='); + switch (list[0]) + { + case ("id"): + //_id = list[1]; + break; + } + } + } + } + else + { + if (!line.StartsWith("#EXTINF:")) { throw new ApplicationException("Bad file"); } + line = line.Replace("#EXTINF:", ""); + var nameStart = line.LastIndexOf(','); + line = line.Substring(0, nameStart); + var vars = line.Split(' ').ToList(); + vars.RemoveAt(0); + channels.Add(new M3UChannel()); + foreach (var variable in vars) + { + var list = variable.Replace('"', ' ').Split('='); + switch (list[0]) + { + case "tvg-id": + channels.Last().Id = list[1]; + channels.Last().Number = list[1]; + break; + case "tvg-name": + channels.Last().Name = list[1]; + break; + } + } + } + position++; + } + } + file.Close(); + return Task.FromResult((IEnumerable)channels); + } + + public Task> GetTunerInfos(TunerHostInfo info, CancellationToken cancellationToken) + { + var list = new List(); + + list.Add(new LiveTvTunerInfo() + { + Name = Name, + SourceType = Type, + Status = LiveTvTunerStatus.Available, + Id = info.Url.GetMD5().ToString("N"), + Url = info.Url + }); + + return Task.FromResult(list); + } + + private LiveTvOptions GetConfiguration() + { + return _config.GetConfiguration("livetv"); + } + + public List GetTunerHosts() + { + return GetConfiguration().TunerHosts.Where(i => string.Equals(i.Type, Type, StringComparison.OrdinalIgnoreCase)).ToList(); + } + + public async Task GetChannelStream(TunerHostInfo info, string channelId, string streamId, CancellationToken cancellationToken) + { + var channels = await GetChannels(info, cancellationToken).ConfigureAwait(false); + var m3uchannels = channels.Cast(); + var channel = m3uchannels.FirstOrDefault(c => c.Id == channelId); + if (channel != null) + { + var path = channel.Path; + MediaProtocol protocol = MediaProtocol.File; + if (path.StartsWith("http")) + { + protocol = MediaProtocol.Http; + } + else if (path.StartsWith("rtmp")) + { + protocol = MediaProtocol.Rtmp; + } + else if (path.StartsWith("rtsp")) + { + protocol = MediaProtocol.Rtsp; + } + + return new MediaSourceInfo + { + Path = channel.Path, + Protocol = protocol, + MediaStreams = new List + { + 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 + + } + } + }; + } + throw new ApplicationException("Host doesnt provide this channel"); + } + + class M3UChannel : ChannelInfo + { + public string Path { get; set; } + + public M3UChannel() + { + } + } + } +} diff --git a/MediaBrowser.Server.Implementations/MediaBrowser.Server.Implementations.csproj b/MediaBrowser.Server.Implementations/MediaBrowser.Server.Implementations.csproj index 33b2493f5..bc1d07426 100644 --- a/MediaBrowser.Server.Implementations/MediaBrowser.Server.Implementations.csproj +++ b/MediaBrowser.Server.Implementations/MediaBrowser.Server.Implementations.csproj @@ -216,10 +216,17 @@ + + + + + + +