diff options
| author | Luke Pulverenti <luke.pulverenti@gmail.com> | 2016-11-03 19:35:19 -0400 |
|---|---|---|
| committer | Luke Pulverenti <luke.pulverenti@gmail.com> | 2016-11-03 19:35:19 -0400 |
| commit | d5ea8ca3ad378fc7e0a18ad314e1dfce07003ab6 (patch) | |
| tree | 4742a665e3455389a9795ff8b6c292263b3876e8 /Emby.Server.Implementations | |
| parent | d0babf322dad6624ee15622d11db52e58db5197f (diff) | |
move classes to portable
Diffstat (limited to 'Emby.Server.Implementations')
32 files changed, 12887 insertions, 0 deletions
diff --git a/Emby.Server.Implementations/Emby.Server.Implementations.csproj b/Emby.Server.Implementations/Emby.Server.Implementations.csproj index 322b6773f..7eb6a67d4 100644 --- a/Emby.Server.Implementations/Emby.Server.Implementations.csproj +++ b/Emby.Server.Implementations/Emby.Server.Implementations.csproj @@ -110,8 +110,34 @@ <Compile Include="Library\Validators\StudiosValidator.cs" /> <Compile Include="Library\Validators\YearsPostScanTask.cs" /> <Compile Include="LiveTv\ChannelImageProvider.cs" /> + <Compile Include="LiveTv\EmbyTV\DirectRecorder.cs" /> + <Compile Include="LiveTv\EmbyTV\EmbyTV.cs" /> + <Compile Include="LiveTv\EmbyTV\EmbyTVRegistration.cs" /> + <Compile Include="LiveTv\EmbyTV\EncodedRecorder.cs" /> + <Compile Include="LiveTv\EmbyTV\EntryPoint.cs" /> + <Compile Include="LiveTv\EmbyTV\IRecorder.cs" /> + <Compile Include="LiveTv\EmbyTV\ItemDataProvider.cs" /> + <Compile Include="LiveTv\EmbyTV\RecordingHelper.cs" /> + <Compile Include="LiveTv\EmbyTV\SeriesTimerManager.cs" /> + <Compile Include="LiveTv\EmbyTV\TimerManager.cs" /> + <Compile Include="LiveTv\Listings\SchedulesDirect.cs" /> + <Compile Include="LiveTv\Listings\XmlTvListingsProvider.cs" /> + <Compile Include="LiveTv\LiveStreamHelper.cs" /> + <Compile Include="LiveTv\LiveTvConfigurationFactory.cs" /> + <Compile Include="LiveTv\LiveTvDtoService.cs" /> + <Compile Include="LiveTv\LiveTvManager.cs" /> + <Compile Include="LiveTv\LiveTvMediaSourceProvider.cs" /> <Compile Include="LiveTv\ProgramImageProvider.cs" /> <Compile Include="LiveTv\RecordingImageProvider.cs" /> + <Compile Include="LiveTv\RefreshChannelsScheduledTask.cs" /> + <Compile Include="LiveTv\TunerHosts\BaseTunerHost.cs" /> + <Compile Include="LiveTv\TunerHosts\HdHomerun\HdHomerunDiscovery.cs" /> + <Compile Include="LiveTv\TunerHosts\HdHomerun\HdHomerunHost.cs" /> + <Compile Include="LiveTv\TunerHosts\HdHomerun\HdHomerunLiveStream.cs" /> + <Compile Include="LiveTv\TunerHosts\M3uParser.cs" /> + <Compile Include="LiveTv\TunerHosts\M3UTunerHost.cs" /> + <Compile Include="LiveTv\TunerHosts\MulticastStream.cs" /> + <Compile Include="LiveTv\TunerHosts\QueueStream.cs" /> <Compile Include="Logging\PatternsLogger.cs" /> <Compile Include="MediaEncoder\EncodingManager.cs" /> <Compile Include="News\NewsEntryPoint.cs" /> @@ -136,6 +162,10 @@ <Compile Include="ScheduledTasks\SystemUpdateTask.cs" /> <Compile Include="ServerManager\ServerManager.cs" /> <Compile Include="ServerManager\WebSocketConnection.cs" /> + <Compile Include="Session\HttpSessionController.cs" /> + <Compile Include="Session\SessionManager.cs" /> + <Compile Include="Session\SessionWebSocketListener.cs" /> + <Compile Include="Session\WebSocketController.cs" /> <Compile Include="Sorting\AiredEpisodeOrderComparer.cs" /> <Compile Include="Sorting\AirTimeComparer.cs" /> <Compile Include="Sorting\AlbumArtistComparer.cs" /> diff --git a/Emby.Server.Implementations/LiveTv/EmbyTV/DirectRecorder.cs b/Emby.Server.Implementations/LiveTv/EmbyTV/DirectRecorder.cs new file mode 100644 index 000000000..6bb06843a --- /dev/null +++ b/Emby.Server.Implementations/LiveTv/EmbyTV/DirectRecorder.cs @@ -0,0 +1,107 @@ +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using MediaBrowser.Model.IO; +using MediaBrowser.Common.IO; +using MediaBrowser.Common.Net; +using MediaBrowser.Controller.IO; +using MediaBrowser.Model.Dto; +using MediaBrowser.Model.Logging; + +namespace Emby.Server.Implementations.LiveTv.EmbyTV +{ + public class DirectRecorder : IRecorder + { + private readonly ILogger _logger; + private readonly IHttpClient _httpClient; + private readonly IFileSystem _fileSystem; + + public DirectRecorder(ILogger logger, IHttpClient httpClient, IFileSystem fileSystem) + { + _logger = logger; + _httpClient = httpClient; + _fileSystem = fileSystem; + } + + public string GetOutputPath(MediaSourceInfo mediaSource, string targetFile) + { + return targetFile; + } + + public async Task Record(MediaSourceInfo mediaSource, string targetFile, TimeSpan duration, Action onStarted, CancellationToken cancellationToken) + { + var httpRequestOptions = new HttpRequestOptions() + { + Url = mediaSource.Path + }; + + httpRequestOptions.BufferContent = false; + + using (var response = await _httpClient.SendAsync(httpRequestOptions, "GET").ConfigureAwait(false)) + { + _logger.Info("Opened recording stream from tuner provider"); + + using (var output = _fileSystem.GetFileStream(targetFile, FileOpenMode.Create, FileAccessMode.Write, FileShareMode.Read)) + { + onStarted(); + + _logger.Info("Copying recording stream to file {0}", targetFile); + + // The media source if infinite so we need to handle stopping ourselves + var durationToken = new CancellationTokenSource(duration); + cancellationToken = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, durationToken.Token).Token; + + await CopyUntilCancelled(response.Content, output, cancellationToken).ConfigureAwait(false); + } + } + + _logger.Info("Recording completed to file {0}", targetFile); + } + + private const int BufferSize = 81920; + public static Task CopyUntilCancelled(Stream source, Stream target, CancellationToken cancellationToken) + { + return CopyUntilCancelled(source, target, null, cancellationToken); + } + public static async Task CopyUntilCancelled(Stream source, Stream target, Action onStarted, CancellationToken cancellationToken) + { + while (!cancellationToken.IsCancellationRequested) + { + var bytesRead = await CopyToAsyncInternal(source, target, BufferSize, onStarted, cancellationToken).ConfigureAwait(false); + + onStarted = null; + + //var position = fs.Position; + //_logger.Debug("Streamed {0} bytes to position {1} from file {2}", bytesRead, position, path); + + if (bytesRead == 0) + { + await Task.Delay(100).ConfigureAwait(false); + } + } + } + + private static async Task<int> CopyToAsyncInternal(Stream source, Stream destination, Int32 bufferSize, Action onStarted, CancellationToken cancellationToken) + { + byte[] buffer = new byte[bufferSize]; + int bytesRead; + int totalBytesRead = 0; + + while ((bytesRead = await source.ReadAsync(buffer, 0, buffer.Length, cancellationToken).ConfigureAwait(false)) != 0) + { + await destination.WriteAsync(buffer, 0, bytesRead, cancellationToken).ConfigureAwait(false); + + totalBytesRead += bytesRead; + + if (onStarted != null) + { + onStarted(); + } + onStarted = null; + } + + return totalBytesRead; + } + } +} diff --git a/Emby.Server.Implementations/LiveTv/EmbyTV/EmbyTV.cs b/Emby.Server.Implementations/LiveTv/EmbyTV/EmbyTV.cs new file mode 100644 index 000000000..b8b0cb73c --- /dev/null +++ b/Emby.Server.Implementations/LiveTv/EmbyTV/EmbyTV.cs @@ -0,0 +1,1972 @@ +using MediaBrowser.Common; +using MediaBrowser.Common.Configuration; +using MediaBrowser.Common.Net; +using MediaBrowser.Common.Security; +using MediaBrowser.Controller.Configuration; +using MediaBrowser.Controller.Drawing; +using MediaBrowser.Controller.FileOrganization; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.LiveTv; +using MediaBrowser.Controller.MediaEncoding; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.Dto; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.Events; +using MediaBrowser.Model.LiveTv; +using MediaBrowser.Model.Logging; +using MediaBrowser.Model.Serialization; +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using System.Xml; +using MediaBrowser.Model.IO; +using MediaBrowser.Common.Events; +using MediaBrowser.Common.Extensions; +using MediaBrowser.Common.IO; +using MediaBrowser.Controller; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.TV; +using MediaBrowser.Controller.IO; +using MediaBrowser.Model.Configuration; +using MediaBrowser.Model.Diagnostics; +using MediaBrowser.Model.FileOrganization; +using MediaBrowser.Model.System; +using MediaBrowser.Model.Threading; + +namespace Emby.Server.Implementations.LiveTv.EmbyTV +{ + public class EmbyTV : ILiveTvService, ISupportsDirectStreamProvider, ISupportsNewTimerIds, IDisposable + { + private readonly IServerApplicationHost _appHost; + private readonly ILogger _logger; + private readonly IHttpClient _httpClient; + private readonly IServerConfigurationManager _config; + private readonly IJsonSerializer _jsonSerializer; + + private readonly ItemDataProvider<SeriesTimerInfo> _seriesTimerProvider; + private readonly TimerManager _timerProvider; + + private readonly LiveTvManager _liveTvManager; + private readonly IFileSystem _fileSystem; + + private readonly ILibraryMonitor _libraryMonitor; + private readonly ILibraryManager _libraryManager; + private readonly IProviderManager _providerManager; + private readonly IFileOrganizationService _organizationService; + private readonly IMediaEncoder _mediaEncoder; + private readonly IProcessFactory _processFactory; + private readonly ISystemEvents _systemEvents; + + public static EmbyTV Current; + + public event EventHandler DataSourceChanged; + public event EventHandler<RecordingStatusChangedEventArgs> RecordingStatusChanged; + + private readonly ConcurrentDictionary<string, ActiveRecordingInfo> _activeRecordings = + new ConcurrentDictionary<string, ActiveRecordingInfo>(StringComparer.OrdinalIgnoreCase); + + public EmbyTV(IServerApplicationHost appHost, ILogger logger, IJsonSerializer jsonSerializer, IHttpClient httpClient, IServerConfigurationManager config, ILiveTvManager liveTvManager, IFileSystem fileSystem, ILibraryManager libraryManager, ILibraryMonitor libraryMonitor, IProviderManager providerManager, IFileOrganizationService organizationService, IMediaEncoder mediaEncoder, ITimerFactory timerFactory, IProcessFactory processFactory, ISystemEvents systemEvents) + { + Current = this; + + _appHost = appHost; + _logger = logger; + _httpClient = httpClient; + _config = config; + _fileSystem = fileSystem; + _libraryManager = libraryManager; + _libraryMonitor = libraryMonitor; + _providerManager = providerManager; + _organizationService = organizationService; + _mediaEncoder = mediaEncoder; + _processFactory = processFactory; + _systemEvents = systemEvents; + _liveTvManager = (LiveTvManager)liveTvManager; + _jsonSerializer = jsonSerializer; + + _seriesTimerProvider = new SeriesTimerManager(fileSystem, jsonSerializer, _logger, Path.Combine(DataPath, "seriestimers")); + _timerProvider = new TimerManager(fileSystem, jsonSerializer, _logger, Path.Combine(DataPath, "timers"), _logger, timerFactory); + _timerProvider.TimerFired += _timerProvider_TimerFired; + + _config.NamedConfigurationUpdated += _config_NamedConfigurationUpdated; + } + + private void _config_NamedConfigurationUpdated(object sender, ConfigurationUpdateEventArgs e) + { + if (string.Equals(e.Key, "livetv", StringComparison.OrdinalIgnoreCase)) + { + OnRecordingFoldersChanged(); + } + } + + public void Start() + { + _timerProvider.RestartTimers(); + + _systemEvents.Resume += _systemEvents_Resume; + CreateRecordingFolders(); + } + + private void _systemEvents_Resume(object sender, EventArgs e) + { + _timerProvider.RestartTimers(); + } + + private void OnRecordingFoldersChanged() + { + CreateRecordingFolders(); + } + + internal void CreateRecordingFolders() + { + try + { + CreateRecordingFoldersInternal(); + } + catch (Exception ex) + { + _logger.ErrorException("Error creating recording folders", ex); + } + } + + internal void CreateRecordingFoldersInternal() + { + var recordingFolders = GetRecordingFolders(); + + var virtualFolders = _libraryManager.GetVirtualFolders() + .ToList(); + + var allExistingPaths = virtualFolders.SelectMany(i => i.Locations).ToList(); + + var pathsAdded = new List<string>(); + + foreach (var recordingFolder in recordingFolders) + { + var pathsToCreate = recordingFolder.Locations + .Where(i => !allExistingPaths.Contains(i, StringComparer.OrdinalIgnoreCase)) + .ToList(); + + if (pathsToCreate.Count == 0) + { + continue; + } + + var mediaPathInfos = pathsToCreate.Select(i => new MediaPathInfo { Path = i }).ToArray(); + + var libraryOptions = new LibraryOptions + { + PathInfos = mediaPathInfos + }; + try + { + _libraryManager.AddVirtualFolder(recordingFolder.Name, recordingFolder.CollectionType, libraryOptions, true); + } + catch (Exception ex) + { + _logger.ErrorException("Error creating virtual folder", ex); + } + + pathsAdded.AddRange(pathsToCreate); + } + + var config = GetConfiguration(); + + var pathsToRemove = config.MediaLocationsCreated + .Except(recordingFolders.SelectMany(i => i.Locations)) + .ToList(); + + if (pathsAdded.Count > 0 || pathsToRemove.Count > 0) + { + pathsAdded.InsertRange(0, config.MediaLocationsCreated); + config.MediaLocationsCreated = pathsAdded.Except(pathsToRemove).Distinct(StringComparer.OrdinalIgnoreCase).ToArray(); + _config.SaveConfiguration("livetv", config); + } + + foreach (var path in pathsToRemove) + { + RemovePathFromLibrary(path); + } + } + + private void RemovePathFromLibrary(string path) + { + _logger.Debug("Removing path from library: {0}", path); + + var requiresRefresh = false; + var virtualFolders = _libraryManager.GetVirtualFolders() + .ToList(); + + foreach (var virtualFolder in virtualFolders) + { + if (!virtualFolder.Locations.Contains(path, StringComparer.OrdinalIgnoreCase)) + { + continue; + } + + if (virtualFolder.Locations.Count == 1) + { + // remove entire virtual folder + try + { + _libraryManager.RemoveVirtualFolder(virtualFolder.Name, true); + } + catch (Exception ex) + { + _logger.ErrorException("Error removing virtual folder", ex); + } + } + else + { + try + { + _libraryManager.RemoveMediaPath(virtualFolder.Name, path); + requiresRefresh = true; + } + catch (Exception ex) + { + _logger.ErrorException("Error removing media path", ex); + } + } + } + + if (requiresRefresh) + { + _libraryManager.ValidateMediaLibrary(new Progress<Double>(), CancellationToken.None); + } + } + + public string Name + { + get { return "Emby"; } + } + + public string DataPath + { + get { return Path.Combine(_config.CommonApplicationPaths.DataPath, "livetv"); } + } + + private string DefaultRecordingPath + { + get + { + return Path.Combine(DataPath, "recordings"); + } + } + + private string RecordingPath + { + get + { + var path = GetConfiguration().RecordingPath; + + return string.IsNullOrWhiteSpace(path) + ? DefaultRecordingPath + : path; + } + } + + public string HomePageUrl + { + get { return "http://emby.media"; } + } + + public async Task<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 = _appHost.ApplicationVersion.ToString(); + status.IsVisible = false; + return status; + } + + public async Task RefreshSeriesTimers(CancellationToken cancellationToken, IProgress<double> progress) + { + var seriesTimers = await GetSeriesTimersAsync(cancellationToken).ConfigureAwait(false); + + List<ChannelInfo> channels = null; + + foreach (var timer in seriesTimers) + { + List<ProgramInfo> epgData; + + if (timer.RecordAnyChannel) + { + if (channels == null) + { + channels = (await GetChannelsAsync(true, CancellationToken.None).ConfigureAwait(false)).ToList(); + } + var channelIds = channels.Select(i => i.Id).ToList(); + epgData = GetEpgDataForChannels(channelIds); + } + else + { + epgData = GetEpgDataForChannel(timer.ChannelId); + } + await UpdateTimersForSeriesTimer(epgData, timer, true).ConfigureAwait(false); + } + + var timers = await GetTimersAsync(cancellationToken).ConfigureAwait(false); + + foreach (var timer in timers.ToList()) + { + if (DateTime.UtcNow > timer.EndDate && !_activeRecordings.ContainsKey(timer.Id)) + { + OnTimerOutOfDate(timer); + } + } + } + + private void OnTimerOutOfDate(TimerInfo timer) + { + _timerProvider.Delete(timer); + } + + private async Task<IEnumerable<ChannelInfo>> GetChannelsAsync(bool enableCache, CancellationToken cancellationToken) + { + var list = new List<ChannelInfo>(); + + foreach (var hostInstance in _liveTvManager.TunerHosts) + { + try + { + var channels = await hostInstance.GetChannels(enableCache, cancellationToken).ConfigureAwait(false); + + list.AddRange(channels); + } + catch (Exception ex) + { + _logger.ErrorException("Error getting channels", ex); + } + } + + foreach (var provider in GetListingProviders()) + { + var enabledChannels = list + .Where(i => IsListingProviderEnabledForTuner(provider.Item2, i.TunerHostId)) + .ToList(); + + if (enabledChannels.Count > 0) + { + try + { + await provider.Item1.AddMetadata(provider.Item2, enabledChannels, cancellationToken).ConfigureAwait(false); + } + catch (NotSupportedException) + { + + } + catch (Exception ex) + { + _logger.ErrorException("Error adding metadata", ex); + } + } + } + + return list; + } + + public async Task<List<ChannelInfo>> GetChannelsForListingsProvider(ListingsProviderInfo listingsProvider, CancellationToken cancellationToken) + { + var list = new List<ChannelInfo>(); + + foreach (var hostInstance in _liveTvManager.TunerHosts) + { + try + { + var channels = await hostInstance.GetChannels(false, cancellationToken).ConfigureAwait(false); + + list.AddRange(channels); + } + catch (Exception ex) + { + _logger.ErrorException("Error getting channels", ex); + } + } + + return list + .Where(i => IsListingProviderEnabledForTuner(listingsProvider, i.TunerHostId)) + .ToList(); + } + + public Task<IEnumerable<ChannelInfo>> GetChannelsAsync(CancellationToken cancellationToken) + { + return GetChannelsAsync(false, cancellationToken); + } + + public Task CancelSeriesTimerAsync(string timerId, CancellationToken cancellationToken) + { + var timers = _timerProvider + .GetAll() + .Where(i => string.Equals(i.SeriesTimerId, timerId, StringComparison.OrdinalIgnoreCase)) + .ToList(); + + foreach (var timer in timers) + { + CancelTimerInternal(timer.Id, true); + } + + var remove = _seriesTimerProvider.GetAll().FirstOrDefault(r => string.Equals(r.Id, timerId, StringComparison.OrdinalIgnoreCase)); + if (remove != null) + { + _seriesTimerProvider.Delete(remove); + } + return Task.FromResult(true); + } + + private void CancelTimerInternal(string timerId, bool isSeriesCancelled) + { + var timer = _timerProvider.GetTimer(timerId); + if (timer != null) + { + if (string.IsNullOrWhiteSpace(timer.SeriesTimerId) || isSeriesCancelled) + { + _timerProvider.Delete(timer); + } + else + { + timer.Status = RecordingStatus.Cancelled; + _timerProvider.AddOrUpdate(timer, false); + } + } + ActiveRecordingInfo activeRecordingInfo; + + if (_activeRecordings.TryGetValue(timerId, out activeRecordingInfo)) + { + activeRecordingInfo.CancellationTokenSource.Cancel(); + } + } + + public Task CancelTimerAsync(string timerId, CancellationToken cancellationToken) + { + CancelTimerInternal(timerId, false); + return Task.FromResult(true); + } + + public Task DeleteRecordingAsync(string recordingId, CancellationToken cancellationToken) + { + return Task.FromResult(true); + } + + public Task CreateSeriesTimerAsync(SeriesTimerInfo info, CancellationToken cancellationToken) + { + throw new NotImplementedException(); + } + + public Task CreateTimerAsync(TimerInfo info, CancellationToken cancellationToken) + { + throw new NotImplementedException(); + } + + public Task<string> CreateTimer(TimerInfo timer, CancellationToken cancellationToken) + { + var existingTimer = _timerProvider.GetAll() + .FirstOrDefault(i => string.Equals(timer.ProgramId, i.ProgramId, StringComparison.OrdinalIgnoreCase)); + + if (existingTimer != null) + { + if (existingTimer.Status == RecordingStatus.Cancelled || + existingTimer.Status == RecordingStatus.Completed) + { + existingTimer.Status = RecordingStatus.New; + _timerProvider.Update(existingTimer); + return Task.FromResult(existingTimer.Id); + } + else + { + throw new ArgumentException("A scheduled recording already exists for this program."); + } + } + + timer.Id = Guid.NewGuid().ToString("N"); + + ProgramInfo programInfo = null; + + if (!string.IsNullOrWhiteSpace(timer.ProgramId)) + { + programInfo = GetProgramInfoFromCache(timer.ChannelId, timer.ProgramId); + } + if (programInfo == null) + { + _logger.Info("Unable to find program with Id {0}. Will search using start date", timer.ProgramId); + programInfo = GetProgramInfoFromCache(timer.ChannelId, timer.StartDate); + } + + if (programInfo != null) + { + RecordingHelper.CopyProgramInfoToTimerInfo(programInfo, timer); + } + + _timerProvider.Add(timer); + return Task.FromResult(timer.Id); + } + + public async Task<string> CreateSeriesTimer(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, false).ConfigureAwait(false); + + return info.Id; + } + + public async Task UpdateSeriesTimerAsync(SeriesTimerInfo info, CancellationToken cancellationToken) + { + var instance = _seriesTimerProvider.GetAll().FirstOrDefault(i => string.Equals(i.Id, info.Id, StringComparison.OrdinalIgnoreCase)); + + if (instance != null) + { + instance.ChannelId = info.ChannelId; + instance.Days = info.Days; + instance.EndDate = info.EndDate; + instance.IsPostPaddingRequired = info.IsPostPaddingRequired; + instance.IsPrePaddingRequired = info.IsPrePaddingRequired; + instance.PostPaddingSeconds = info.PostPaddingSeconds; + instance.PrePaddingSeconds = info.PrePaddingSeconds; + instance.Priority = info.Priority; + instance.RecordAnyChannel = info.RecordAnyChannel; + instance.RecordAnyTime = info.RecordAnyTime; + instance.RecordNewOnly = info.RecordNewOnly; + instance.SkipEpisodesInLibrary = info.SkipEpisodesInLibrary; + instance.KeepUpTo = info.KeepUpTo; + instance.KeepUntil = info.KeepUntil; + instance.StartDate = info.StartDate; + + _seriesTimerProvider.Update(instance); + + List<ProgramInfo> epgData; + if (instance.RecordAnyChannel) + { + var channels = await GetChannelsAsync(true, CancellationToken.None).ConfigureAwait(false); + var channelIds = channels.Select(i => i.Id).ToList(); + epgData = GetEpgDataForChannels(channelIds); + } + else + { + epgData = GetEpgDataForChannel(instance.ChannelId); + } + + await UpdateTimersForSeriesTimer(epgData, instance, true).ConfigureAwait(false); + } + } + + public Task UpdateTimerAsync(TimerInfo updatedTimer, CancellationToken cancellationToken) + { + var existingTimer = _timerProvider.GetTimer(updatedTimer.Id); + + if (existingTimer == null) + { + throw new ResourceNotFoundException(); + } + + // Only update if not currently active + ActiveRecordingInfo activeRecordingInfo; + if (!_activeRecordings.TryGetValue(updatedTimer.Id, out activeRecordingInfo)) + { + existingTimer.PrePaddingSeconds = updatedTimer.PrePaddingSeconds; + existingTimer.PostPaddingSeconds = updatedTimer.PostPaddingSeconds; + existingTimer.IsPostPaddingRequired = updatedTimer.IsPostPaddingRequired; + existingTimer.IsPrePaddingRequired = updatedTimer.IsPrePaddingRequired; + } + + return Task.FromResult(true); + } + + private void UpdateExistingTimerWithNewMetadata(TimerInfo existingTimer, TimerInfo updatedTimer) + { + // Update the program info but retain the status + existingTimer.ChannelId = updatedTimer.ChannelId; + existingTimer.CommunityRating = updatedTimer.CommunityRating; + existingTimer.EndDate = updatedTimer.EndDate; + existingTimer.EpisodeNumber = updatedTimer.EpisodeNumber; + existingTimer.EpisodeTitle = updatedTimer.EpisodeTitle; + existingTimer.Genres = updatedTimer.Genres; + existingTimer.HomePageUrl = updatedTimer.HomePageUrl; + existingTimer.IsKids = updatedTimer.IsKids; + existingTimer.IsNews = updatedTimer.IsNews; + existingTimer.IsMovie = updatedTimer.IsMovie; + existingTimer.IsProgramSeries = updatedTimer.IsProgramSeries; + existingTimer.IsRepeat = updatedTimer.IsRepeat; + existingTimer.IsSports = updatedTimer.IsSports; + existingTimer.Name = updatedTimer.Name; + existingTimer.OfficialRating = updatedTimer.OfficialRating; + existingTimer.OriginalAirDate = updatedTimer.OriginalAirDate; + existingTimer.Overview = updatedTimer.Overview; + existingTimer.ProductionYear = updatedTimer.ProductionYear; + existingTimer.ProgramId = updatedTimer.ProgramId; + existingTimer.SeasonNumber = updatedTimer.SeasonNumber; + existingTimer.ShortOverview = updatedTimer.ShortOverview; + existingTimer.StartDate = updatedTimer.StartDate; + } + + 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 async Task<IEnumerable<RecordingInfo>> GetRecordingsAsync(CancellationToken cancellationToken) + { + return _activeRecordings.Values.ToList().Select(GetRecordingInfo).ToList(); + } + + public string GetActiveRecordingPath(string id) + { + ActiveRecordingInfo info; + + if (_activeRecordings.TryGetValue(id, out info)) + { + return info.Path; + } + return null; + } + + private RecordingInfo GetRecordingInfo(ActiveRecordingInfo info) + { + var timer = info.Timer; + var program = info.Program; + + var result = new RecordingInfo + { + ChannelId = timer.ChannelId, + CommunityRating = timer.CommunityRating, + DateLastUpdated = DateTime.UtcNow, + EndDate = timer.EndDate, + EpisodeTitle = timer.EpisodeTitle, + Genres = timer.Genres, + Id = "recording" + timer.Id, + IsKids = timer.IsKids, + IsMovie = timer.IsMovie, + IsNews = timer.IsNews, + IsRepeat = timer.IsRepeat, + IsSeries = timer.IsProgramSeries, + IsSports = timer.IsSports, + Name = timer.Name, + OfficialRating = timer.OfficialRating, + OriginalAirDate = timer.OriginalAirDate, + Overview = timer.Overview, + ProgramId = timer.ProgramId, + SeriesTimerId = timer.SeriesTimerId, + StartDate = timer.StartDate, + Status = RecordingStatus.InProgress, + TimerId = timer.Id + }; + + if (program != null) + { + result.Audio = program.Audio; + result.ImagePath = program.ImagePath; + result.ImageUrl = program.ImageUrl; + result.IsHD = program.IsHD; + result.IsLive = program.IsLive; + result.IsPremiere = program.IsPremiere; + result.ShowId = program.ShowId; + } + + return result; + } + + public Task<IEnumerable<TimerInfo>> GetTimersAsync(CancellationToken cancellationToken) + { + var excludeStatues = new List<RecordingStatus> + { + RecordingStatus.Completed + }; + + var timers = _timerProvider.GetAll() + .Where(i => !excludeStatues.Contains(i.Status)); + + return Task.FromResult(timers); + } + + public Task<SeriesTimerInfo> GetNewTimerDefaultsAsync(CancellationToken cancellationToken, ProgramInfo program = null) + { + var config = GetConfiguration(); + + var defaults = new SeriesTimerInfo() + { + PostPaddingSeconds = Math.Max(config.PostPaddingSeconds, 0), + PrePaddingSeconds = Math.Max(config.PrePaddingSeconds, 0), + RecordAnyChannel = false, + RecordAnyTime = true, + RecordNewOnly = true, + + Days = new List<DayOfWeek> + { + DayOfWeek.Sunday, + DayOfWeek.Monday, + DayOfWeek.Tuesday, + DayOfWeek.Wednesday, + DayOfWeek.Thursday, + DayOfWeek.Friday, + DayOfWeek.Saturday + } + }; + + if (program != null) + { + defaults.SeriesId = program.SeriesId; + defaults.ProgramId = program.Id; + defaults.RecordNewOnly = !program.IsRepeat; + } + + defaults.SkipEpisodesInLibrary = defaults.RecordNewOnly; + defaults.KeepUntil = KeepUntil.UntilDeleted; + + return Task.FromResult(defaults); + } + + public Task<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) + { + try + { + return await GetProgramsAsyncInternal(channelId, startDateUtc, endDateUtc, cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + _logger.ErrorException("Error getting programs", ex); + return GetEpgDataForChannel(channelId).Where(i => i.StartDate <= endDateUtc && i.EndDate >= startDateUtc); + } + } + + private bool IsListingProviderEnabledForTuner(ListingsProviderInfo info, string tunerHostId) + { + if (info.EnableAllTuners) + { + return true; + } + + if (string.IsNullOrWhiteSpace(tunerHostId)) + { + throw new ArgumentNullException("tunerHostId"); + } + + return info.EnabledTuners.Contains(tunerHostId, StringComparer.OrdinalIgnoreCase); + } + + private async Task<IEnumerable<ProgramInfo>> GetProgramsAsyncInternal(string channelId, DateTime startDateUtc, DateTime endDateUtc, CancellationToken cancellationToken) + { + var channels = await GetChannelsAsync(true, cancellationToken).ConfigureAwait(false); + var channel = channels.First(i => string.Equals(i.Id, channelId, StringComparison.OrdinalIgnoreCase)); + + foreach (var provider in GetListingProviders()) + { + if (!IsListingProviderEnabledForTuner(provider.Item2, channel.TunerHostId)) + { + _logger.Debug("Skipping getting programs for channel {0}-{1} from {2}-{3}, because it's not enabled for this tuner.", channel.Number, channel.Name, provider.Item1.Name, provider.Item2.ListingsId ?? string.Empty); + continue; + } + + _logger.Debug("Getting programs for channel {0}-{1} from {2}-{3}", channel.Number, channel.Name, provider.Item1.Name, provider.Item2.ListingsId ?? string.Empty); + + var channelMappings = GetChannelMappings(provider.Item2); + var channelNumber = channel.Number; + string mappedChannelNumber; + if (channelMappings.TryGetValue(channelNumber, out mappedChannelNumber)) + { + _logger.Debug("Found mapped channel on provider {0}. Tuner channel number: {1}, Mapped channel number: {2}", provider.Item1.Name, channelNumber, mappedChannelNumber); + channelNumber = mappedChannelNumber; + } + + var programs = await provider.Item1.GetProgramsAsync(provider.Item2, channelNumber, channel.Name, 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 Dictionary<string, string> GetChannelMappings(ListingsProviderInfo info) + { + var dict = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase); + + foreach (var mapping in info.ChannelMappings) + { + dict[mapping.Name] = mapping.Value; + } + + return dict; + } + + 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(); + } + + private readonly SemaphoreSlim _liveStreamsSemaphore = new SemaphoreSlim(1, 1); + private readonly List<LiveStream> _liveStreams = new List<LiveStream>(); + + public async Task<MediaSourceInfo> GetChannelStream(string channelId, string streamId, CancellationToken cancellationToken) + { + var result = await GetChannelStreamWithDirectStreamProvider(channelId, streamId, cancellationToken).ConfigureAwait(false); + + return result.Item1; + } + + public async Task<Tuple<MediaSourceInfo, IDirectStreamProvider>> GetChannelStreamWithDirectStreamProvider(string channelId, string streamId, CancellationToken cancellationToken) + { + var result = await GetChannelStreamInternal(channelId, streamId, cancellationToken).ConfigureAwait(false); + + return new Tuple<MediaSourceInfo, IDirectStreamProvider>(result.Item2, result.Item1 as IDirectStreamProvider); + } + + private MediaSourceInfo CloneMediaSource(MediaSourceInfo mediaSource, bool enableStreamSharing) + { + var json = _jsonSerializer.SerializeToString(mediaSource); + mediaSource = _jsonSerializer.DeserializeFromString<MediaSourceInfo>(json); + + mediaSource.Id = Guid.NewGuid().ToString("N") + "_" + mediaSource.Id; + + //if (mediaSource.DateLiveStreamOpened.HasValue && enableStreamSharing) + //{ + // var ticks = (DateTime.UtcNow - mediaSource.DateLiveStreamOpened.Value).Ticks - TimeSpan.FromSeconds(10).Ticks; + // ticks = Math.Max(0, ticks); + // mediaSource.Path += "?t=" + ticks.ToString(CultureInfo.InvariantCulture) + "&s=" + mediaSource.DateLiveStreamOpened.Value.Ticks.ToString(CultureInfo.InvariantCulture); + //} + + return mediaSource; + } + + public async Task<LiveStream> GetLiveStream(string uniqueId) + { + await _liveStreamsSemaphore.WaitAsync().ConfigureAwait(false); + + try + { + return _liveStreams + .FirstOrDefault(i => string.Equals(i.UniqueId, uniqueId, StringComparison.OrdinalIgnoreCase)); + } + finally + { + _liveStreamsSemaphore.Release(); + } + + } + + private async Task<Tuple<LiveStream, MediaSourceInfo, ITunerHost>> GetChannelStreamInternal(string channelId, string streamId, CancellationToken cancellationToken) + { + _logger.Info("Streaming Channel " + channelId); + + await _liveStreamsSemaphore.WaitAsync(cancellationToken).ConfigureAwait(false); + + var result = _liveStreams.FirstOrDefault(i => string.Equals(i.OriginalStreamId, streamId, StringComparison.OrdinalIgnoreCase)); + + if (result != null && result.EnableStreamSharing) + { + var openedMediaSource = CloneMediaSource(result.OpenedMediaSource, result.EnableStreamSharing); + result.SharedStreamIds.Add(openedMediaSource.Id); + _liveStreamsSemaphore.Release(); + + _logger.Info("Live stream {0} consumer count is now {1}", streamId, result.ConsumerCount); + + return new Tuple<LiveStream, MediaSourceInfo, ITunerHost>(result, openedMediaSource, result.TunerHost); + } + + try + { + foreach (var hostInstance in _liveTvManager.TunerHosts) + { + try + { + result = await hostInstance.GetChannelStream(channelId, streamId, cancellationToken).ConfigureAwait(false); + + var openedMediaSource = CloneMediaSource(result.OpenedMediaSource, result.EnableStreamSharing); + + result.SharedStreamIds.Add(openedMediaSource.Id); + _liveStreams.Add(result); + + result.TunerHost = hostInstance; + result.OriginalStreamId = streamId; + + _logger.Info("Returning mediasource streamId {0}, mediaSource.Id {1}, mediaSource.LiveStreamId {2}", + streamId, openedMediaSource.Id, openedMediaSource.LiveStreamId); + + return new Tuple<LiveStream, MediaSourceInfo, ITunerHost>(result, openedMediaSource, hostInstance); + } + catch (FileNotFoundException) + { + } + catch (OperationCanceledException) + { + } + } + } + finally + { + _liveStreamsSemaphore.Release(); + } + + throw new Exception("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 async Task<List<MediaSourceInfo>> GetRecordingStreamMediaSources(string recordingId, CancellationToken cancellationToken) + { + ActiveRecordingInfo info; + + recordingId = recordingId.Replace("recording", string.Empty); + + if (_activeRecordings.TryGetValue(recordingId, out info)) + { + var stream = new MediaSourceInfo + { + Path = _appHost.GetLocalApiUrl("127.0.0.1") + "/LiveTv/LiveRecordings/" + recordingId + "/stream", + Id = recordingId, + SupportsDirectPlay = false, + SupportsDirectStream = true, + SupportsTranscoding = true, + IsInfiniteStream = true, + RequiresOpening = false, + RequiresClosing = false, + Protocol = MediaBrowser.Model.MediaInfo.MediaProtocol.Http, + BufferMs = 0 + }; + + var isAudio = false; + await new LiveStreamHelper(_mediaEncoder, _logger).AddMediaInfoWithProbe(stream, isAudio, cancellationToken).ConfigureAwait(false); + + return new List<MediaSourceInfo> + { + stream + }; + } + + throw new FileNotFoundException(); + } + + public async Task CloseLiveStream(string id, CancellationToken cancellationToken) + { + // Ignore the consumer id + //id = id.Substring(id.IndexOf('_') + 1); + + await _liveStreamsSemaphore.WaitAsync(cancellationToken).ConfigureAwait(false); + + try + { + var stream = _liveStreams.FirstOrDefault(i => i.SharedStreamIds.Contains(id)); + if (stream != null) + { + stream.SharedStreamIds.Remove(id); + + _logger.Info("Live stream {0} consumer count is now {1}", id, stream.ConsumerCount); + + if (stream.ConsumerCount <= 0) + { + _liveStreams.Remove(stream); + + _logger.Info("Closing live stream {0}", id); + + await stream.Close().ConfigureAwait(false); + _logger.Info("Live stream {0} closed successfully", id); + } + } + else + { + _logger.Warn("Live stream not found: {0}, unable to close", id); + } + } + catch (OperationCanceledException) + { + } + catch (Exception ex) + { + _logger.ErrorException("Error closing live stream", ex); + } + finally + { + _liveStreamsSemaphore.Release(); + } + } + + public Task RecordLiveStream(string id, CancellationToken cancellationToken) + { + return Task.FromResult(0); + } + + public Task ResetTuner(string id, CancellationToken cancellationToken) + { + return Task.FromResult(0); + } + + async void _timerProvider_TimerFired(object sender, GenericEventArgs<TimerInfo> e) + { + var timer = e.Argument; + + _logger.Info("Recording timer fired."); + + try + { + var recordingEndDate = timer.EndDate.AddSeconds(timer.PostPaddingSeconds); + + if (recordingEndDate <= DateTime.UtcNow) + { + _logger.Warn("Recording timer fired for updatedTimer {0}, Id: {1}, but the program has already ended.", timer.Name, timer.Id); + OnTimerOutOfDate(timer); + return; + } + + var activeRecordingInfo = new ActiveRecordingInfo + { + CancellationTokenSource = new CancellationTokenSource(), + Timer = timer + }; + + if (_activeRecordings.TryAdd(timer.Id, activeRecordingInfo)) + { + await RecordStream(timer, recordingEndDate, activeRecordingInfo, activeRecordingInfo.CancellationTokenSource.Token).ConfigureAwait(false); + } + else + { + _logger.Info("Skipping RecordStream because it's already in progress."); + } + } + catch (OperationCanceledException) + { + + } + catch (Exception ex) + { + _logger.ErrorException("Error recording stream", ex); + } + } + + private string GetRecordingPath(TimerInfo timer, out string seriesPath) + { + var recordPath = RecordingPath; + var config = GetConfiguration(); + seriesPath = null; + + if (timer.IsProgramSeries) + { + var customRecordingPath = config.SeriesRecordingPath; + var allowSubfolder = true; + if (!string.IsNullOrWhiteSpace(customRecordingPath)) + { + allowSubfolder = string.Equals(customRecordingPath, recordPath, StringComparison.OrdinalIgnoreCase); + recordPath = customRecordingPath; + } + + if (allowSubfolder && config.EnableRecordingSubfolders) + { + recordPath = Path.Combine(recordPath, "Series"); + } + + var folderName = _fileSystem.GetValidFilename(timer.Name).Trim(); + + // Can't use the year here in the folder name because it is the year of the episode, not the series. + recordPath = Path.Combine(recordPath, folderName); + + seriesPath = recordPath; + + if (timer.SeasonNumber.HasValue) + { + folderName = string.Format("Season {0}", timer.SeasonNumber.Value.ToString(CultureInfo.InvariantCulture)); + recordPath = Path.Combine(recordPath, folderName); + } + } + else if (timer.IsMovie) + { + var customRecordingPath = config.MovieRecordingPath; + var allowSubfolder = true; + if (!string.IsNullOrWhiteSpace(customRecordingPath)) + { + allowSubfolder = string.Equals(customRecordingPath, recordPath, StringComparison.OrdinalIgnoreCase); + recordPath = customRecordingPath; + } + + if (allowSubfolder && config.EnableRecordingSubfolders) + { + recordPath = Path.Combine(recordPath, "Movies"); + } + + var folderName = _fileSystem.GetValidFilename(timer.Name).Trim(); + if (timer.ProductionYear.HasValue) + { + folderName += " (" + timer.ProductionYear.Value.ToString(CultureInfo.InvariantCulture) + ")"; + } + recordPath = Path.Combine(recordPath, folderName); + } + else if (timer.IsKids) + { + if (config.EnableRecordingSubfolders) + { + recordPath = Path.Combine(recordPath, "Kids"); + } + + var folderName = _fileSystem.GetValidFilename(timer.Name).Trim(); + if (timer.ProductionYear.HasValue) + { + folderName += " (" + timer.ProductionYear.Value.ToString(CultureInfo.InvariantCulture) + ")"; + } + recordPath = Path.Combine(recordPath, folderName); + } + else if (timer.IsSports) + { + if (config.EnableRecordingSubfolders) + { + recordPath = Path.Combine(recordPath, "Sports"); + } + recordPath = Path.Combine(recordPath, _fileSystem.GetValidFilename(timer.Name).Trim()); + } + else + { + if (config.EnableRecordingSubfolders) + { + recordPath = Path.Combine(recordPath, "Other"); + } + recordPath = Path.Combine(recordPath, _fileSystem.GetValidFilename(timer.Name).Trim()); + } + + var recordingFileName = _fileSystem.GetValidFilename(RecordingHelper.GetRecordingName(timer)).Trim() + ".ts"; + + return Path.Combine(recordPath, recordingFileName); + } + + private async Task RecordStream(TimerInfo timer, DateTime recordingEndDate, + ActiveRecordingInfo activeRecordingInfo, CancellationToken cancellationToken) + { + if (timer == null) + { + throw new ArgumentNullException("timer"); + } + + ProgramInfo programInfo = null; + + if (!string.IsNullOrWhiteSpace(timer.ProgramId)) + { + programInfo = GetProgramInfoFromCache(timer.ChannelId, timer.ProgramId); + } + if (programInfo == null) + { + _logger.Info("Unable to find program with Id {0}. Will search using start date", timer.ProgramId); + programInfo = GetProgramInfoFromCache(timer.ChannelId, timer.StartDate); + } + + if (programInfo != null) + { + RecordingHelper.CopyProgramInfoToTimerInfo(programInfo, timer); + activeRecordingInfo.Program = programInfo; + } + + string seriesPath = null; + var recordPath = GetRecordingPath(timer, out seriesPath); + var recordingStatus = RecordingStatus.New; + + string liveStreamId = null; + + OnRecordingStatusChanged(); + + try + { + var allMediaSources = await GetChannelStreamMediaSources(timer.ChannelId, CancellationToken.None).ConfigureAwait(false); + + var liveStreamInfo = await GetChannelStreamInternal(timer.ChannelId, allMediaSources[0].Id, CancellationToken.None) + .ConfigureAwait(false); + + var mediaStreamInfo = liveStreamInfo.Item2; + liveStreamId = mediaStreamInfo.Id; + + // HDHR doesn't seem to release the tuner right away after first probing with ffmpeg + //await Task.Delay(3000, cancellationToken).ConfigureAwait(false); + + var recorder = await GetRecorder().ConfigureAwait(false); + + recordPath = recorder.GetOutputPath(mediaStreamInfo, recordPath); + recordPath = EnsureFileUnique(recordPath, timer.Id); + + _libraryManager.RegisterIgnoredPath(recordPath); + _libraryMonitor.ReportFileSystemChangeBeginning(recordPath); + _fileSystem.CreateDirectory(Path.GetDirectoryName(recordPath)); + activeRecordingInfo.Path = recordPath; + + var duration = recordingEndDate - DateTime.UtcNow; + + _logger.Info("Beginning recording. Will record for {0} minutes.", + duration.TotalMinutes.ToString(CultureInfo.InvariantCulture)); + + _logger.Info("Writing file to path: " + recordPath); + _logger.Info("Opening recording stream from tuner provider"); + + Action onStarted = () => + { + timer.Status = RecordingStatus.InProgress; + _timerProvider.AddOrUpdate(timer, false); + + SaveNfo(timer, recordPath, seriesPath); + EnforceKeepUpTo(timer); + }; + + await recorder.Record(mediaStreamInfo, recordPath, duration, onStarted, cancellationToken) + .ConfigureAwait(false); + + recordingStatus = RecordingStatus.Completed; + _logger.Info("Recording completed: {0}", recordPath); + } + catch (OperationCanceledException) + { + _logger.Info("Recording stopped: {0}", recordPath); + recordingStatus = RecordingStatus.Completed; + } + catch (Exception ex) + { + _logger.ErrorException("Error recording to {0}", ex, recordPath); + recordingStatus = RecordingStatus.Error; + } + + if (!string.IsNullOrWhiteSpace(liveStreamId)) + { + try + { + await CloseLiveStream(liveStreamId, CancellationToken.None).ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.ErrorException("Error closing live stream", ex); + } + } + + _libraryManager.UnRegisterIgnoredPath(recordPath); + _libraryMonitor.ReportFileSystemChangeComplete(recordPath, true); + + ActiveRecordingInfo removed; + _activeRecordings.TryRemove(timer.Id, out removed); + + if (recordingStatus != RecordingStatus.Completed && DateTime.UtcNow < timer.EndDate) + { + const int retryIntervalSeconds = 60; + _logger.Info("Retrying recording in {0} seconds.", retryIntervalSeconds); + + timer.Status = RecordingStatus.New; + timer.StartDate = DateTime.UtcNow.AddSeconds(retryIntervalSeconds); + _timerProvider.AddOrUpdate(timer); + } + else if (_fileSystem.FileExists(recordPath)) + { + timer.RecordingPath = recordPath; + timer.Status = RecordingStatus.Completed; + _timerProvider.AddOrUpdate(timer, false); + OnSuccessfulRecording(timer, recordPath); + } + else + { + _timerProvider.Delete(timer); + } + + OnRecordingStatusChanged(); + } + + private void OnRecordingStatusChanged() + { + EventHelper.FireEventIfNotNull(RecordingStatusChanged, this, new RecordingStatusChangedEventArgs + { + + }, _logger); + } + + private async void EnforceKeepUpTo(TimerInfo timer) + { + if (string.IsNullOrWhiteSpace(timer.SeriesTimerId)) + { + return; + } + + var seriesTimerId = timer.SeriesTimerId; + var seriesTimer = _seriesTimerProvider.GetAll().FirstOrDefault(i => string.Equals(i.Id, seriesTimerId, StringComparison.OrdinalIgnoreCase)); + + if (seriesTimer == null || seriesTimer.KeepUpTo <= 1) + { + return; + } + + if (_disposed) + { + return; + } + + await _recordingDeleteSemaphore.WaitAsync().ConfigureAwait(false); + + try + { + if (_disposed) + { + return; + } + + var timersToDelete = _timerProvider.GetAll() + .Where(i => i.Status == RecordingStatus.Completed && !string.IsNullOrWhiteSpace(i.RecordingPath)) + .Where(i => string.Equals(i.SeriesTimerId, seriesTimerId, StringComparison.OrdinalIgnoreCase)) + .OrderByDescending(i => i.EndDate) + .Where(i => _fileSystem.FileExists(i.RecordingPath)) + .Skip(seriesTimer.KeepUpTo - 1) + .ToList(); + + await DeleteLibraryItemsForTimers(timersToDelete).ConfigureAwait(false); + } + finally + { + _recordingDeleteSemaphore.Release(); + } + } + + private readonly SemaphoreSlim _recordingDeleteSemaphore = new SemaphoreSlim(1, 1); + private async Task DeleteLibraryItemsForTimers(List<TimerInfo> timers) + { + foreach (var timer in timers) + { + if (_disposed) + { + return; + } + + try + { + await DeleteLibraryItemForTimer(timer).ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.ErrorException("Error deleting recording", ex); + } + } + } + + private async Task DeleteLibraryItemForTimer(TimerInfo timer) + { + var libraryItem = _libraryManager.FindByPath(timer.RecordingPath, false); + + if (libraryItem != null) + { + await _libraryManager.DeleteItem(libraryItem, new DeleteOptions + { + DeleteFileLocation = true + }); + } + else + { + try + { + _fileSystem.DeleteFile(timer.RecordingPath); + } + catch (IOException) + { + + } + } + + _timerProvider.Delete(timer); + } + + private string EnsureFileUnique(string path, string timerId) + { + var originalPath = path; + var index = 1; + + while (FileExists(path, timerId)) + { + var parent = Path.GetDirectoryName(originalPath); + var name = Path.GetFileNameWithoutExtension(originalPath); + name += "-" + index.ToString(CultureInfo.InvariantCulture); + + path = Path.ChangeExtension(Path.Combine(parent, name), Path.GetExtension(originalPath)); + index++; + } + + return path; + } + + private bool FileExists(string path, string timerId) + { + if (_fileSystem.FileExists(path)) + { + return true; + } + + var hasRecordingAtPath = _activeRecordings + .Values + .ToList() + .Any(i => string.Equals(i.Path, path, StringComparison.OrdinalIgnoreCase) && !string.Equals(i.Timer.Id, timerId, StringComparison.OrdinalIgnoreCase)); + + if (hasRecordingAtPath) + { + return true; + } + return false; + } + + private async Task<IRecorder> GetRecorder() + { + var config = GetConfiguration(); + + if (config.EnableRecordingEncoding) + { + var regInfo = await _liveTvManager.GetRegistrationInfo("embytvrecordingconversion").ConfigureAwait(false); + + if (regInfo.IsValid) + { + return new EncodedRecorder(_logger, _fileSystem, _mediaEncoder, _config.ApplicationPaths, _jsonSerializer, config, _httpClient, _processFactory); + } + } + + return new DirectRecorder(_logger, _httpClient, _fileSystem); + } + + private async void OnSuccessfulRecording(TimerInfo timer, string path) + { + //if (timer.IsProgramSeries && GetConfiguration().EnableAutoOrganize) + //{ + // try + // { + // // this is to account for the library monitor holding a lock for additional time after the change is complete. + // // ideally this shouldn't be hard-coded + // await Task.Delay(30000).ConfigureAwait(false); + + // var organize = new EpisodeFileOrganizer(_organizationService, _config, _fileSystem, _logger, _libraryManager, _libraryMonitor, _providerManager); + + // var result = await organize.OrganizeEpisodeFile(path, _config.GetAutoOrganizeOptions(), false, CancellationToken.None).ConfigureAwait(false); + + // if (result.Status == FileSortingStatus.Success) + // { + // return; + // } + // } + // catch (Exception ex) + // { + // _logger.ErrorException("Error processing new recording", ex); + // } + //} + } + + private void SaveNfo(TimerInfo timer, string recordingPath, string seriesPath) + { + try + { + if (timer.IsProgramSeries) + { + SaveSeriesNfo(timer, recordingPath, seriesPath); + } + else if (!timer.IsMovie || timer.IsSports || timer.IsNews) + { + SaveVideoNfo(timer, recordingPath); + } + } + catch (Exception ex) + { + _logger.ErrorException("Error saving nfo", ex); + } + } + + private void SaveSeriesNfo(TimerInfo timer, string recordingPath, string seriesPath) + { + var nfoPath = Path.Combine(seriesPath, "tvshow.nfo"); + + if (_fileSystem.FileExists(nfoPath)) + { + return; + } + + using (var stream = _fileSystem.GetFileStream(nfoPath, FileOpenMode.Create, FileAccessMode.Write, FileShareMode.Read)) + { + var settings = new XmlWriterSettings + { + Indent = true, + Encoding = Encoding.UTF8, + CloseOutput = false + }; + + using (XmlWriter writer = XmlWriter.Create(stream, settings)) + { + writer.WriteStartDocument(true); + writer.WriteStartElement("tvshow"); + + if (!string.IsNullOrWhiteSpace(timer.Name)) + { + writer.WriteElementString("title", timer.Name); + } + + writer.WriteEndElement(); + writer.WriteEndDocument(); + } + } + } + + public const string DateAddedFormat = "yyyy-MM-dd HH:mm:ss"; + private void SaveVideoNfo(TimerInfo timer, string recordingPath) + { + var nfoPath = Path.ChangeExtension(recordingPath, ".nfo"); + + if (_fileSystem.FileExists(nfoPath)) + { + return; + } + + using (var stream = _fileSystem.GetFileStream(nfoPath, FileOpenMode.Create, FileAccessMode.Write, FileShareMode.Read)) + { + var settings = new XmlWriterSettings + { + Indent = true, + Encoding = Encoding.UTF8, + CloseOutput = false + }; + + using (XmlWriter writer = XmlWriter.Create(stream, settings)) + { + writer.WriteStartDocument(true); + writer.WriteStartElement("movie"); + + if (!string.IsNullOrWhiteSpace(timer.Name)) + { + writer.WriteElementString("title", timer.Name); + } + + writer.WriteElementString("dateadded", DateTime.UtcNow.ToLocalTime().ToString(DateAddedFormat)); + + if (timer.ProductionYear.HasValue) + { + writer.WriteElementString("year", timer.ProductionYear.Value.ToString(CultureInfo.InvariantCulture)); + } + if (!string.IsNullOrEmpty(timer.OfficialRating)) + { + writer.WriteElementString("mpaa", timer.OfficialRating); + } + + var overview = (timer.Overview ?? string.Empty) + .StripHtml() + .Replace(""", "'"); + + writer.WriteElementString("plot", overview); + writer.WriteElementString("lockdata", true.ToString().ToLower()); + + if (timer.CommunityRating.HasValue) + { + writer.WriteElementString("rating", timer.CommunityRating.Value.ToString(CultureInfo.InvariantCulture)); + } + + if (timer.IsSports) + { + AddGenre(timer.Genres, "Sports"); + } + if (timer.IsKids) + { + AddGenre(timer.Genres, "Kids"); + AddGenre(timer.Genres, "Children"); + } + if (timer.IsNews) + { + AddGenre(timer.Genres, "News"); + } + + foreach (var genre in timer.Genres) + { + writer.WriteElementString("genre", genre); + } + + if (!string.IsNullOrWhiteSpace(timer.ShortOverview)) + { + writer.WriteElementString("outline", timer.ShortOverview); + } + + if (!string.IsNullOrWhiteSpace(timer.HomePageUrl)) + { + writer.WriteElementString("website", timer.HomePageUrl); + } + + writer.WriteEndElement(); + writer.WriteEndDocument(); + } + } + } + + private void AddGenre(List<string> genres, string genre) + { + if (!genres.Contains(genre, StringComparer.OrdinalIgnoreCase)) + { + genres.Add(genre); + } + } + + private ProgramInfo GetProgramInfoFromCache(string channelId, string programId) + { + var epgData = GetEpgDataForChannel(channelId); + return epgData.FirstOrDefault(p => string.Equals(p.Id, programId, StringComparison.OrdinalIgnoreCase)); + } + + private ProgramInfo GetProgramInfoFromCache(string channelId, DateTime startDateUtc) + { + var epgData = GetEpgDataForChannel(channelId); + var startDateTicks = startDateUtc.Ticks; + // Find the first program that starts within 3 minutes + return epgData.FirstOrDefault(p => Math.Abs(startDateTicks - p.StartDate.Ticks) <= TimeSpan.FromMinutes(3).Ticks); + } + + private LiveTvOptions GetConfiguration() + { + return _config.GetConfiguration<LiveTvOptions>("livetv"); + } + + private bool ShouldCancelTimerForSeriesTimer(SeriesTimerInfo seriesTimer, TimerInfo timer) + { + if (!seriesTimer.RecordAnyTime) + { + if (Math.Abs(seriesTimer.StartDate.TimeOfDay.Ticks - timer.StartDate.TimeOfDay.Ticks) >= TimeSpan.FromMinutes(5).Ticks) + { + return true; + } + + if (!seriesTimer.Days.Contains(timer.StartDate.ToLocalTime().DayOfWeek)) + { + return true; + } + } + + if (seriesTimer.RecordNewOnly && timer.IsRepeat) + { + return true; + } + + if (!seriesTimer.RecordAnyChannel && !string.Equals(timer.ChannelId, seriesTimer.ChannelId, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + return seriesTimer.SkipEpisodesInLibrary && IsProgramAlreadyInLibrary(timer); + } + + private async Task UpdateTimersForSeriesTimer(List<ProgramInfo> epgData, SeriesTimerInfo seriesTimer, bool deleteInvalidTimers) + { + var allTimers = GetTimersForSeries(seriesTimer, epgData) + .ToList(); + + var registration = await _liveTvManager.GetRegistrationInfo("seriesrecordings").ConfigureAwait(false); + + if (registration.IsValid) + { + foreach (var timer in allTimers) + { + var existingTimer = _timerProvider.GetTimer(timer.Id); + + if (existingTimer == null) + { + if (ShouldCancelTimerForSeriesTimer(seriesTimer, timer)) + { + timer.Status = RecordingStatus.Cancelled; + } + _timerProvider.Add(timer); + } + else + { + // Only update if not currently active + ActiveRecordingInfo activeRecordingInfo; + if (!_activeRecordings.TryGetValue(timer.Id, out activeRecordingInfo)) + { + UpdateExistingTimerWithNewMetadata(existingTimer, timer); + + if (ShouldCancelTimerForSeriesTimer(seriesTimer, timer)) + { + existingTimer.Status = RecordingStatus.Cancelled; + } + + existingTimer.SeriesTimerId = seriesTimer.Id; + _timerProvider.Update(existingTimer); + } + } + } + } + + if (deleteInvalidTimers) + { + var allTimerIds = allTimers + .Select(i => i.Id) + .ToList(); + + var deleteStatuses = new List<RecordingStatus> + { + RecordingStatus.New + }; + + var deletes = _timerProvider.GetAll() + .Where(i => string.Equals(i.SeriesTimerId, seriesTimer.Id, StringComparison.OrdinalIgnoreCase)) + .Where(i => !allTimerIds.Contains(i.Id, StringComparer.OrdinalIgnoreCase) && i.StartDate > DateTime.UtcNow) + .Where(i => deleteStatuses.Contains(i.Status)) + .ToList(); + + foreach (var timer in deletes) + { + CancelTimerInternal(timer.Id, false); + } + } + } + + private IEnumerable<TimerInfo> GetTimersForSeries(SeriesTimerInfo seriesTimer, + IEnumerable<ProgramInfo> allPrograms) + { + if (seriesTimer == null) + { + throw new ArgumentNullException("seriesTimer"); + } + if (allPrograms == null) + { + throw new ArgumentNullException("allPrograms"); + } + + // Exclude programs that have already ended + allPrograms = allPrograms.Where(i => i.EndDate > DateTime.UtcNow); + + allPrograms = GetProgramsForSeries(seriesTimer, allPrograms); + + return allPrograms.Select(i => RecordingHelper.CreateTimer(i, seriesTimer)); + } + + private bool IsProgramAlreadyInLibrary(TimerInfo program) + { + if ((program.EpisodeNumber.HasValue && program.SeasonNumber.HasValue) || !string.IsNullOrWhiteSpace(program.EpisodeTitle)) + { + var seriesIds = _libraryManager.GetItemIds(new InternalItemsQuery + { + IncludeItemTypes = new[] { typeof(Series).Name }, + Name = program.Name + + }).Select(i => i.ToString("N")).ToArray(); + + if (seriesIds.Length == 0) + { + return false; + } + + if (program.EpisodeNumber.HasValue && program.SeasonNumber.HasValue) + { + var result = _libraryManager.GetItemsResult(new InternalItemsQuery + { + IncludeItemTypes = new[] { typeof(Episode).Name }, + ParentIndexNumber = program.SeasonNumber.Value, + IndexNumber = program.EpisodeNumber.Value, + AncestorIds = seriesIds, + IsVirtualItem = false + }); + + if (result.TotalRecordCount > 0) + { + return true; + } + } + } + + return false; + } + + private IEnumerable<ProgramInfo> GetProgramsForSeries(SeriesTimerInfo seriesTimer, IEnumerable<ProgramInfo> allPrograms) + { + 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(_config.CommonApplicationPaths.CachePath, "embytvepg", channelId + ".json"); + } + + private readonly object _epgLock = new object(); + private void SaveEpgDataForChannel(string channelId, List<ProgramInfo> epgData) + { + var path = GetChannelEpgCachePath(channelId); + _fileSystem.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(); + } + + private bool _disposed; + public void Dispose() + { + _disposed = true; + foreach (var pair in _activeRecordings.ToList()) + { + pair.Value.CancellationTokenSource.Cancel(); + } + } + + public List<VirtualFolderInfo> GetRecordingFolders() + { + var list = new List<VirtualFolderInfo>(); + + var defaultFolder = RecordingPath; + var defaultName = "Recordings"; + + if (_fileSystem.DirectoryExists(defaultFolder)) + { + list.Add(new VirtualFolderInfo + { + Locations = new List<string> { defaultFolder }, + Name = defaultName + }); + } + + var customPath = GetConfiguration().MovieRecordingPath; + if ((!string.IsNullOrWhiteSpace(customPath) && !string.Equals(customPath, defaultFolder, StringComparison.OrdinalIgnoreCase)) && _fileSystem.DirectoryExists(customPath)) + { + list.Add(new VirtualFolderInfo + { + Locations = new List<string> { customPath }, + Name = "Recorded Movies", + CollectionType = CollectionType.Movies + }); + } + + customPath = GetConfiguration().SeriesRecordingPath; + if ((!string.IsNullOrWhiteSpace(customPath) && !string.Equals(customPath, defaultFolder, StringComparison.OrdinalIgnoreCase)) && _fileSystem.DirectoryExists(customPath)) + { + list.Add(new VirtualFolderInfo + { + Locations = new List<string> { customPath }, + Name = "Recorded Series", + CollectionType = CollectionType.TvShows + }); + } + + return list; + } + + class ActiveRecordingInfo + { + public string Path { get; set; } + public TimerInfo Timer { get; set; } + public ProgramInfo Program { get; set; } + public CancellationTokenSource CancellationTokenSource { get; set; } + } + } +}
\ No newline at end of file diff --git a/Emby.Server.Implementations/LiveTv/EmbyTV/EmbyTVRegistration.cs b/Emby.Server.Implementations/LiveTv/EmbyTV/EmbyTVRegistration.cs new file mode 100644 index 000000000..b339537ae --- /dev/null +++ b/Emby.Server.Implementations/LiveTv/EmbyTV/EmbyTVRegistration.cs @@ -0,0 +1,36 @@ +using System.Threading.Tasks; +using MediaBrowser.Common.Security; + +namespace Emby.Server.Implementations.LiveTv.EmbyTV +{ + public class EmbyTVRegistration : IRequiresRegistration + { + private readonly ISecurityManager _securityManager; + + public static EmbyTVRegistration Instance; + + public EmbyTVRegistration(ISecurityManager securityManager) + { + _securityManager = securityManager; + Instance = this; + } + + private bool? _isXmlTvEnabled; + + public Task LoadRegistrationInfoAsync() + { + _isXmlTvEnabled = null; + return Task.FromResult(true); + } + + public async Task<bool> EnableXmlTv() + { + if (!_isXmlTvEnabled.HasValue) + { + var info = await _securityManager.GetRegistrationStatus("xmltv").ConfigureAwait(false); + _isXmlTvEnabled = info.IsValid; + } + return _isXmlTvEnabled.Value; + } + } +} diff --git a/Emby.Server.Implementations/LiveTv/EmbyTV/EncodedRecorder.cs b/Emby.Server.Implementations/LiveTv/EmbyTV/EncodedRecorder.cs new file mode 100644 index 000000000..93ba9c420 --- /dev/null +++ b/Emby.Server.Implementations/LiveTv/EmbyTV/EncodedRecorder.cs @@ -0,0 +1,325 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using MediaBrowser.Model.IO; +using MediaBrowser.Common.IO; +using MediaBrowser.Common.Net; +using MediaBrowser.Controller; +using MediaBrowser.Controller.IO; +using MediaBrowser.Controller.MediaEncoding; +using MediaBrowser.Model.Diagnostics; +using MediaBrowser.Model.Dto; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.LiveTv; +using MediaBrowser.Model.Logging; +using MediaBrowser.Model.Serialization; + +namespace Emby.Server.Implementations.LiveTv.EmbyTV +{ + public class EncodedRecorder : IRecorder + { + private readonly ILogger _logger; + private readonly IFileSystem _fileSystem; + private readonly IHttpClient _httpClient; + private readonly IMediaEncoder _mediaEncoder; + private readonly IServerApplicationPaths _appPaths; + private readonly LiveTvOptions _liveTvOptions; + private bool _hasExited; + private Stream _logFileStream; + private string _targetPath; + private IProcess _process; + private readonly IProcessFactory _processFactory; + private readonly IJsonSerializer _json; + private readonly TaskCompletionSource<bool> _taskCompletionSource = new TaskCompletionSource<bool>(); + + public EncodedRecorder(ILogger logger, IFileSystem fileSystem, IMediaEncoder mediaEncoder, IServerApplicationPaths appPaths, IJsonSerializer json, LiveTvOptions liveTvOptions, IHttpClient httpClient, IProcessFactory processFactory) + { + _logger = logger; + _fileSystem = fileSystem; + _mediaEncoder = mediaEncoder; + _appPaths = appPaths; + _json = json; + _liveTvOptions = liveTvOptions; + _httpClient = httpClient; + _processFactory = processFactory; + } + + private string OutputFormat + { + get + { + var format = _liveTvOptions.RecordingEncodingFormat; + + if (string.Equals(format, "mkv", StringComparison.OrdinalIgnoreCase) || string.Equals(_liveTvOptions.RecordedVideoCodec, "copy", StringComparison.OrdinalIgnoreCase)) + { + return "mkv"; + } + + return "mp4"; + } + } + + public string GetOutputPath(MediaSourceInfo mediaSource, string targetFile) + { + return Path.ChangeExtension(targetFile, "." + OutputFormat); + } + + public async Task Record(MediaSourceInfo mediaSource, string targetFile, TimeSpan duration, Action onStarted, CancellationToken cancellationToken) + { + var durationToken = new CancellationTokenSource(duration); + cancellationToken = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, durationToken.Token).Token; + + await RecordFromFile(mediaSource, mediaSource.Path, targetFile, duration, onStarted, cancellationToken).ConfigureAwait(false); + + _logger.Info("Recording completed to file {0}", targetFile); + } + + private Task RecordFromFile(MediaSourceInfo mediaSource, string inputFile, string targetFile, TimeSpan duration, Action onStarted, CancellationToken cancellationToken) + { + _targetPath = targetFile; + _fileSystem.CreateDirectory(Path.GetDirectoryName(targetFile)); + + var process = _processFactory.Create(new ProcessOptions + { + CreateNoWindow = true, + UseShellExecute = false, + + // Must consume both stdout and stderr or deadlocks may occur + //RedirectStandardOutput = true, + RedirectStandardError = true, + RedirectStandardInput = true, + + FileName = _mediaEncoder.EncoderPath, + Arguments = GetCommandLineArgs(mediaSource, inputFile, targetFile, duration), + + IsHidden = true, + ErrorDialog = false, + EnableRaisingEvents = true + }); + + _process = process; + + var commandLineLogMessage = process.StartInfo.FileName + " " + process.StartInfo.Arguments; + _logger.Info(commandLineLogMessage); + + var logFilePath = Path.Combine(_appPaths.LogDirectoryPath, "record-transcode-" + Guid.NewGuid() + ".txt"); + _fileSystem.CreateDirectory(Path.GetDirectoryName(logFilePath)); + + // FFMpeg writes debug/error info to stderr. This is useful when debugging so let's put it in the log directory. + _logFileStream = _fileSystem.GetFileStream(logFilePath, FileOpenMode.Create, FileAccessMode.Write, FileShareMode.Read, true); + + var commandLineLogMessageBytes = Encoding.UTF8.GetBytes(_json.SerializeToString(mediaSource) + Environment.NewLine + Environment.NewLine + commandLineLogMessage + Environment.NewLine + Environment.NewLine); + _logFileStream.Write(commandLineLogMessageBytes, 0, commandLineLogMessageBytes.Length); + + process.Exited += (sender, args) => OnFfMpegProcessExited(process, inputFile); + + process.Start(); + + cancellationToken.Register(Stop); + + // MUST read both stdout and stderr asynchronously or a deadlock may occurr + //process.BeginOutputReadLine(); + + onStarted(); + + // Important - don't await the log task or we won't be able to kill ffmpeg when the user stops playback + StartStreamingLog(process.StandardError.BaseStream, _logFileStream); + + _logger.Info("ffmpeg recording process started for {0}", _targetPath); + + return _taskCompletionSource.Task; + } + + private string GetCommandLineArgs(MediaSourceInfo mediaSource, string inputTempFile, string targetFile, TimeSpan duration) + { + string videoArgs; + if (EncodeVideo(mediaSource)) + { + var maxBitrate = 25000000; + videoArgs = string.Format( + "-codec:v:0 libx264 -force_key_frames \"expr:gte(t,n_forced*5)\" {0} -pix_fmt yuv420p -preset superfast -crf 23 -b:v {1} -maxrate {1} -bufsize ({1}*2) -vsync -1 -profile:v high -level 41", + GetOutputSizeParam(), + maxBitrate.ToString(CultureInfo.InvariantCulture)); + } + else + { + videoArgs = "-codec:v:0 copy"; + } + + var durationParam = " -t " + _mediaEncoder.GetTimeParameter(duration.Ticks); + var inputModifiers = "-fflags +genpts -async 1 -vsync -1"; + var commandLineArgs = "-i \"{0}\"{4} -sn {2} -map_metadata -1 -threads 0 {3} -y \"{1}\""; + + long startTimeTicks = 0; + //if (mediaSource.DateLiveStreamOpened.HasValue) + //{ + // var elapsed = DateTime.UtcNow - mediaSource.DateLiveStreamOpened.Value; + // elapsed -= TimeSpan.FromSeconds(10); + // if (elapsed.TotalSeconds >= 0) + // { + // startTimeTicks = elapsed.Ticks + startTimeTicks; + // } + //} + + if (mediaSource.ReadAtNativeFramerate) + { + inputModifiers += " -re"; + } + + if (startTimeTicks > 0) + { + inputModifiers = "-ss " + _mediaEncoder.GetTimeParameter(startTimeTicks) + " " + inputModifiers; + } + + commandLineArgs = string.Format(commandLineArgs, inputTempFile, targetFile, videoArgs, GetAudioArgs(mediaSource), durationParam); + + return inputModifiers + " " + commandLineArgs; + } + + private string GetAudioArgs(MediaSourceInfo mediaSource) + { + var mediaStreams = mediaSource.MediaStreams ?? new List<MediaStream>(); + var inputAudioCodec = mediaStreams.Where(i => i.Type == MediaStreamType.Audio).Select(i => i.Codec).FirstOrDefault() ?? string.Empty; + + // do not copy aac because many players have difficulty with aac_latm + if (_liveTvOptions.EnableOriginalAudioWithEncodedRecordings && !string.Equals(inputAudioCodec, "aac", StringComparison.OrdinalIgnoreCase)) + { + return "-codec:a:0 copy"; + } + + var audioChannels = 2; + var audioStream = mediaStreams.FirstOrDefault(i => i.Type == MediaStreamType.Audio); + if (audioStream != null) + { + audioChannels = audioStream.Channels ?? audioChannels; + } + return "-codec:a:0 aac -strict experimental -ab 320000"; + } + + private bool EncodeVideo(MediaSourceInfo mediaSource) + { + if (string.Equals(_liveTvOptions.RecordedVideoCodec, "copy", StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + var mediaStreams = mediaSource.MediaStreams ?? new List<MediaStream>(); + return !mediaStreams.Any(i => i.Type == MediaStreamType.Video && string.Equals(i.Codec, "h264", StringComparison.OrdinalIgnoreCase) && !i.IsInterlaced); + } + + protected string GetOutputSizeParam() + { + var filters = new List<string>(); + + filters.Add("yadif=0:-1:0"); + + var output = string.Empty; + + if (filters.Count > 0) + { + output += string.Format(" -vf \"{0}\"", string.Join(",", filters.ToArray())); + } + + return output; + } + + private void Stop() + { + if (!_hasExited) + { + try + { + _logger.Info("Killing ffmpeg recording process for {0}", _targetPath); + + //process.Kill(); + _process.StandardInput.WriteLine("q"); + } + catch (Exception ex) + { + _logger.ErrorException("Error killing transcoding job for {0}", ex, _targetPath); + } + } + } + + /// <summary> + /// Processes the exited. + /// </summary> + private void OnFfMpegProcessExited(IProcess process, string inputFile) + { + _hasExited = true; + + DisposeLogStream(); + + try + { + var exitCode = process.ExitCode; + + _logger.Info("FFMpeg recording exited with code {0} for {1}", exitCode, _targetPath); + + if (exitCode == 0) + { + _taskCompletionSource.TrySetResult(true); + } + else + { + _taskCompletionSource.TrySetException(new Exception(string.Format("Recording for {0} failed. Exit code {1}", _targetPath, exitCode))); + } + } + catch + { + _logger.Error("FFMpeg recording exited with an error for {0}.", _targetPath); + _taskCompletionSource.TrySetException(new Exception(string.Format("Recording for {0} failed", _targetPath))); + } + } + + private void DisposeLogStream() + { + if (_logFileStream != null) + { + try + { + _logFileStream.Dispose(); + } + catch (Exception ex) + { + _logger.ErrorException("Error disposing recording log stream", ex); + } + + _logFileStream = null; + } + } + + private async void StartStreamingLog(Stream source, Stream target) + { + try + { + using (var reader = new StreamReader(source)) + { + while (!reader.EndOfStream) + { + var line = await reader.ReadLineAsync().ConfigureAwait(false); + + var bytes = Encoding.UTF8.GetBytes(Environment.NewLine + line); + + await target.WriteAsync(bytes, 0, bytes.Length).ConfigureAwait(false); + await target.FlushAsync().ConfigureAwait(false); + } + } + } + catch (ObjectDisposedException) + { + // Don't spam the log. This doesn't seem to throw in windows, but sometimes under linux + } + catch (Exception ex) + { + _logger.ErrorException("Error reading ffmpeg recording log", ex); + } + } + } +}
\ No newline at end of file diff --git a/Emby.Server.Implementations/LiveTv/EmbyTV/EntryPoint.cs b/Emby.Server.Implementations/LiveTv/EmbyTV/EntryPoint.cs new file mode 100644 index 000000000..139cf570e --- /dev/null +++ b/Emby.Server.Implementations/LiveTv/EmbyTV/EntryPoint.cs @@ -0,0 +1,16 @@ +using MediaBrowser.Controller.Plugins; + +namespace Emby.Server.Implementations.LiveTv.EmbyTV +{ + public class EntryPoint : IServerEntryPoint + { + public void Run() + { + EmbyTV.Current.Start(); + } + + public void Dispose() + { + } + } +} diff --git a/Emby.Server.Implementations/LiveTv/EmbyTV/IRecorder.cs b/Emby.Server.Implementations/LiveTv/EmbyTV/IRecorder.cs new file mode 100644 index 000000000..3b5e60c4a --- /dev/null +++ b/Emby.Server.Implementations/LiveTv/EmbyTV/IRecorder.cs @@ -0,0 +1,23 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using MediaBrowser.Model.Dto; + +namespace Emby.Server.Implementations.LiveTv.EmbyTV +{ + public interface IRecorder + { + /// <summary> + /// Records the specified media source. + /// </summary> + /// <param name="mediaSource">The media source.</param> + /// <param name="targetFile">The target file.</param> + /// <param name="duration">The duration.</param> + /// <param name="onStarted">The on started.</param> + /// <param name="cancellationToken">The cancellation token.</param> + /// <returns>Task.</returns> + Task Record(MediaSourceInfo mediaSource, string targetFile, TimeSpan duration, Action onStarted, CancellationToken cancellationToken); + + string GetOutputPath(MediaSourceInfo mediaSource, string targetFile); + } +} diff --git a/Emby.Server.Implementations/LiveTv/EmbyTV/ItemDataProvider.cs b/Emby.Server.Implementations/LiveTv/EmbyTV/ItemDataProvider.cs new file mode 100644 index 000000000..ded4f04c4 --- /dev/null +++ b/Emby.Server.Implementations/LiveTv/EmbyTV/ItemDataProvider.cs @@ -0,0 +1,146 @@ +using MediaBrowser.Model.Logging; +using MediaBrowser.Model.Serialization; +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using MediaBrowser.Common.IO; +using MediaBrowser.Controller.IO; +using MediaBrowser.Model.IO; + +namespace Emby.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; + private readonly IFileSystem _fileSystem; + + public ItemDataProvider(IFileSystem fileSystem, IJsonSerializer jsonSerializer, ILogger logger, string dataPath, Func<T, T, bool> equalityComparer) + { + Logger = logger; + _dataPath = dataPath; + EqualityComparer = equalityComparer; + _jsonSerializer = jsonSerializer; + _fileSystem = fileSystem; + } + + public IReadOnlyList<T> GetAll() + { + lock (_fileDataLock) + { + if (_items == null) + { + Logger.Info("Loading live tv data from {0}", _dataPath); + _items = GetItemsFromFile(_dataPath); + } + return _items.ToList(); + } + } + + private List<T> GetItemsFromFile(string path) + { + var jsonFile = path + ".json"; + + try + { + return _jsonSerializer.DeserializeFromFile<List<T>>(jsonFile) ?? new List<T>(); + } + catch (FileNotFoundException) + { + } + catch (IOException ex) + { + Logger.ErrorException("Error deserializing {0}", ex, jsonFile); + } + catch (Exception ex) + { + Logger.ErrorException("Error deserializing {0}", ex, jsonFile); + } + return new List<T>(); + } + + private void UpdateList(List<T> newList) + { + if (newList == null) + { + throw new ArgumentNullException("newList"); + } + + var file = _dataPath + ".json"; + _fileSystem.CreateDirectory(Path.GetDirectoryName(file)); + + lock (_fileDataLock) + { + _jsonSerializer.SerializeToFile(newList, file); + _items = newList; + } + } + + public virtual void Update(T item) + { + if (item == null) + { + throw new ArgumentNullException("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) + { + if (item == null) + { + throw new ArgumentNullException("item"); + } + + var list = GetAll().ToList(); + + if (list.Any(i => EqualityComparer(i, item))) + { + throw new ArgumentException("item already exists"); + } + + list.Add(item); + + UpdateList(list); + } + + public void AddOrUpdate(T item) + { + var list = GetAll().ToList(); + + if (!list.Any(i => EqualityComparer(i, item))) + { + Add(item); + } + else + { + Update(item); + } + } + + public virtual void Delete(T item) + { + var list = GetAll().Where(i => !EqualityComparer(i, item)).ToList(); + + UpdateList(list); + } + } +} diff --git a/Emby.Server.Implementations/LiveTv/EmbyTV/RecordingHelper.cs b/Emby.Server.Implementations/LiveTv/EmbyTV/RecordingHelper.cs new file mode 100644 index 000000000..0ae5971bc --- /dev/null +++ b/Emby.Server.Implementations/LiveTv/EmbyTV/RecordingHelper.cs @@ -0,0 +1,105 @@ +using MediaBrowser.Common.Extensions; +using MediaBrowser.Controller.LiveTv; +using System; +using System.Globalization; +using MediaBrowser.Model.LiveTv; + +namespace Emby.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 seriesTimer) + { + var timer = new TimerInfo(); + + timer.ChannelId = parent.ChannelId; + timer.Id = (seriesTimer.Id + parent.Id).GetMD5().ToString("N"); + timer.StartDate = parent.StartDate; + timer.EndDate = parent.EndDate; + timer.ProgramId = parent.Id; + timer.PrePaddingSeconds = seriesTimer.PrePaddingSeconds; + timer.PostPaddingSeconds = seriesTimer.PostPaddingSeconds; + timer.IsPostPaddingRequired = seriesTimer.IsPostPaddingRequired; + timer.IsPrePaddingRequired = seriesTimer.IsPrePaddingRequired; + timer.KeepUntil = seriesTimer.KeepUntil; + timer.Priority = seriesTimer.Priority; + timer.Name = parent.Name; + timer.Overview = parent.Overview; + timer.SeriesTimerId = seriesTimer.Id; + + CopyProgramInfoToTimerInfo(parent, timer); + + return timer; + } + + public static void CopyProgramInfoToTimerInfo(ProgramInfo programInfo, TimerInfo timerInfo) + { + timerInfo.SeasonNumber = programInfo.SeasonNumber; + timerInfo.EpisodeNumber = programInfo.EpisodeNumber; + timerInfo.IsMovie = programInfo.IsMovie; + timerInfo.IsKids = programInfo.IsKids; + timerInfo.IsNews = programInfo.IsNews; + timerInfo.IsSports = programInfo.IsSports; + timerInfo.ProductionYear = programInfo.ProductionYear; + timerInfo.EpisodeTitle = programInfo.EpisodeTitle; + timerInfo.OriginalAirDate = programInfo.OriginalAirDate; + timerInfo.IsProgramSeries = programInfo.IsSeries; + + timerInfo.HomePageUrl = programInfo.HomePageUrl; + timerInfo.CommunityRating = programInfo.CommunityRating; + timerInfo.ShortOverview = programInfo.ShortOverview; + timerInfo.OfficialRating = programInfo.OfficialRating; + timerInfo.IsRepeat = programInfo.IsRepeat; + } + + public static string GetRecordingName(TimerInfo info) + { + var name = info.Name; + + if (info.IsProgramSeries) + { + var addHyphen = true; + + if (info.SeasonNumber.HasValue && info.EpisodeNumber.HasValue) + { + name += string.Format(" 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"); + } + else + { + name += " " + DateTime.Now.ToString("yyyy-MM-dd"); + } + + if (!string.IsNullOrWhiteSpace(info.EpisodeTitle)) + { + if (addHyphen) + { + name += " -"; + } + + name += " " + info.EpisodeTitle; + } + } + + else if (info.IsMovie && info.ProductionYear != null) + { + name += " (" + info.ProductionYear + ")"; + } + else + { + name += " " + info.StartDate.ToString("yyyy-MM-dd") + " " + info.Id; + } + + return name; + } + } +} diff --git a/Emby.Server.Implementations/LiveTv/EmbyTV/SeriesTimerManager.cs b/Emby.Server.Implementations/LiveTv/EmbyTV/SeriesTimerManager.cs new file mode 100644 index 000000000..7bf6bf1ca --- /dev/null +++ b/Emby.Server.Implementations/LiveTv/EmbyTV/SeriesTimerManager.cs @@ -0,0 +1,28 @@ +using MediaBrowser.Controller.LiveTv; +using MediaBrowser.Model.Logging; +using MediaBrowser.Model.Serialization; +using System; +using MediaBrowser.Common.IO; +using MediaBrowser.Controller.IO; +using MediaBrowser.Model.IO; + +namespace Emby.Server.Implementations.LiveTv.EmbyTV +{ + public class SeriesTimerManager : ItemDataProvider<SeriesTimerInfo> + { + public SeriesTimerManager(IFileSystem fileSystem, IJsonSerializer jsonSerializer, ILogger logger, string dataPath) + : base(fileSystem, 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/Emby.Server.Implementations/LiveTv/EmbyTV/TimerManager.cs b/Emby.Server.Implementations/LiveTv/EmbyTV/TimerManager.cs new file mode 100644 index 000000000..35868d318 --- /dev/null +++ b/Emby.Server.Implementations/LiveTv/EmbyTV/TimerManager.cs @@ -0,0 +1,170 @@ +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.Globalization; +using System.Linq; +using System.Threading; +using MediaBrowser.Common.IO; +using MediaBrowser.Controller.IO; +using MediaBrowser.Model.IO; +using MediaBrowser.Model.LiveTv; +using MediaBrowser.Model.Threading; + +namespace Emby.Server.Implementations.LiveTv.EmbyTV +{ + public class TimerManager : ItemDataProvider<TimerInfo> + { + private readonly ConcurrentDictionary<string, ITimer> _timers = new ConcurrentDictionary<string, ITimer>(StringComparer.OrdinalIgnoreCase); + private readonly ILogger _logger; + + public event EventHandler<GenericEventArgs<TimerInfo>> TimerFired; + private readonly ITimerFactory _timerFactory; + + public TimerManager(IFileSystem fileSystem, IJsonSerializer jsonSerializer, ILogger logger, string dataPath, ILogger logger1, ITimerFactory timerFactory) + : base(fileSystem, jsonSerializer, logger, dataPath, (r1, r2) => string.Equals(r1.Id, r2.Id, StringComparison.OrdinalIgnoreCase)) + { + _logger = logger1; + _timerFactory = timerFactory; + } + + public void RestartTimers() + { + StopTimers(); + + foreach (var item in GetAll().ToList()) + { + AddOrUpdateSystemTimer(item); + } + } + + public void StopTimers() + { + foreach (var pair in _timers.ToList()) + { + pair.Value.Dispose(); + } + + _timers.Clear(); + } + + public override void Delete(TimerInfo item) + { + base.Delete(item); + StopTimer(item); + } + + public override void Update(TimerInfo item) + { + base.Update(item); + AddOrUpdateSystemTimer(item); + } + + public void AddOrUpdate(TimerInfo item, bool resetTimer) + { + if (resetTimer) + { + AddOrUpdate(item); + return; + } + + var list = GetAll().ToList(); + + if (!list.Any(i => EqualityComparer(i, item))) + { + base.Add(item); + } + else + { + base.Update(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); + AddOrUpdateSystemTimer(item); + } + + private bool ShouldStartTimer(TimerInfo item) + { + if (item.Status == RecordingStatus.Completed || + item.Status == RecordingStatus.Cancelled) + { + return false; + } + + return true; + } + + private void AddOrUpdateSystemTimer(TimerInfo item) + { + StopTimer(item); + + if (!ShouldStartTimer(item)) + { + return; + } + + var startDate = RecordingHelper.GetStartTime(item); + var now = DateTime.UtcNow; + + if (startDate < now) + { + EventHelper.FireEventIfNotNull(TimerFired, this, new GenericEventArgs<TimerInfo> { Argument = item }, Logger); + return; + } + + var dueTime = startDate - now; + StartTimer(item, dueTime); + } + + private void StartTimer(TimerInfo item, TimeSpan dueTime) + { + var timer = _timerFactory.Create(TimerCallback, item.Id, dueTime, TimeSpan.Zero); + + if (_timers.TryAdd(item.Id, timer)) + { + _logger.Info("Creating recording timer for {0}, {1}. Timer will fire in {2} minutes", item.Id, item.Name, dueTime.TotalMinutes.ToString(CultureInfo.InvariantCulture)); + } + else + { + timer.Dispose(); + _logger.Warn("Timer already exists for item {0}", item.Id); + } + } + + private void StopTimer(TimerInfo item) + { + ITimer 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); + } + } + + public TimerInfo GetTimer(string id) + { + return GetAll().FirstOrDefault(r => string.Equals(r.Id, id, StringComparison.OrdinalIgnoreCase)); + } + } +} diff --git a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirect.cs b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirect.cs new file mode 100644 index 000000000..7beaed338 --- /dev/null +++ b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirect.cs @@ -0,0 +1,1311 @@ +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 Emby.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 Dictionary<string, Dictionary<string, ScheduleDirect.Station>> _channelPairingCache = + new Dictionary<string, Dictionary<string, ScheduleDirect.Station>>(StringComparer.OrdinalIgnoreCase); + + 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().Date; + var end = new List<DateTime> { endDateUtc, endDateUtc.ToLocalTime() }.Max().Date; + + while (start <= end) + { + dates.Add(start.ToString("yyyy-MM-dd")); + start = start.AddDays(1); + } + + return dates; + } + + public async Task<IEnumerable<ProgramInfo>> GetProgramsAsync(ListingsProviderInfo info, string channelNumber, string channelName, DateTime startDateUtc, DateTime endDateUtc, CancellationToken cancellationToken) + { + List<ProgramInfo> programsInfo = new List<ProgramInfo>(); + + var token = await GetToken(info, cancellationToken).ConfigureAwait(false); + + if (string.IsNullOrWhiteSpace(token)) + { + _logger.Warn("SchedulesDirect token is empty, returning empty program list"); + return programsInfo; + } + + if (string.IsNullOrWhiteSpace(info.ListingsId)) + { + _logger.Warn("ListingsId is null, returning empty program list"); + return programsInfo; + } + + var dates = GetScheduleRequestDates(startDateUtc, endDateUtc); + + ScheduleDirect.Station station = GetStation(info.ListingsId, channelNumber, channelName); + + if (station == null) + { + _logger.Info("No Schedules Direct Station found for channel {0} with name {1}", channelNumber, channelName); + 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); + + var httpOptions = new HttpRequestOptions() + { + Url = ApiUrl + "/schedules", + UserAgent = UserAgent, + CancellationToken = cancellationToken, + // The data can be large so give it some extra time + TimeoutMs = 60000, + LogErrorResponseBody = true + }; + + httpOptions.RequestHeaders["token"] = token; + + httpOptions.RequestContent = requestString; + using (var response = await Post(httpOptions, true, info).ConfigureAwait(false)) + { + 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, + LogErrorResponseBody = true, + // The data can be large so give it some extra time + TimeoutMs = 60000 + }; + + 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 Post(httpOptions, true, info).ConfigureAwait(false)) + { + 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(info, 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); + + if (images != null) + { + var imageIndex = images.FindIndex(i => i.programID == schedule.programID.Substring(0, 10)); + if (imageIndex > -1) + { + var programEntry = programDict[schedule.programID]; + + var data = images[imageIndex].data ?? new List<ScheduleDirect.ImageData>(); + data = data.OrderByDescending(GetSizeOrder).ToList(); + + programEntry.primaryImage = GetProgramImage(ApiUrl, data, "Logo", true, 600); + //programEntry.thumbImage = GetProgramImage(ApiUrl, data, "Iconic", false); + //programEntry.bannerImage = GetProgramImage(ApiUrl, data, "Banner", false) ?? + // GetProgramImage(ApiUrl, data, "Banner-L1", false) ?? + // GetProgramImage(ApiUrl, data, "Banner-LO", false) ?? + // GetProgramImage(ApiUrl, data, "Banner-LOT", false); + } + } + + programsInfo.Add(GetProgram(channelNumber, schedule, programDict[schedule.programID])); + } + _logger.Info("Finished with EPGData"); + } + } + + return programsInfo; + } + + private int GetSizeOrder(ScheduleDirect.ImageData image) + { + if (!string.IsNullOrWhiteSpace(image.height)) + { + int value; + if (int.TryParse(image.height, out value)) + { + return value; + } + } + + return 0; + } + + private readonly object _channelCacheLock = new object(); + private ScheduleDirect.Station GetStation(string listingsId, string channelNumber, string channelName) + { + lock (_channelCacheLock) + { + Dictionary<string, ScheduleDirect.Station> channelPair; + if (_channelPairingCache.TryGetValue(listingsId, out channelPair)) + { + ScheduleDirect.Station station; + + if (channelPair.TryGetValue(channelNumber, out station)) + { + return station; + } + + if (!string.IsNullOrWhiteSpace(channelName)) + { + channelName = NormalizeName(channelName); + + var result = channelPair.Values.FirstOrDefault(i => string.Equals(NormalizeName(i.callsign ?? string.Empty), channelName, StringComparison.OrdinalIgnoreCase)); + + if (result != null) + { + return result; + } + } + + if (!string.IsNullOrWhiteSpace(channelNumber)) + { + return channelPair.Values.FirstOrDefault(i => string.Equals(NormalizeName(i.stationID ?? string.Empty), channelNumber, StringComparison.OrdinalIgnoreCase)); + } + } + + return null; + } + } + + private void AddToChannelPairCache(string listingsId, string channelNumber, ScheduleDirect.Station schChannel) + { + lock (_channelCacheLock) + { + Dictionary<string, ScheduleDirect.Station> cache; + if (_channelPairingCache.TryGetValue(listingsId, out cache)) + { + cache[channelNumber] = schChannel; + } + else + { + cache = new Dictionary<string, ScheduleDirect.Station>(); + cache[channelNumber] = schChannel; + _channelPairingCache[listingsId] = cache; + } + } + } + + private void ClearPairCache(string listingsId) + { + lock (_channelCacheLock) + { + Dictionary<string, ScheduleDirect.Station> cache; + if (_channelPairingCache.TryGetValue(listingsId, out cache)) + { + cache.Clear(); + } + } + } + + private int GetChannelPairCacheCount(string listingsId) + { + lock (_channelCacheLock) + { + Dictionary<string, ScheduleDirect.Station> cache; + if (_channelPairingCache.TryGetValue(listingsId, out cache)) + { + return cache.Count; + } + + return 0; + } + } + + private string NormalizeName(string value) + { + return value.Replace(" ", string.Empty).Replace("-", string.Empty); + } + + public async Task AddMetadata(ListingsProviderInfo info, List<ChannelInfo> channels, + CancellationToken cancellationToken) + { + var listingsId = info.ListingsId; + if (string.IsNullOrWhiteSpace(listingsId)) + { + throw new Exception("ListingsId required"); + } + + var token = await GetToken(info, cancellationToken); + + if (string.IsNullOrWhiteSpace(token)) + { + throw new Exception("token required"); + } + + ClearPairCache(listingsId); + + var httpOptions = new HttpRequestOptions() + { + Url = ApiUrl + "/lineups/" + listingsId, + UserAgent = UserAgent, + CancellationToken = cancellationToken, + LogErrorResponseBody = true, + // The data can be large so give it some extra time + TimeoutMs = 60000 + }; + + httpOptions.RequestHeaders["token"] = token; + + using (var response = await Get(httpOptions, true, info).ConfigureAwait(false)) + { + 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 channelNumber = map.logicalChannelNumber; + + if (string.IsNullOrWhiteSpace(channelNumber)) + { + channelNumber = map.channel; + } + if (string.IsNullOrWhiteSpace(channelNumber)) + { + channelNumber = map.atscMajor + "." + map.atscMinor; + } + channelNumber = channelNumber.TrimStart('0'); + + _logger.Debug("Found channel: " + channelNumber + " in Schedules Direct"); + + var schChannel = (root.stations ?? new List<ScheduleDirect.Station>()).FirstOrDefault(item => string.Equals(item.stationID, map.stationID, StringComparison.OrdinalIgnoreCase)); + if (schChannel != null) + { + AddToChannelPairCache(listingsId, channelNumber, schChannel); + } + else + { + AddToChannelPairCache(listingsId, channelNumber, new ScheduleDirect.Station + { + stationID = map.stationID + }); + } + } + _logger.Info("Added " + GetChannelPairCacheCount(listingsId) + " channels to the dictionary"); + + foreach (ChannelInfo channel in channels) + { + var station = GetStation(listingsId, channel.Number, channel.Name); + + if (station != null) + { + if (station.logo != null) + { + channel.ImageUrl = station.logo.URL; + channel.HasImage = true; + } + + if (!string.IsNullOrWhiteSpace(station.name)) + { + channel.Name = station.name; + } + } + 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 = GetDate(programInfo.airDateTime); + 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, "atmos", StringComparison.OrdinalIgnoreCase))) + { + audioType = ProgramAudio.Atmos; + } + else 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; + } + + 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 = details.primaryImage, + 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); + info.Is3D = programInfo.videoProperties.Contains("3d", 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.IsSeries || info.IsRepeat)) + { + info.OriginalAirDate = DateTime.Parse(details.originalAirDate); + info.ProductionYear = info.OriginalAirDate.Value.Year; + } + + 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 DateTime GetDate(string value) + { + var date = DateTime.ParseExact(value, "yyyy'-'MM'-'dd'T'HH':'mm':'ss'Z'", CultureInfo.InvariantCulture); + + if (date.Kind != DateTimeKind.Utc) + { + date = DateTime.SpecifyKind(date, DateTimeKind.Utc); + } + return date; + } + + private string GetProgramImage(string apiUrl, List<ScheduleDirect.ImageData> images, string category, bool returnDefaultImage, int desiredWidth) + { + string url = null; + + var matches = images + .Where(i => string.Equals(i.category, category, StringComparison.OrdinalIgnoreCase)) + .ToList(); + + if (matches.Count == 0) + { + if (!returnDefaultImage) + { + return null; + } + matches = images; + } + + var match = matches.FirstOrDefault(i => + { + if (!string.IsNullOrWhiteSpace(i.width)) + { + int value; + if (int.TryParse(i.width, out value)) + { + return value <= desiredWidth; + } + } + + return false; + }); + + if (match == null) + { + // Get the second lowest quality image, when possible + if (matches.Count > 1) + { + match = matches[matches.Count - 2]; + } + else + { + match = matches.FirstOrDefault(); + } + } + + if (match == null) + { + return null; + } + + var uri = match.uri; + + if (!string.IsNullOrWhiteSpace(uri)) + { + if (uri.IndexOf("http", StringComparison.OrdinalIgnoreCase) != -1) + { + url = uri; + } + else + { + url = apiUrl + "/image/" + uri; + } + } + //_logger.Debug("URL for image is : " + url); + return url; + } + + private async Task<List<ScheduleDirect.ShowImages>> GetImageForPrograms( + ListingsProviderInfo info, + List<string> programIds, + CancellationToken cancellationToken) + { + var imageIdString = "["; + + foreach (var i in programIds) + { + 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, + LogErrorResponseBody = true, + // The data can be large so give it some extra time + TimeoutMs = 60000 + }; + List<ScheduleDirect.ShowImages> images; + using (var innerResponse2 = await Post(httpOptions, true, info).ConfigureAwait(false)) + { + 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, + LogErrorResponseBody = true + }; + + options.RequestHeaders["token"] = token; + + try + { + using (Stream responce = await Get(options, false, info).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(20).Ticks) + { + return savedToken.Name; + } + } + } + + await _tokenSemaphore.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + var result = await GetTokenInternal(username, password, cancellationToken).ConfigureAwait(false); + savedToken.Name = result; + savedToken.Value = DateTime.UtcNow.Ticks.ToString(CultureInfo.InvariantCulture); + return result; + } + catch (HttpException ex) + { + if (ex.StatusCode.HasValue) + { + if ((int)ex.StatusCode.Value == 400) + { + _tokens.Clear(); + _lastErrorResponse = DateTime.UtcNow; + } + } + throw; + } + finally + { + _tokenSemaphore.Release(); + } + } + + private async Task<HttpResponseInfo> Post(HttpRequestOptions options, + bool enableRetry, + ListingsProviderInfo providerInfo) + { + try + { + return await _httpClient.Post(options).ConfigureAwait(false); + } + catch (HttpException ex) + { + _tokens.Clear(); + + if (!ex.StatusCode.HasValue || (int)ex.StatusCode.Value >= 500) + { + enableRetry = false; + } + + if (!enableRetry) + { + throw; + } + } + + var newToken = await GetToken(providerInfo, options.CancellationToken).ConfigureAwait(false); + options.RequestHeaders["token"] = newToken; + return await Post(options, false, providerInfo).ConfigureAwait(false); + } + + private async Task<Stream> Get(HttpRequestOptions options, + bool enableRetry, + ListingsProviderInfo providerInfo) + { + try + { + return await _httpClient.Get(options).ConfigureAwait(false); + } + catch (HttpException ex) + { + _tokens.Clear(); + + if (!ex.StatusCode.HasValue || (int)ex.StatusCode.Value >= 500) + { + enableRetry = false; + } + + if (!enableRetry) + { + throw; + } + } + + var newToken = await GetToken(providerInfo, options.CancellationToken).ConfigureAwait(false); + options.RequestHeaders["token"] = newToken; + return await Get(options, false, providerInfo).ConfigureAwait(false); + } + + 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, + LogErrorResponseBody = true + }; + //_logger.Info("Obtaining token from Schedules Direct from addres: " + httpOptions.Url + " with body " + + // httpOptions.RequestContent); + + using (var responce = await Post(httpOptions, false, null).ConfigureAwait(false)) + { + 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 Exception("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, + LogErrorResponseBody = true, + BufferContent = false + }; + + httpOptions.RequestHeaders["token"] = token; + + using (var response = await _httpClient.SendAsync(httpOptions, "PUT")) + { + } + } + + public string Name + { + get { return "Schedules Direct"; } + } + + public static string TypeName = "SchedulesDirect"; + public string Type + { + get { return TypeName; } + } + + 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, + LogErrorResponseBody = true + }; + + options.RequestHeaders["token"] = token; + + try + { + using (var response = await Get(options, false, null).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 async Task<List<ChannelInfo>> GetChannels(ListingsProviderInfo info, CancellationToken cancellationToken) + { + var listingsId = info.ListingsId; + if (string.IsNullOrWhiteSpace(listingsId)) + { + throw new Exception("ListingsId required"); + } + + await AddMetadata(info, new List<ChannelInfo>(), cancellationToken).ConfigureAwait(false); + + var token = await GetToken(info, cancellationToken); + + if (string.IsNullOrWhiteSpace(token)) + { + throw new Exception("token required"); + } + + var httpOptions = new HttpRequestOptions() + { + Url = ApiUrl + "/lineups/" + listingsId, + UserAgent = UserAgent, + CancellationToken = cancellationToken, + LogErrorResponseBody = true, + // The data can be large so give it some extra time + TimeoutMs = 60000 + }; + + httpOptions.RequestHeaders["token"] = token; + + var list = new List<ChannelInfo>(); + + using (var response = await Get(httpOptions, true, info).ConfigureAwait(false)) + { + 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 channelNumber = map.logicalChannelNumber; + + if (string.IsNullOrWhiteSpace(channelNumber)) + { + channelNumber = map.channel; + } + if (string.IsNullOrWhiteSpace(channelNumber)) + { + channelNumber = map.atscMajor + "." + map.atscMinor; + } + channelNumber = channelNumber.TrimStart('0'); + + var name = channelNumber; + var station = GetStation(listingsId, channelNumber, null); + + if (station != null && !string.IsNullOrWhiteSpace(station.name)) + { + name = station.name; + } + + list.Add(new ChannelInfo + { + Number = channelNumber, + Name = name + }); + } + } + + return list; + } + + 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 string logicalChannelNumber { 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 Day() + { + programs = new List<Program>(); + } + } + + // + 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 primaryImage { get; set; } + public string thumbImage { get; set; } + public string bannerImage { 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; } + } + + } + } +}
\ No newline at end of file diff --git a/Emby.Server.Implementations/LiveTv/Listings/XmlTvListingsProvider.cs b/Emby.Server.Implementations/LiveTv/Listings/XmlTvListingsProvider.cs new file mode 100644 index 000000000..57723e3c5 --- /dev/null +++ b/Emby.Server.Implementations/LiveTv/Listings/XmlTvListingsProvider.cs @@ -0,0 +1,236 @@ +using MediaBrowser.Controller.LiveTv; +using MediaBrowser.Model.Dto; +using MediaBrowser.Model.LiveTv; +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Net; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Emby.XmlTv.Classes; +using Emby.XmlTv.Entities; +using MediaBrowser.Common.Extensions; +using MediaBrowser.Common.Net; +using MediaBrowser.Controller.Configuration; +using MediaBrowser.Model.IO; +using MediaBrowser.Model.Logging; + +namespace Emby.Server.Implementations.LiveTv.Listings +{ + public class XmlTvListingsProvider : IListingsProvider + { + private readonly IServerConfigurationManager _config; + private readonly IHttpClient _httpClient; + private readonly ILogger _logger; + private readonly IFileSystem _fileSystem; + + public XmlTvListingsProvider(IServerConfigurationManager config, IHttpClient httpClient, ILogger logger, IFileSystem fileSystem) + { + _config = config; + _httpClient = httpClient; + _logger = logger; + _fileSystem = fileSystem; + } + + public string Name + { + get { return "XmlTV"; } + } + + public string Type + { + get { return "xmltv"; } + } + + private string GetLanguage() + { + return _config.Configuration.PreferredMetadataLanguage; + } + + private async Task<string> GetXml(string path, CancellationToken cancellationToken) + { + _logger.Info("xmltv path: {0}", path); + + if (!path.StartsWith("http", StringComparison.OrdinalIgnoreCase)) + { + return path; + } + + var cacheFilename = DateTime.UtcNow.DayOfYear.ToString(CultureInfo.InvariantCulture) + "-" + DateTime.UtcNow.Hour.ToString(CultureInfo.InvariantCulture) + ".xml"; + var cacheFile = Path.Combine(_config.ApplicationPaths.CachePath, "xmltv", cacheFilename); + if (_fileSystem.FileExists(cacheFile)) + { + return cacheFile; + } + + _logger.Info("Downloading xmltv listings from {0}", path); + + var tempFile = await _httpClient.GetTempFile(new HttpRequestOptions + { + CancellationToken = cancellationToken, + Url = path, + Progress = new Progress<Double>(), + DecompressionMethod = CompressionMethod.Gzip, + + // It's going to come back gzipped regardless of this value + // So we need to make sure the decompression method is set to gzip + EnableHttpCompression = true + + }).ConfigureAwait(false); + + _fileSystem.CreateDirectory(Path.GetDirectoryName(cacheFile)); + + using (var stream = _fileSystem.OpenRead(tempFile)) + { + using (var reader = new StreamReader(stream, Encoding.UTF8)) + { + using (var fileStream = _fileSystem.GetFileStream(cacheFile, FileOpenMode.Create, FileAccessMode.Write, FileShareMode.Read)) + { + using (var writer = new StreamWriter(fileStream)) + { + while (!reader.EndOfStream) + { + writer.WriteLine(reader.ReadLine()); + } + } + } + } + } + + _logger.Debug("Returning xmltv path {0}", cacheFile); + return cacheFile; + } + + public async Task<IEnumerable<ProgramInfo>> GetProgramsAsync(ListingsProviderInfo info, string channelNumber, string channelName, DateTime startDateUtc, DateTime endDateUtc, CancellationToken cancellationToken) + { + if (!await EmbyTV.EmbyTVRegistration.Instance.EnableXmlTv().ConfigureAwait(false)) + { + var length = endDateUtc - startDateUtc; + if (length.TotalDays > 1) + { + endDateUtc = startDateUtc.AddDays(1); + } + } + + var path = await GetXml(info.Path, cancellationToken).ConfigureAwait(false); + var reader = new XmlTvReader(path, GetLanguage(), null); + + var results = reader.GetProgrammes(channelNumber, startDateUtc, endDateUtc, cancellationToken); + return results.Select(p => GetProgramInfo(p, info)); + } + + private ProgramInfo GetProgramInfo(XmlTvProgram p, ListingsProviderInfo info) + { + var programInfo = new ProgramInfo + { + ChannelId = p.ChannelId, + EndDate = GetDate(p.EndDate), + EpisodeNumber = p.Episode == null ? null : p.Episode.Episode, + EpisodeTitle = p.Episode == null ? null : p.Episode.Title, + Genres = p.Categories, + Id = String.Format("{0}_{1:O}", p.ChannelId, p.StartDate), // Construct an id from the channel and start date, + StartDate = GetDate(p.StartDate), + Name = p.Title, + Overview = p.Description, + ShortOverview = p.Description, + ProductionYear = !p.CopyrightDate.HasValue ? (int?)null : p.CopyrightDate.Value.Year, + SeasonNumber = p.Episode == null ? null : p.Episode.Series, + IsSeries = p.Episode != null, + IsRepeat = p.IsRepeat, + IsPremiere = p.Premiere != null, + IsKids = p.Categories.Any(c => info.KidsCategories.Contains(c, StringComparer.OrdinalIgnoreCase)), + IsMovie = p.Categories.Any(c => info.MovieCategories.Contains(c, StringComparer.OrdinalIgnoreCase)), + IsNews = p.Categories.Any(c => info.NewsCategories.Contains(c, StringComparer.OrdinalIgnoreCase)), + IsSports = p.Categories.Any(c => info.SportsCategories.Contains(c, StringComparer.OrdinalIgnoreCase)), + ImageUrl = p.Icon != null && !String.IsNullOrEmpty(p.Icon.Source) ? p.Icon.Source : null, + HasImage = p.Icon != null && !String.IsNullOrEmpty(p.Icon.Source), + OfficialRating = p.Rating != null && !String.IsNullOrEmpty(p.Rating.Value) ? p.Rating.Value : null, + CommunityRating = p.StarRating.HasValue ? p.StarRating.Value : (float?)null, + SeriesId = p.Episode != null ? p.Title.GetMD5().ToString("N") : null + }; + + if (programInfo.IsMovie) + { + programInfo.IsSeries = false; + programInfo.EpisodeNumber = null; + programInfo.EpisodeTitle = null; + } + + return programInfo; + } + + private DateTime GetDate(DateTime date) + { + if (date.Kind != DateTimeKind.Utc) + { + date = DateTime.SpecifyKind(date, DateTimeKind.Utc); + } + return date; + } + + public async Task AddMetadata(ListingsProviderInfo info, List<ChannelInfo> channels, CancellationToken cancellationToken) + { + // Add the channel image url + var path = await GetXml(info.Path, cancellationToken).ConfigureAwait(false); + var reader = new XmlTvReader(path, GetLanguage(), null); + var results = reader.GetChannels().ToList(); + + if (channels != null) + { + foreach (var c in channels) + { + var channelNumber = info.GetMappedChannel(c.Number); + var match = results.FirstOrDefault(r => string.Equals(r.Id, channelNumber, StringComparison.OrdinalIgnoreCase)); + + if (match != null && match.Icon != null && !String.IsNullOrEmpty(match.Icon.Source)) + { + c.ImageUrl = match.Icon.Source; + } + } + } + } + + public Task Validate(ListingsProviderInfo info, bool validateLogin, bool validateListings) + { + // Assume all urls are valid. check files for existence + if (!info.Path.StartsWith("http", StringComparison.OrdinalIgnoreCase) && !_fileSystem.FileExists(info.Path)) + { + throw new FileNotFoundException("Could not find the XmlTv file specified:", info.Path); + } + + return Task.FromResult(true); + } + + public async Task<List<NameIdPair>> GetLineups(ListingsProviderInfo info, string country, string location) + { + // In theory this should never be called because there is always only one lineup + var path = await GetXml(info.Path, CancellationToken.None).ConfigureAwait(false); + var reader = new XmlTvReader(path, GetLanguage(), null); + var results = reader.GetChannels(); + + // Should this method be async? + return results.Select(c => new NameIdPair() { Id = c.Id, Name = c.DisplayName }).ToList(); + } + + public async Task<List<ChannelInfo>> GetChannels(ListingsProviderInfo info, CancellationToken cancellationToken) + { + // In theory this should never be called because there is always only one lineup + var path = await GetXml(info.Path, cancellationToken).ConfigureAwait(false); + var reader = new XmlTvReader(path, GetLanguage(), null); + var results = reader.GetChannels(); + + // Should this method be async? + return results.Select(c => new ChannelInfo() + { + Id = c.Id, + Name = c.DisplayName, + ImageUrl = c.Icon != null && !String.IsNullOrEmpty(c.Icon.Source) ? c.Icon.Source : null, + Number = c.Id + + }).ToList(); + } + } +}
\ No newline at end of file diff --git a/Emby.Server.Implementations/LiveTv/LiveStreamHelper.cs b/Emby.Server.Implementations/LiveTv/LiveStreamHelper.cs new file mode 100644 index 000000000..a338ae23a --- /dev/null +++ b/Emby.Server.Implementations/LiveTv/LiveStreamHelper.cs @@ -0,0 +1,110 @@ +using System; +using System.Globalization; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using MediaBrowser.Controller.MediaEncoding; +using MediaBrowser.Model.Dlna; +using MediaBrowser.Model.Dto; +using MediaBrowser.Model.Logging; + +namespace Emby.Server.Implementations.LiveTv +{ + public class LiveStreamHelper + { + private readonly IMediaEncoder _mediaEncoder; + private readonly ILogger _logger; + + public LiveStreamHelper(IMediaEncoder mediaEncoder, ILogger logger) + { + _mediaEncoder = mediaEncoder; + _logger = logger; + } + + public async Task AddMediaInfoWithProbe(MediaSourceInfo mediaSource, bool isAudio, CancellationToken cancellationToken) + { + var originalRuntime = mediaSource.RunTimeTicks; + + var now = DateTime.UtcNow; + + var info = await _mediaEncoder.GetMediaInfo(new MediaInfoRequest + { + InputPath = mediaSource.Path, + Protocol = mediaSource.Protocol, + MediaType = isAudio ? DlnaProfileType.Audio : DlnaProfileType.Video, + ExtractChapters = false, + AnalyzeDurationSections = 2 + + }, cancellationToken).ConfigureAwait(false); + + _logger.Info("Live tv media info probe took {0} seconds", (DateTime.UtcNow - now).TotalSeconds.ToString(CultureInfo.InvariantCulture)); + + mediaSource.Bitrate = info.Bitrate; + mediaSource.Container = info.Container; + mediaSource.Formats = info.Formats; + mediaSource.MediaStreams = info.MediaStreams; + mediaSource.RunTimeTicks = info.RunTimeTicks; + mediaSource.Size = info.Size; + mediaSource.Timestamp = info.Timestamp; + mediaSource.Video3DFormat = info.Video3DFormat; + mediaSource.VideoType = info.VideoType; + + mediaSource.DefaultSubtitleStreamIndex = null; + + // Null this out so that it will be treated like a live stream + if (!originalRuntime.HasValue) + { + mediaSource.RunTimeTicks = null; + } + + var audioStream = mediaSource.MediaStreams.FirstOrDefault(i => i.Type == MediaBrowser.Model.Entities.MediaStreamType.Audio); + + if (audioStream == null || audioStream.Index == -1) + { + mediaSource.DefaultAudioStreamIndex = null; + } + else + { + mediaSource.DefaultAudioStreamIndex = audioStream.Index; + } + + var videoStream = mediaSource.MediaStreams.FirstOrDefault(i => i.Type == MediaBrowser.Model.Entities.MediaStreamType.Video); + if (videoStream != null) + { + if (!videoStream.BitRate.HasValue) + { + var width = videoStream.Width ?? 1920; + + if (width >= 1900) + { + videoStream.BitRate = 8000000; + } + + else if (width >= 1260) + { + videoStream.BitRate = 3000000; + } + + else if (width >= 700) + { + videoStream.BitRate = 1000000; + } + } + + // This is coming up false and preventing stream copy + videoStream.IsAVC = null; + } + + // Try to estimate this + if (!mediaSource.Bitrate.HasValue) + { + var total = mediaSource.MediaStreams.Select(i => i.BitRate ?? 0).Sum(); + + if (total > 0) + { + mediaSource.Bitrate = total; + } + } + } + } +} diff --git a/Emby.Server.Implementations/LiveTv/LiveTvConfigurationFactory.cs b/Emby.Server.Implementations/LiveTv/LiveTvConfigurationFactory.cs new file mode 100644 index 000000000..2be642737 --- /dev/null +++ b/Emby.Server.Implementations/LiveTv/LiveTvConfigurationFactory.cs @@ -0,0 +1,21 @@ +using MediaBrowser.Common.Configuration; +using MediaBrowser.Model.LiveTv; +using System.Collections.Generic; + +namespace Emby.Server.Implementations.LiveTv +{ + public class LiveTvConfigurationFactory : IConfigurationFactory + { + public IEnumerable<ConfigurationStore> GetConfigurations() + { + return new List<ConfigurationStore> + { + new ConfigurationStore + { + ConfigurationType = typeof(LiveTvOptions), + Key = "livetv" + } + }; + } + } +} diff --git a/Emby.Server.Implementations/LiveTv/LiveTvDtoService.cs b/Emby.Server.Implementations/LiveTv/LiveTvDtoService.cs new file mode 100644 index 000000000..4e7161521 --- /dev/null +++ b/Emby.Server.Implementations/LiveTv/LiveTvDtoService.cs @@ -0,0 +1,390 @@ +using MediaBrowser.Common; +using MediaBrowser.Common.Extensions; +using MediaBrowser.Controller.Drawing; +using MediaBrowser.Controller.Dto; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.LiveTv; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.LiveTv; +using MediaBrowser.Model.Logging; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +namespace Emby.Server.Implementations.LiveTv +{ + public class LiveTvDtoService + { + private readonly ILogger _logger; + private readonly IImageProcessor _imageProcessor; + + private readonly IUserDataManager _userDataManager; + private readonly IDtoService _dtoService; + private readonly IApplicationHost _appHost; + private readonly ILibraryManager _libraryManager; + + public LiveTvDtoService(IDtoService dtoService, IUserDataManager userDataManager, IImageProcessor imageProcessor, ILogger logger, IApplicationHost appHost, ILibraryManager libraryManager) + { + _dtoService = dtoService; + _userDataManager = userDataManager; + _imageProcessor = imageProcessor; + _logger = logger; + _appHost = appHost; + _libraryManager = libraryManager; + } + + public TimerInfoDto GetTimerInfoDto(TimerInfo info, ILiveTvService service, LiveTvProgram program, LiveTvChannel channel) + { + var dto = new TimerInfoDto + { + Id = GetInternalTimerId(service.Name, info.Id).ToString("N"), + Overview = info.Overview, + EndDate = info.EndDate, + Name = info.Name, + StartDate = info.StartDate, + ExternalId = info.Id, + ChannelId = GetInternalChannelId(service.Name, info.ChannelId).ToString("N"), + Status = info.Status, + SeriesTimerId = string.IsNullOrEmpty(info.SeriesTimerId) ? null : GetInternalSeriesTimerId(service.Name, info.SeriesTimerId).ToString("N"), + PrePaddingSeconds = info.PrePaddingSeconds, + PostPaddingSeconds = info.PostPaddingSeconds, + IsPostPaddingRequired = info.IsPostPaddingRequired, + IsPrePaddingRequired = info.IsPrePaddingRequired, + KeepUntil = info.KeepUntil, + ExternalChannelId = info.ChannelId, + ExternalSeriesTimerId = info.SeriesTimerId, + ServiceName = service.Name, + ExternalProgramId = info.ProgramId, + Priority = info.Priority, + RunTimeTicks = (info.EndDate - info.StartDate).Ticks, + ServerId = _appHost.SystemId + }; + + if (!string.IsNullOrEmpty(info.ProgramId)) + { + dto.ProgramId = GetInternalProgramId(service.Name, info.ProgramId).ToString("N"); + } + + if (program != null) + { + dto.ProgramInfo = _dtoService.GetBaseItemDto(program, new DtoOptions()); + + if (info.Status != RecordingStatus.Cancelled && info.Status != RecordingStatus.Error) + { + dto.ProgramInfo.TimerId = dto.Id; + dto.ProgramInfo.Status = info.Status.ToString(); + } + + dto.ProgramInfo.SeriesTimerId = dto.SeriesTimerId; + } + + if (channel != null) + { + dto.ChannelName = channel.Name; + } + + return dto; + } + + public SeriesTimerInfoDto GetSeriesTimerInfoDto(SeriesTimerInfo info, ILiveTvService service, string channelName) + { + var dto = new SeriesTimerInfoDto + { + Id = GetInternalSeriesTimerId(service.Name, info.Id).ToString("N"), + Overview = info.Overview, + EndDate = info.EndDate, + Name = info.Name, + StartDate = info.StartDate, + ExternalId = info.Id, + PrePaddingSeconds = info.PrePaddingSeconds, + PostPaddingSeconds = info.PostPaddingSeconds, + IsPostPaddingRequired = info.IsPostPaddingRequired, + IsPrePaddingRequired = info.IsPrePaddingRequired, + Days = info.Days, + Priority = info.Priority, + RecordAnyChannel = info.RecordAnyChannel, + RecordAnyTime = info.RecordAnyTime, + SkipEpisodesInLibrary = info.SkipEpisodesInLibrary, + KeepUpTo = info.KeepUpTo, + KeepUntil = info.KeepUntil, + RecordNewOnly = info.RecordNewOnly, + ExternalChannelId = info.ChannelId, + ExternalProgramId = info.ProgramId, + ServiceName = service.Name, + ChannelName = channelName, + ServerId = _appHost.SystemId + }; + + if (!string.IsNullOrEmpty(info.ChannelId)) + { + dto.ChannelId = GetInternalChannelId(service.Name, info.ChannelId).ToString("N"); + } + + if (!string.IsNullOrEmpty(info.ProgramId)) + { + dto.ProgramId = GetInternalProgramId(service.Name, info.ProgramId).ToString("N"); + } + + dto.DayPattern = info.Days == null ? null : GetDayPattern(info.Days); + + if (!string.IsNullOrWhiteSpace(info.SeriesId)) + { + var program = _libraryManager.GetItemList(new InternalItemsQuery + { + IncludeItemTypes = new string[] { typeof(LiveTvProgram).Name }, + ExternalSeriesId = info.SeriesId, + Limit = 1, + ImageTypes = new ImageType[] { ImageType.Primary } + + }).FirstOrDefault(); + + if (program != null) + { + var image = program.GetImageInfo(ImageType.Primary, 0); + if (image != null) + { + try + { + dto.ParentPrimaryImageTag = _imageProcessor.GetImageCacheTag(program, image); + dto.ParentPrimaryImageItemId = program.Id.ToString("N"); + } + catch (Exception ex) + { + } + } + } + } + + return dto; + } + + public DayPattern? GetDayPattern(List<DayOfWeek> days) + { + DayPattern? pattern = null; + + if (days.Count > 0) + { + if (days.Count == 7) + { + pattern = DayPattern.Daily; + } + else if (days.Count == 2) + { + if (days.Contains(DayOfWeek.Saturday) && days.Contains(DayOfWeek.Sunday)) + { + pattern = DayPattern.Weekends; + } + } + else if (days.Count == 5) + { + if (days.Contains(DayOfWeek.Monday) && days.Contains(DayOfWeek.Tuesday) && days.Contains(DayOfWeek.Wednesday) && days.Contains(DayOfWeek.Thursday) && days.Contains(DayOfWeek.Friday)) + { + pattern = DayPattern.Weekdays; + } + } + } + + return pattern; + } + + public LiveTvTunerInfoDto GetTunerInfoDto(string serviceName, LiveTvTunerInfo info, string channelName) + { + var dto = new LiveTvTunerInfoDto + { + Name = info.Name, + Id = info.Id, + Clients = info.Clients, + ProgramName = info.ProgramName, + SourceType = info.SourceType, + Status = info.Status, + ChannelName = channelName, + Url = info.Url, + CanReset = info.CanReset + }; + + if (!string.IsNullOrEmpty(info.ChannelId)) + { + dto.ChannelId = GetInternalChannelId(serviceName, info.ChannelId).ToString("N"); + } + + if (!string.IsNullOrEmpty(info.RecordingId)) + { + dto.RecordingId = GetInternalRecordingId(serviceName, info.RecordingId).ToString("N"); + } + + return dto; + } + + internal string GetImageTag(IHasImages info) + { + try + { + return _imageProcessor.GetImageCacheTag(info, ImageType.Primary); + } + catch (Exception ex) + { + _logger.ErrorException("Error getting image info for {0}", ex, info.Name); + } + + return null; + } + + private const string InternalVersionNumber = "4"; + + public Guid GetInternalChannelId(string serviceName, string externalId) + { + var name = serviceName + externalId + InternalVersionNumber; + + return _libraryManager.GetNewItemId(name.ToLower(), typeof(LiveTvChannel)); + } + + public Guid GetInternalTimerId(string serviceName, string externalId) + { + var name = serviceName + externalId + InternalVersionNumber; + + return name.ToLower().GetMD5(); + } + + public Guid GetInternalSeriesTimerId(string serviceName, string externalId) + { + var name = serviceName + externalId + InternalVersionNumber; + + return name.ToLower().GetMD5(); + } + + public Guid GetInternalProgramId(string serviceName, string externalId) + { + var name = serviceName + externalId + InternalVersionNumber; + + return _libraryManager.GetNewItemId(name.ToLower(), typeof(LiveTvProgram)); + } + + public Guid GetInternalRecordingId(string serviceName, string externalId) + { + var name = serviceName + externalId + InternalVersionNumber + "0"; + + return _libraryManager.GetNewItemId(name.ToLower(), typeof(ILiveTvRecording)); + } + + public async Task<TimerInfo> GetTimerInfo(TimerInfoDto dto, bool isNew, LiveTvManager liveTv, CancellationToken cancellationToken) + { + var info = new TimerInfo + { + Overview = dto.Overview, + EndDate = dto.EndDate, + Name = dto.Name, + StartDate = dto.StartDate, + Status = dto.Status, + PrePaddingSeconds = dto.PrePaddingSeconds, + PostPaddingSeconds = dto.PostPaddingSeconds, + IsPostPaddingRequired = dto.IsPostPaddingRequired, + IsPrePaddingRequired = dto.IsPrePaddingRequired, + KeepUntil = dto.KeepUntil, + Priority = dto.Priority, + SeriesTimerId = dto.ExternalSeriesTimerId, + ProgramId = dto.ExternalProgramId, + ChannelId = dto.ExternalChannelId, + Id = dto.ExternalId + }; + + // Convert internal server id's to external tv provider id's + if (!isNew && !string.IsNullOrEmpty(dto.Id) && string.IsNullOrEmpty(info.Id)) + { + var timer = await liveTv.GetSeriesTimer(dto.Id, cancellationToken).ConfigureAwait(false); + + info.Id = timer.ExternalId; + } + + if (!string.IsNullOrEmpty(dto.ChannelId) && string.IsNullOrEmpty(info.ChannelId)) + { + var channel = liveTv.GetInternalChannel(dto.ChannelId); + + if (channel != null) + { + info.ChannelId = channel.ExternalId; + } + } + + if (!string.IsNullOrEmpty(dto.ProgramId) && string.IsNullOrEmpty(info.ProgramId)) + { + var program = liveTv.GetInternalProgram(dto.ProgramId); + + if (program != null) + { + info.ProgramId = program.ExternalId; + } + } + + if (!string.IsNullOrEmpty(dto.SeriesTimerId) && string.IsNullOrEmpty(info.SeriesTimerId)) + { + var timer = await liveTv.GetSeriesTimer(dto.SeriesTimerId, cancellationToken).ConfigureAwait(false); + + if (timer != null) + { + info.SeriesTimerId = timer.ExternalId; + } + } + + return info; + } + + public async Task<SeriesTimerInfo> GetSeriesTimerInfo(SeriesTimerInfoDto dto, bool isNew, LiveTvManager liveTv, CancellationToken cancellationToken) + { + var info = new SeriesTimerInfo + { + Overview = dto.Overview, + EndDate = dto.EndDate, + Name = dto.Name, + StartDate = dto.StartDate, + PrePaddingSeconds = dto.PrePaddingSeconds, + PostPaddingSeconds = dto.PostPaddingSeconds, + IsPostPaddingRequired = dto.IsPostPaddingRequired, + IsPrePaddingRequired = dto.IsPrePaddingRequired, + Days = dto.Days, + Priority = dto.Priority, + RecordAnyChannel = dto.RecordAnyChannel, + RecordAnyTime = dto.RecordAnyTime, + SkipEpisodesInLibrary = dto.SkipEpisodesInLibrary, + KeepUpTo = dto.KeepUpTo, + KeepUntil = dto.KeepUntil, + RecordNewOnly = dto.RecordNewOnly, + ProgramId = dto.ExternalProgramId, + ChannelId = dto.ExternalChannelId, + Id = dto.ExternalId + }; + + // Convert internal server id's to external tv provider id's + if (!isNew && !string.IsNullOrEmpty(dto.Id) && string.IsNullOrEmpty(info.Id)) + { + var timer = await liveTv.GetSeriesTimer(dto.Id, cancellationToken).ConfigureAwait(false); + + info.Id = timer.ExternalId; + } + + if (!string.IsNullOrEmpty(dto.ChannelId) && string.IsNullOrEmpty(info.ChannelId)) + { + var channel = liveTv.GetInternalChannel(dto.ChannelId); + + if (channel != null) + { + info.ChannelId = channel.ExternalId; + } + } + + if (!string.IsNullOrEmpty(dto.ProgramId) && string.IsNullOrEmpty(info.ProgramId)) + { + var program = liveTv.GetInternalProgram(dto.ProgramId); + + if (program != null) + { + info.ProgramId = program.ExternalId; + } + } + + return info; + } + } +} diff --git a/Emby.Server.Implementations/LiveTv/LiveTvManager.cs b/Emby.Server.Implementations/LiveTv/LiveTvManager.cs new file mode 100644 index 000000000..adec66858 --- /dev/null +++ b/Emby.Server.Implementations/LiveTv/LiveTvManager.cs @@ -0,0 +1,3023 @@ +using MediaBrowser.Common; +using MediaBrowser.Common.Configuration; +using MediaBrowser.Common.Extensions; +using MediaBrowser.Common.Progress; +using MediaBrowser.Controller.Configuration; +using MediaBrowser.Controller.Drawing; +using MediaBrowser.Controller.Dto; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.LiveTv; +using MediaBrowser.Controller.Persistence; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Controller.Sorting; +using MediaBrowser.Model.Dto; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.LiveTv; +using MediaBrowser.Model.Logging; +using MediaBrowser.Model.Querying; +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; +using MediaBrowser.Model.IO; +using MediaBrowser.Common.Events; +using MediaBrowser.Common.IO; +using MediaBrowser.Common.Security; +using MediaBrowser.Controller.Entities.Movies; +using MediaBrowser.Controller.Entities.TV; +using MediaBrowser.Controller.IO; +using MediaBrowser.Model.Events; +using MediaBrowser.Model.Extensions; +using MediaBrowser.Model.Globalization; +using MediaBrowser.Model.Tasks; +using Emby.Server.Implementations.LiveTv.Listings; + +namespace Emby.Server.Implementations.LiveTv +{ + /// <summary> + /// Class LiveTvManager + /// </summary> + public class LiveTvManager : ILiveTvManager, IDisposable + { + private readonly IServerConfigurationManager _config; + private readonly ILogger _logger; + private readonly IItemRepository _itemRepo; + private readonly IUserManager _userManager; + private readonly IUserDataManager _userDataManager; + private readonly ILibraryManager _libraryManager; + private readonly ITaskManager _taskManager; + private readonly IJsonSerializer _jsonSerializer; + private readonly IProviderManager _providerManager; + private readonly ISecurityManager _security; + + private readonly IDtoService _dtoService; + private readonly ILocalizationManager _localization; + + private readonly LiveTvDtoService _tvDtoService; + + private readonly List<ILiveTvService> _services = new List<ILiveTvService>(); + + private readonly SemaphoreSlim _refreshRecordingsLock = new SemaphoreSlim(1, 1); + + private readonly List<ITunerHost> _tunerHosts = new List<ITunerHost>(); + private readonly List<IListingsProvider> _listingProviders = new List<IListingsProvider>(); + private readonly IFileSystem _fileSystem; + + public event EventHandler<GenericEventArgs<TimerEventInfo>> SeriesTimerCancelled; + public event EventHandler<GenericEventArgs<TimerEventInfo>> TimerCancelled; + public event EventHandler<GenericEventArgs<TimerEventInfo>> TimerCreated; + public event EventHandler<GenericEventArgs<TimerEventInfo>> SeriesTimerCreated; + + public string GetEmbyTvActiveRecordingPath(string id) + { + return EmbyTV.EmbyTV.Current.GetActiveRecordingPath(id); + } + + public Task<LiveStream> GetEmbyTvLiveStream(string id) + { + return EmbyTV.EmbyTV.Current.GetLiveStream(id); + } + + 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, ISecurityManager security) + { + _config = config; + _logger = logger; + _itemRepo = itemRepo; + _userManager = userManager; + _libraryManager = libraryManager; + _taskManager = taskManager; + _localization = localization; + _jsonSerializer = jsonSerializer; + _providerManager = providerManager; + _fileSystem = fileSystem; + _security = security; + _dtoService = dtoService; + _userDataManager = userDataManager; + + _tvDtoService = new LiveTvDtoService(dtoService, userDataManager, imageProcessor, logger, appHost, _libraryManager); + } + + /// <summary> + /// Gets the services. + /// </summary> + /// <value>The services.</value> + public IReadOnlyList<ILiveTvService> Services + { + get { return _services; } + } + + private LiveTvOptions GetConfiguration() + { + return _config.GetConfiguration<LiveTvOptions>("livetv"); + } + + /// <summary> + /// Adds the parts. + /// </summary> + /// <param name="services">The services.</param> + /// <param name="tunerHosts">The tuner hosts.</param> + /// <param name="listingProviders">The listing providers.</param> + public void AddParts(IEnumerable<ILiveTvService> services, IEnumerable<ITunerHost> tunerHosts, IEnumerable<IListingsProvider> listingProviders) + { + _services.AddRange(services); + _tunerHosts.AddRange(tunerHosts); + _listingProviders.AddRange(listingProviders); + + foreach (var service in _services) + { + service.DataSourceChanged += service_DataSourceChanged; + service.RecordingStatusChanged += Service_RecordingStatusChanged; + } + } + + private void Service_RecordingStatusChanged(object sender, RecordingStatusChangedEventArgs e) + { + _lastRecordingRefreshTime = DateTime.MinValue; + } + + public List<ITunerHost> TunerHosts + { + get { return _tunerHosts; } + } + + public List<IListingsProvider> ListingProviders + { + get { return _listingProviders; } + } + + void service_DataSourceChanged(object sender, EventArgs e) + { + if (!_isDisposed) + { + _taskManager.CancelIfRunningAndQueue<RefreshChannelsScheduledTask>(); + } + } + + public async Task<QueryResult<LiveTvChannel>> GetInternalChannels(LiveTvChannelQuery query, CancellationToken cancellationToken) + { + var user = string.IsNullOrEmpty(query.UserId) ? null : _userManager.GetUserById(query.UserId); + + var topFolder = await GetInternalLiveTvFolder(cancellationToken).ConfigureAwait(false); + + var internalQuery = new InternalItemsQuery(user) + { + IsMovie = query.IsMovie, + IsNews = query.IsNews, + IsKids = query.IsKids, + IsSports = query.IsSports, + IsSeries = query.IsSeries, + IncludeItemTypes = new[] { typeof(LiveTvChannel).Name }, + SortOrder = query.SortOrder ?? SortOrder.Ascending, + TopParentIds = new[] { topFolder.Id.ToString("N") }, + IsFavorite = query.IsFavorite, + IsLiked = query.IsLiked, + StartIndex = query.StartIndex, + Limit = query.Limit + }; + + internalQuery.OrderBy.AddRange(query.SortBy.Select(i => new Tuple<string, SortOrder>(i, query.SortOrder ?? SortOrder.Ascending))); + + if (query.EnableFavoriteSorting) + { + internalQuery.OrderBy.Insert(0, new Tuple<string, SortOrder>(ItemSortBy.IsFavoriteOrLiked, SortOrder.Descending)); + } + + if (!internalQuery.OrderBy.Any(i => string.Equals(i.Item1, ItemSortBy.SortName, StringComparison.OrdinalIgnoreCase))) + { + internalQuery.OrderBy.Add(new Tuple<string, SortOrder>(ItemSortBy.SortName, SortOrder.Ascending)); + } + + var channelResult = _libraryManager.GetItemsResult(internalQuery); + + var result = new QueryResult<LiveTvChannel> + { + Items = channelResult.Items.Cast<LiveTvChannel>().ToArray(), + TotalRecordCount = channelResult.TotalRecordCount + }; + + return result; + } + + public LiveTvChannel GetInternalChannel(string id) + { + return GetInternalChannel(new Guid(id)); + } + + private LiveTvChannel GetInternalChannel(Guid id) + { + return _libraryManager.GetItemById(id) as LiveTvChannel; + } + + internal LiveTvProgram GetInternalProgram(string id) + { + return _libraryManager.GetItemById(id) as LiveTvProgram; + } + + internal LiveTvProgram GetInternalProgram(Guid id) + { + return _libraryManager.GetItemById(id) as LiveTvProgram; + } + + public async Task<BaseItem> GetInternalRecording(string id, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(id)) + { + throw new ArgumentNullException("id"); + } + + var result = await GetInternalRecordings(new RecordingQuery + { + Id = id + + }, cancellationToken).ConfigureAwait(false); + + return result.Items.FirstOrDefault(); + } + + public async Task<MediaSourceInfo> GetRecordingStream(string id, CancellationToken cancellationToken) + { + var info = await GetLiveStream(id, null, false, cancellationToken).ConfigureAwait(false); + + return info.Item1; + } + + public async Task<Tuple<MediaSourceInfo, IDirectStreamProvider>> GetChannelStream(string id, string mediaSourceId, CancellationToken cancellationToken) + { + return await GetLiveStream(id, mediaSourceId, true, cancellationToken).ConfigureAwait(false); + } + + public async Task<IEnumerable<MediaSourceInfo>> GetRecordingMediaSources(IHasMediaSources item, CancellationToken cancellationToken) + { + var baseItem = (BaseItem)item; + var service = GetService(baseItem); + + return await service.GetRecordingStreamMediaSources(baseItem.ExternalId, cancellationToken).ConfigureAwait(false); + } + + public async Task<IEnumerable<MediaSourceInfo>> GetChannelMediaSources(IHasMediaSources item, CancellationToken cancellationToken) + { + var baseItem = (LiveTvChannel)item; + var service = GetService(baseItem); + + var sources = await service.GetChannelStreamMediaSources(baseItem.ExternalId, cancellationToken).ConfigureAwait(false); + + if (sources.Count == 0) + { + throw new NotImplementedException(); + } + + var list = sources.ToList(); + + foreach (var source in list) + { + Normalize(source, service, baseItem.ChannelType == ChannelType.TV); + } + + return list; + } + + private ILiveTvService GetService(ILiveTvRecording item) + { + return GetService(item.ServiceName); + } + + private ILiveTvService GetService(BaseItem item) + { + return GetService(item.ServiceName); + } + + private ILiveTvService GetService(string name) + { + return _services.FirstOrDefault(i => string.Equals(i.Name, name, StringComparison.OrdinalIgnoreCase)); + } + + private async Task<Tuple<MediaSourceInfo, IDirectStreamProvider>> GetLiveStream(string id, string mediaSourceId, bool isChannel, CancellationToken cancellationToken) + { + if (string.Equals(id, mediaSourceId, StringComparison.OrdinalIgnoreCase)) + { + mediaSourceId = null; + } + + MediaSourceInfo info; + bool isVideo; + ILiveTvService service; + IDirectStreamProvider directStreamProvider = null; + + if (isChannel) + { + var channel = GetInternalChannel(id); + isVideo = channel.ChannelType == ChannelType.TV; + service = GetService(channel); + _logger.Info("Opening channel stream from {0}, external channel Id: {1}", service.Name, channel.ExternalId); + + var supportsManagedStream = service as ISupportsDirectStreamProvider; + if (supportsManagedStream != null) + { + var streamInfo = await supportsManagedStream.GetChannelStreamWithDirectStreamProvider(channel.ExternalId, mediaSourceId, cancellationToken).ConfigureAwait(false); + info = streamInfo.Item1; + directStreamProvider = streamInfo.Item2; + } + else + { + info = await service.GetChannelStream(channel.ExternalId, mediaSourceId, cancellationToken).ConfigureAwait(false); + } + info.RequiresClosing = true; + + if (info.RequiresClosing) + { + var idPrefix = service.GetType().FullName.GetMD5().ToString("N") + "_"; + + info.LiveStreamId = idPrefix + info.Id; + } + } + else + { + var recording = await GetInternalRecording(id, cancellationToken).ConfigureAwait(false); + isVideo = !string.Equals(recording.MediaType, MediaType.Audio, StringComparison.OrdinalIgnoreCase); + service = GetService(recording); + + _logger.Info("Opening recording stream from {0}, external recording Id: {1}", service.Name, recording.ExternalId); + info = await service.GetRecordingStream(recording.ExternalId, null, cancellationToken).ConfigureAwait(false); + info.RequiresClosing = true; + + if (info.RequiresClosing) + { + var idPrefix = service.GetType().FullName.GetMD5().ToString("N") + "_"; + + info.LiveStreamId = idPrefix + info.Id; + } + } + + _logger.Info("Live stream info: {0}", _jsonSerializer.SerializeToString(info)); + Normalize(info, service, isVideo); + + return new Tuple<MediaSourceInfo, IDirectStreamProvider>(info, directStreamProvider); + } + + private void Normalize(MediaSourceInfo mediaSource, ILiveTvService service, bool isVideo) + { + if (mediaSource.MediaStreams.Count == 0) + { + if (isVideo) + { + mediaSource.MediaStreams.AddRange(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, + + // Set to true if unknown to enable deinterlacing + IsInterlaced = true + }, + new MediaStream + { + Type = MediaStreamType.Audio, + // Set the index to -1 because we don't know the exact index of the audio stream within the container + Index = -1 + } + }); + } + else + { + mediaSource.MediaStreams.AddRange(new List<MediaStream> + { + new MediaStream + { + Type = MediaStreamType.Audio, + // Set the index to -1 because we don't know the exact index of the audio stream within the container + Index = -1 + } + }); + } + } + + // Clean some bad data coming from providers + foreach (var stream in mediaSource.MediaStreams) + { + if (stream.BitRate.HasValue && stream.BitRate <= 0) + { + stream.BitRate = null; + } + if (stream.Channels.HasValue && stream.Channels <= 0) + { + stream.Channels = null; + } + if (stream.AverageFrameRate.HasValue && stream.AverageFrameRate <= 0) + { + stream.AverageFrameRate = null; + } + if (stream.RealFrameRate.HasValue && stream.RealFrameRate <= 0) + { + stream.RealFrameRate = null; + } + if (stream.Width.HasValue && stream.Width <= 0) + { + stream.Width = null; + } + if (stream.Height.HasValue && stream.Height <= 0) + { + stream.Height = null; + } + if (stream.SampleRate.HasValue && stream.SampleRate <= 0) + { + stream.SampleRate = null; + } + if (stream.Level.HasValue && stream.Level <= 0) + { + stream.Level = null; + } + } + + var indexes = mediaSource.MediaStreams.Select(i => i.Index).Distinct().ToList(); + + // If there are duplicate stream indexes, set them all to unknown + if (indexes.Count != mediaSource.MediaStreams.Count) + { + foreach (var stream in mediaSource.MediaStreams) + { + stream.Index = -1; + } + } + + // Set the total bitrate if not already supplied + if (!mediaSource.Bitrate.HasValue) + { + var total = mediaSource.MediaStreams.Select(i => i.BitRate ?? 0).Sum(); + + if (total > 0) + { + mediaSource.Bitrate = total; + } + } + + if (!(service is EmbyTV.EmbyTV)) + { + // We can't trust that we'll be able to direct stream it through emby server, no matter what the provider says + mediaSource.SupportsDirectStream = false; + mediaSource.SupportsTranscoding = true; + foreach (var stream in mediaSource.MediaStreams) + { + if (stream.Type == MediaStreamType.Video && string.IsNullOrWhiteSpace(stream.NalLengthSize)) + { + stream.NalLengthSize = "0"; + } + } + } + } + + private async Task<LiveTvChannel> GetChannel(ChannelInfo channelInfo, string serviceName, Guid parentFolderId, CancellationToken cancellationToken) + { + var isNew = false; + var forceUpdate = false; + + var id = _tvDtoService.GetInternalChannelId(serviceName, channelInfo.Id); + + var item = _itemRepo.RetrieveItem(id) as LiveTvChannel; + + if (item == null) + { + item = new LiveTvChannel + { + Name = channelInfo.Name, + Id = id, + DateCreated = DateTime.UtcNow, + }; + + isNew = true; + } + + if (!string.Equals(channelInfo.Id, item.ExternalId)) + { + isNew = true; + } + item.ExternalId = channelInfo.Id; + + if (!item.ParentId.Equals(parentFolderId)) + { + isNew = true; + } + item.ParentId = parentFolderId; + + item.ChannelType = channelInfo.ChannelType; + item.ServiceName = serviceName; + item.Number = channelInfo.Number; + + //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 (!item.HasImage(ImageType.Primary)) + { + if (!string.IsNullOrWhiteSpace(channelInfo.ImagePath)) + { + item.SetImagePath(ImageType.Primary, channelInfo.ImagePath); + forceUpdate = true; + } + else if (!string.IsNullOrWhiteSpace(channelInfo.ImageUrl)) + { + item.SetImagePath(ImageType.Primary, channelInfo.ImageUrl); + forceUpdate = true; + } + } + + if (string.IsNullOrEmpty(item.Name)) + { + item.Name = channelInfo.Name; + } + + if (isNew) + { + await _libraryManager.CreateItem(item, cancellationToken).ConfigureAwait(false); + } + else if (forceUpdate) + { + await _libraryManager.UpdateItem(item, ItemUpdateType.MetadataImport, cancellationToken).ConfigureAwait(false); + } + + await item.RefreshMetadata(new MetadataRefreshOptions(_fileSystem) + { + ForceSave = isNew || forceUpdate + + }, cancellationToken); + + return item; + } + + private Tuple<LiveTvProgram, bool, bool> GetProgram(ProgramInfo info, Dictionary<Guid, LiveTvProgram> allExistingPrograms, LiveTvChannel channel, ChannelType channelType, string serviceName, CancellationToken cancellationToken) + { + var id = _tvDtoService.GetInternalProgramId(serviceName, info.Id); + + LiveTvProgram item = null; + allExistingPrograms.TryGetValue(id, out item); + + var isNew = false; + var forceUpdate = false; + + if (item == null) + { + isNew = true; + item = new LiveTvProgram + { + Name = info.Name, + Id = id, + DateCreated = DateTime.UtcNow, + DateModified = DateTime.UtcNow, + ExternalEtag = info.Etag + }; + } + + var seriesId = info.SeriesId; + + if (!item.ParentId.Equals(channel.Id)) + { + forceUpdate = true; + } + item.ParentId = channel.Id; + + //item.ChannelType = channelType; + if (!string.Equals(item.ServiceName, serviceName, StringComparison.Ordinal)) + { + forceUpdate = true; + } + item.ServiceName = serviceName; + + item.Audio = info.Audio; + item.ChannelId = channel.Id.ToString("N"); + item.CommunityRating = item.CommunityRating ?? info.CommunityRating; + + item.EpisodeTitle = info.EpisodeTitle; + item.ExternalId = info.Id; + item.ExternalSeriesIdLegacy = seriesId; + + if (!string.IsNullOrWhiteSpace(seriesId) && !string.Equals(item.ExternalSeriesId, seriesId, StringComparison.Ordinal)) + { + forceUpdate = true; + } + item.ExternalSeriesId = seriesId; + + item.Genres = info.Genres; + item.IsHD = info.IsHD; + item.IsKids = info.IsKids; + item.IsLive = info.IsLive; + item.IsMovie = info.IsMovie; + item.IsNews = info.IsNews; + item.IsPremiere = info.IsPremiere; + item.IsRepeat = info.IsRepeat; + item.IsSeries = info.IsSeries; + item.IsSports = info.IsSports; + item.Name = info.Name; + item.OfficialRating = item.OfficialRating ?? info.OfficialRating; + item.Overview = item.Overview ?? info.Overview; + item.RunTimeTicks = (info.EndDate - info.StartDate).Ticks; + + if (item.StartDate != info.StartDate) + { + forceUpdate = true; + } + item.StartDate = info.StartDate; + + if (item.EndDate != info.EndDate) + { + forceUpdate = true; + } + item.EndDate = info.EndDate; + + item.HomePageUrl = info.HomePageUrl; + + item.ProductionYear = info.ProductionYear; + + if (!info.IsSeries || info.IsRepeat) + { + item.PremiereDate = info.OriginalAirDate; + } + + item.IndexNumber = info.EpisodeNumber; + item.ParentIndexNumber = info.SeasonNumber; + + if (!item.HasImage(ImageType.Primary)) + { + if (!string.IsNullOrWhiteSpace(info.ImagePath)) + { + item.SetImage(new ItemImageInfo + { + Path = info.ImagePath, + Type = ImageType.Primary, + IsPlaceholder = true + }, 0); + } + else if (!string.IsNullOrWhiteSpace(info.ImageUrl)) + { + item.SetImage(new ItemImageInfo + { + Path = info.ImageUrl, + Type = ImageType.Primary, + IsPlaceholder = true + }, 0); + } + } + + var isUpdated = false; + if (isNew) + { + } + else if (forceUpdate || string.IsNullOrWhiteSpace(info.Etag)) + { + isUpdated = true; + } + else + { + // Increment this whenver some internal change deems it necessary + var etag = info.Etag + "4"; + + if (!string.Equals(etag, item.ExternalEtag, StringComparison.OrdinalIgnoreCase)) + { + item.ExternalEtag = etag; + isUpdated = true; + } + } + + return new Tuple<LiveTvProgram, bool, bool>(item, isNew, isUpdated); + } + + private async Task<Guid> CreateRecordingRecord(RecordingInfo info, string serviceName, Guid parentFolderId, CancellationToken cancellationToken) + { + var isNew = false; + + var id = _tvDtoService.GetInternalRecordingId(serviceName, info.Id); + + var item = _itemRepo.RetrieveItem(id); + + if (item == null) + { + if (info.ChannelType == ChannelType.TV) + { + item = new LiveTvVideoRecording + { + Name = info.Name, + Id = id, + DateCreated = DateTime.UtcNow, + DateModified = DateTime.UtcNow, + VideoType = VideoType.VideoFile + }; + } + else + { + item = new LiveTvAudioRecording + { + Name = info.Name, + Id = id, + DateCreated = DateTime.UtcNow, + DateModified = DateTime.UtcNow + }; + } + + isNew = true; + } + + item.ChannelId = _tvDtoService.GetInternalChannelId(serviceName, info.ChannelId).ToString("N"); + item.CommunityRating = info.CommunityRating; + item.OfficialRating = info.OfficialRating; + item.Overview = info.Overview; + item.EndDate = info.EndDate; + item.Genres = info.Genres; + item.PremiereDate = info.OriginalAirDate; + + var recording = (ILiveTvRecording)item; + + recording.ExternalId = info.Id; + + var dataChanged = false; + + recording.Audio = info.Audio; + recording.EndDate = info.EndDate; + recording.EpisodeTitle = info.EpisodeTitle; + recording.IsHD = info.IsHD; + recording.IsKids = info.IsKids; + recording.IsLive = info.IsLive; + recording.IsMovie = info.IsMovie; + recording.IsNews = info.IsNews; + recording.IsPremiere = info.IsPremiere; + recording.IsRepeat = info.IsRepeat; + recording.IsSports = info.IsSports; + recording.SeriesTimerId = info.SeriesTimerId; + recording.TimerId = info.TimerId; + recording.StartDate = info.StartDate; + + if (!dataChanged) + { + dataChanged = recording.IsSeries != info.IsSeries; + } + recording.IsSeries = info.IsSeries; + + if (!item.ParentId.Equals(parentFolderId)) + { + dataChanged = true; + } + item.ParentId = parentFolderId; + + if (!item.HasImage(ImageType.Primary)) + { + if (!string.IsNullOrWhiteSpace(info.ImagePath)) + { + item.SetImage(new ItemImageInfo + { + Path = info.ImagePath, + Type = ImageType.Primary, + IsPlaceholder = true + }, 0); + } + else if (!string.IsNullOrWhiteSpace(info.ImageUrl)) + { + item.SetImage(new ItemImageInfo + { + Path = info.ImageUrl, + Type = ImageType.Primary, + IsPlaceholder = true + }, 0); + } + } + + var statusChanged = info.Status != recording.Status; + + recording.Status = info.Status; + + recording.ServiceName = serviceName; + + if (!string.IsNullOrEmpty(info.Path)) + { + if (!dataChanged) + { + dataChanged = !string.Equals(item.Path, info.Path); + } + var fileInfo = _fileSystem.GetFileInfo(info.Path); + + recording.DateCreated = _fileSystem.GetCreationTimeUtc(fileInfo); + recording.DateModified = _fileSystem.GetLastWriteTimeUtc(fileInfo); + item.Path = info.Path; + } + else if (!string.IsNullOrEmpty(info.Url)) + { + if (!dataChanged) + { + dataChanged = !string.Equals(item.Path, info.Url); + } + item.Path = info.Url; + } + + var metadataRefreshMode = MetadataRefreshMode.Default; + + if (isNew) + { + await _libraryManager.CreateItem(item, cancellationToken).ConfigureAwait(false); + } + else if (dataChanged || info.DateLastUpdated > recording.DateLastSaved || statusChanged) + { + metadataRefreshMode = MetadataRefreshMode.FullRefresh; + await _libraryManager.UpdateItem(item, ItemUpdateType.MetadataImport, cancellationToken).ConfigureAwait(false); + } + + if (info.Status != RecordingStatus.InProgress) + { + _providerManager.QueueRefresh(item.Id, new MetadataRefreshOptions(_fileSystem) + { + MetadataRefreshMode = metadataRefreshMode + }); + } + + return item.Id; + } + + public async Task<BaseItemDto> GetProgram(string id, CancellationToken cancellationToken, User user = null) + { + var program = GetInternalProgram(id); + + var dto = _dtoService.GetBaseItemDto(program, new DtoOptions(), user); + + var list = new List<Tuple<BaseItemDto, string, string, string>>(); + list.Add(new Tuple<BaseItemDto, string, string, string>(dto, program.ServiceName, program.ExternalId, program.ExternalSeriesIdLegacy)); + + await AddRecordingInfo(list, cancellationToken).ConfigureAwait(false); + + return dto; + } + + public async Task<QueryResult<BaseItemDto>> GetPrograms(ProgramQuery query, DtoOptions options, CancellationToken cancellationToken) + { + var user = string.IsNullOrEmpty(query.UserId) ? null : _userManager.GetUserById(query.UserId); + + var topFolder = await GetInternalLiveTvFolder(cancellationToken).ConfigureAwait(false); + + if (query.SortBy.Length == 0) + { + // Unless something else was specified, order by start date to take advantage of a specialized index + query.SortBy = new[] { ItemSortBy.StartDate }; + } + + var internalQuery = new InternalItemsQuery(user) + { + IncludeItemTypes = new[] { typeof(LiveTvProgram).Name }, + MinEndDate = query.MinEndDate, + MinStartDate = query.MinStartDate, + MaxEndDate = query.MaxEndDate, + MaxStartDate = query.MaxStartDate, + ChannelIds = query.ChannelIds, + IsMovie = query.IsMovie, + IsSeries = query.IsSeries, + IsSports = query.IsSports, + IsKids = query.IsKids, + IsNews = query.IsNews, + Genres = query.Genres, + StartIndex = query.StartIndex, + Limit = query.Limit, + SortBy = query.SortBy, + SortOrder = query.SortOrder ?? SortOrder.Ascending, + EnableTotalRecordCount = query.EnableTotalRecordCount, + TopParentIds = new[] { topFolder.Id.ToString("N") }, + Name = query.Name, + DtoOptions = options + }; + + if (!string.IsNullOrWhiteSpace(query.SeriesTimerId)) + { + var seriesTimers = await GetSeriesTimersInternal(new SeriesTimerQuery { }, cancellationToken).ConfigureAwait(false); + var seriesTimer = seriesTimers.Items.FirstOrDefault(i => string.Equals(_tvDtoService.GetInternalSeriesTimerId(i.ServiceName, i.Id).ToString("N"), query.SeriesTimerId, StringComparison.OrdinalIgnoreCase)); + if (seriesTimer != null) + { + internalQuery.ExternalSeriesId = seriesTimer.SeriesId; + + if (string.IsNullOrWhiteSpace(seriesTimer.SeriesId)) + { + // Better to return nothing than every program in the database + return new QueryResult<BaseItemDto>(); + } + } + else + { + // Better to return nothing than every program in the database + return new QueryResult<BaseItemDto>(); + } + } + + if (query.HasAired.HasValue) + { + if (query.HasAired.Value) + { + internalQuery.MaxEndDate = DateTime.UtcNow; + } + else + { + internalQuery.MinEndDate = DateTime.UtcNow; + } + } + + var queryResult = _libraryManager.QueryItems(internalQuery); + + RemoveFields(options); + + var returnArray = (await _dtoService.GetBaseItemDtos(queryResult.Items, options, user).ConfigureAwait(false)).ToArray(); + + var result = new QueryResult<BaseItemDto> + { + Items = returnArray, + TotalRecordCount = queryResult.TotalRecordCount + }; + + return result; + } + + public async Task<QueryResult<LiveTvProgram>> GetRecommendedProgramsInternal(RecommendedProgramQuery query, DtoOptions options, CancellationToken cancellationToken) + { + var user = _userManager.GetUserById(query.UserId); + + var topFolder = await GetInternalLiveTvFolder(cancellationToken).ConfigureAwait(false); + + var internalQuery = new InternalItemsQuery(user) + { + IncludeItemTypes = new[] { typeof(LiveTvProgram).Name }, + IsAiring = query.IsAiring, + IsNews = query.IsNews, + IsMovie = query.IsMovie, + IsSeries = query.IsSeries, + IsSports = query.IsSports, + IsKids = query.IsKids, + EnableTotalRecordCount = query.EnableTotalRecordCount, + SortBy = new[] { ItemSortBy.StartDate }, + TopParentIds = new[] { topFolder.Id.ToString("N") }, + DtoOptions = options + }; + + if (query.Limit.HasValue) + { + internalQuery.Limit = Math.Max(query.Limit.Value * 4, 200); + } + + if (query.HasAired.HasValue) + { + if (query.HasAired.Value) + { + internalQuery.MaxEndDate = DateTime.UtcNow; + } + else + { + internalQuery.MinEndDate = DateTime.UtcNow; + } + } + + IEnumerable<LiveTvProgram> programs = _libraryManager.QueryItems(internalQuery).Items.Cast<LiveTvProgram>(); + + var programList = programs.ToList(); + + var factorChannelWatchCount = (query.IsAiring ?? false) || (query.IsKids ?? false) || (query.IsSports ?? false) || (query.IsMovie ?? false) || (query.IsNews ?? false) || (query.IsSeries ?? false); + + programs = programList.OrderBy(i => i.StartDate.Date) + .ThenByDescending(i => GetRecommendationScore(i, user.Id, factorChannelWatchCount)) + .ThenBy(i => i.StartDate); + + if (query.Limit.HasValue) + { + programs = programs.Take(query.Limit.Value); + } + + programList = programs.ToList(); + + var returnArray = programList.ToArray(); + + var result = new QueryResult<LiveTvProgram> + { + Items = returnArray, + TotalRecordCount = returnArray.Length + }; + + return result; + } + + public async Task<QueryResult<BaseItemDto>> GetRecommendedPrograms(RecommendedProgramQuery query, DtoOptions options, CancellationToken cancellationToken) + { + var internalResult = await GetRecommendedProgramsInternal(query, options, cancellationToken).ConfigureAwait(false); + + var user = _userManager.GetUserById(query.UserId); + + RemoveFields(options); + + var returnArray = (await _dtoService.GetBaseItemDtos(internalResult.Items, options, user).ConfigureAwait(false)).ToArray(); + + var result = new QueryResult<BaseItemDto> + { + Items = returnArray, + TotalRecordCount = internalResult.TotalRecordCount + }; + + return result; + } + + private int GetRecommendationScore(LiveTvProgram program, Guid userId, bool factorChannelWatchCount) + { + var score = 0; + + if (program.IsLive) + { + score++; + } + + if (program.IsSeries && !program.IsRepeat) + { + score++; + } + + var channel = GetInternalChannel(program.ChannelId); + + var channelUserdata = _userDataManager.GetUserData(userId, channel); + + if (channelUserdata.Likes ?? false) + { + score += 2; + } + else if (!(channelUserdata.Likes ?? true)) + { + score -= 2; + } + + if (channelUserdata.IsFavorite) + { + score += 3; + } + + if (factorChannelWatchCount) + { + score += channelUserdata.PlayCount; + } + + return score; + } + + private async Task AddRecordingInfo(IEnumerable<Tuple<BaseItemDto, string, string, string>> programs, CancellationToken cancellationToken) + { + var timers = new Dictionary<string, List<TimerInfo>>(); + var seriesTimers = new Dictionary<string, List<SeriesTimerInfo>>(); + + foreach (var programTuple in programs) + { + var program = programTuple.Item1; + var serviceName = programTuple.Item2; + var externalProgramId = programTuple.Item3; + string externalSeriesId = programTuple.Item4; + + if (string.IsNullOrWhiteSpace(serviceName)) + { + continue; + } + + List<TimerInfo> timerList; + if (!timers.TryGetValue(serviceName, out timerList)) + { + try + { + var tempTimers = await GetService(serviceName).GetTimersAsync(cancellationToken).ConfigureAwait(false); + timers[serviceName] = timerList = tempTimers.ToList(); + } + catch (Exception ex) + { + _logger.ErrorException("Error getting timer infos", ex); + timers[serviceName] = timerList = new List<TimerInfo>(); + } + } + + var timer = timerList.FirstOrDefault(i => string.Equals(i.ProgramId, externalProgramId, StringComparison.OrdinalIgnoreCase)); + var foundSeriesTimer = false; + + if (timer != null) + { + if (timer.Status != RecordingStatus.Cancelled && timer.Status != RecordingStatus.Error) + { + program.TimerId = _tvDtoService.GetInternalTimerId(serviceName, timer.Id) + .ToString("N"); + + program.Status = timer.Status.ToString(); + } + + if (!string.IsNullOrEmpty(timer.SeriesTimerId)) + { + program.SeriesTimerId = _tvDtoService.GetInternalSeriesTimerId(serviceName, timer.SeriesTimerId) + .ToString("N"); + + foundSeriesTimer = true; + } + } + + if (foundSeriesTimer || string.IsNullOrWhiteSpace(externalSeriesId)) + { + continue; + } + + List<SeriesTimerInfo> seriesTimerList; + if (!seriesTimers.TryGetValue(serviceName, out seriesTimerList)) + { + try + { + var tempTimers = await GetService(serviceName).GetSeriesTimersAsync(cancellationToken).ConfigureAwait(false); + seriesTimers[serviceName] = seriesTimerList = tempTimers.ToList(); + } + catch (Exception ex) + { + _logger.ErrorException("Error getting series timer infos", ex); + seriesTimers[serviceName] = seriesTimerList = new List<SeriesTimerInfo>(); + } + } + + var seriesTimer = seriesTimerList.FirstOrDefault(i => string.Equals(i.SeriesId, externalSeriesId, StringComparison.OrdinalIgnoreCase)); + + if (seriesTimer != null) + { + program.SeriesTimerId = _tvDtoService.GetInternalSeriesTimerId(serviceName, seriesTimer.Id) + .ToString("N"); + } + } + } + + internal Task RefreshChannels(IProgress<double> progress, CancellationToken cancellationToken) + { + return RefreshChannelsInternal(progress, cancellationToken); + } + + private async Task RefreshChannelsInternal(IProgress<double> progress, CancellationToken cancellationToken) + { + EmbyTV.EmbyTV.Current.CreateRecordingFolders(); + + var numComplete = 0; + double progressPerService = _services.Count == 0 + ? 0 + : 1 / _services.Count; + + var newChannelIdList = new List<Guid>(); + var newProgramIdList = new List<Guid>(); + + foreach (var service in _services) + { + cancellationToken.ThrowIfCancellationRequested(); + + _logger.Debug("Refreshing guide from {0}", service.Name); + + try + { + var innerProgress = new ActionableProgress<double>(); + innerProgress.RegisterAction(p => progress.Report(p * progressPerService)); + + var idList = await RefreshChannelsInternal(service, innerProgress, cancellationToken).ConfigureAwait(false); + + newChannelIdList.AddRange(idList.Item1); + newProgramIdList.AddRange(idList.Item2); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + _logger.ErrorException("Error refreshing channels for service", ex); + } + + numComplete++; + double percent = numComplete; + percent /= _services.Count; + + progress.Report(100 * percent); + } + + await CleanDatabaseInternal(newChannelIdList, new[] { typeof(LiveTvChannel).Name }, progress, cancellationToken).ConfigureAwait(false); + await CleanDatabaseInternal(newProgramIdList, new[] { typeof(LiveTvProgram).Name }, progress, cancellationToken).ConfigureAwait(false); + + var coreService = _services.OfType<EmbyTV.EmbyTV>().FirstOrDefault(); + + if (coreService != null) + { + await coreService.RefreshSeriesTimers(cancellationToken, new Progress<double>()).ConfigureAwait(false); + } + + // Load these now which will prefetch metadata + var dtoOptions = new DtoOptions(); + dtoOptions.Fields.Remove(ItemFields.SyncInfo); + dtoOptions.Fields.Remove(ItemFields.BasicSyncInfo); + await GetRecordings(new RecordingQuery(), dtoOptions, cancellationToken).ConfigureAwait(false); + + progress.Report(100); + } + + private async Task<Tuple<List<Guid>, List<Guid>>> RefreshChannelsInternal(ILiveTvService service, IProgress<double> progress, CancellationToken cancellationToken) + { + progress.Report(10); + + var allChannels = await GetChannels(service, cancellationToken).ConfigureAwait(false); + var allChannelsList = allChannels.ToList(); + + var list = new List<LiveTvChannel>(); + + var numComplete = 0; + var parentFolder = await GetInternalLiveTvFolder(cancellationToken).ConfigureAwait(false); + var parentFolderId = parentFolder.Id; + + foreach (var channelInfo in allChannelsList) + { + cancellationToken.ThrowIfCancellationRequested(); + + try + { + var item = await GetChannel(channelInfo.Item2, channelInfo.Item1, parentFolderId, cancellationToken).ConfigureAwait(false); + + list.Add(item); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + _logger.ErrorException("Error getting channel information for {0}", ex, channelInfo.Item2.Name); + } + + numComplete++; + double percent = numComplete; + percent /= allChannelsList.Count; + + progress.Report(5 * percent + 10); + } + + progress.Report(15); + + numComplete = 0; + var programs = new List<Guid>(); + var channels = new List<Guid>(); + + var guideDays = GetGuideDays(); + + _logger.Info("Refreshing guide with {0} days of guide data", guideDays); + + cancellationToken.ThrowIfCancellationRequested(); + + foreach (var currentChannel in list) + { + channels.Add(currentChannel.Id); + cancellationToken.ThrowIfCancellationRequested(); + + try + { + var start = DateTime.UtcNow.AddHours(-1); + var end = start.AddDays(guideDays); + + var isMovie = false; + var isSports = false; + var isNews = false; + var isKids = false; + var iSSeries = false; + + var channelPrograms = await service.GetProgramsAsync(currentChannel.ExternalId, start, end, cancellationToken).ConfigureAwait(false); + + var existingPrograms = _libraryManager.GetItemList(new InternalItemsQuery + { + + IncludeItemTypes = new string[] { typeof(LiveTvProgram).Name }, + ChannelIds = new string[] { currentChannel.Id.ToString("N") } + + }).Cast<LiveTvProgram>().ToDictionary(i => i.Id); + + var newPrograms = new List<LiveTvProgram>(); + var updatedPrograms = new List<LiveTvProgram>(); + + foreach (var program in channelPrograms) + { + var programTuple = GetProgram(program, existingPrograms, currentChannel, currentChannel.ChannelType, service.Name, cancellationToken); + var programItem = programTuple.Item1; + + if (programTuple.Item2) + { + newPrograms.Add(programItem); + } + else if (programTuple.Item3) + { + updatedPrograms.Add(programItem); + } + + programs.Add(programItem.Id); + + if (program.IsMovie) + { + isMovie = true; + } + + if (program.IsSeries) + { + iSSeries = true; + } + + if (program.IsSports) + { + isSports = true; + } + + if (program.IsNews) + { + isNews = true; + } + + if (program.IsKids) + { + isKids = true; + } + } + + _logger.Debug("Channel {0} has {1} new programs and {2} updated programs", currentChannel.Name, newPrograms.Count, updatedPrograms.Count); + + if (newPrograms.Count > 0) + { + await _libraryManager.CreateItems(newPrograms, cancellationToken).ConfigureAwait(false); + } + + // TODO: Do this in bulk + foreach (var program in updatedPrograms) + { + await _libraryManager.UpdateItem(program, ItemUpdateType.MetadataImport, cancellationToken).ConfigureAwait(false); + } + + foreach (var program in newPrograms) + { + _providerManager.QueueRefresh(program.Id, new MetadataRefreshOptions(_fileSystem)); + } + foreach (var program in updatedPrograms) + { + _providerManager.QueueRefresh(program.Id, new MetadataRefreshOptions(_fileSystem)); + } + + currentChannel.IsMovie = isMovie; + currentChannel.IsNews = isNews; + currentChannel.IsSports = isSports; + currentChannel.IsKids = isKids; + currentChannel.IsSeries = iSSeries; + + await currentChannel.UpdateToRepository(ItemUpdateType.MetadataImport, cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + _logger.ErrorException("Error getting programs for channel {0}", ex, currentChannel.Name); + } + + numComplete++; + double percent = numComplete; + percent /= allChannelsList.Count; + + progress.Report(80 * percent + 10); + } + progress.Report(100); + + return new Tuple<List<Guid>, List<Guid>>(channels, programs); + } + + private async Task CleanDatabaseInternal(List<Guid> currentIdList, string[] validTypes, IProgress<double> progress, CancellationToken cancellationToken) + { + var list = _itemRepo.GetItemIdsList(new InternalItemsQuery + { + IncludeItemTypes = validTypes + + }).ToList(); + + var numComplete = 0; + + foreach (var itemId in list) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (itemId == Guid.Empty) + { + // Somehow some invalid data got into the db. It probably predates the boundary checking + continue; + } + + if (!currentIdList.Contains(itemId)) + { + var item = _libraryManager.GetItemById(itemId); + + if (item != null) + { + await _libraryManager.DeleteItem(item, new DeleteOptions + { + DeleteFileLocation = false + + }).ConfigureAwait(false); + } + } + + numComplete++; + double percent = numComplete; + percent /= list.Count; + + progress.Report(100 * percent); + } + } + + private const int MaxGuideDays = 14; + private double GetGuideDays() + { + var config = GetConfiguration(); + + if (config.GuideDays.HasValue) + { + return Math.Max(1, Math.Min(config.GuideDays.Value, MaxGuideDays)); + } + + return 7; + } + + private async Task<IEnumerable<Tuple<string, ChannelInfo>>> GetChannels(ILiveTvService service, CancellationToken cancellationToken) + { + var channels = await service.GetChannelsAsync(cancellationToken).ConfigureAwait(false); + + return channels.Select(i => new Tuple<string, ChannelInfo>(service.Name, i)); + } + + private DateTime _lastRecordingRefreshTime; + private async Task RefreshRecordings(CancellationToken cancellationToken) + { + const int cacheMinutes = 3; + + if ((DateTime.UtcNow - _lastRecordingRefreshTime).TotalMinutes < cacheMinutes) + { + return; + } + + await _refreshRecordingsLock.WaitAsync(cancellationToken).ConfigureAwait(false); + + try + { + if ((DateTime.UtcNow - _lastRecordingRefreshTime).TotalMinutes < cacheMinutes) + { + return; + } + + var tasks = _services.Select(async i => + { + try + { + var recs = await i.GetRecordingsAsync(cancellationToken).ConfigureAwait(false); + return recs.Select(r => new Tuple<RecordingInfo, ILiveTvService>(r, i)); + } + catch (Exception ex) + { + _logger.ErrorException("Error getting recordings", ex); + return new List<Tuple<RecordingInfo, ILiveTvService>>(); + } + }); + + var results = await Task.WhenAll(tasks).ConfigureAwait(false); + var folder = await GetInternalLiveTvFolder(cancellationToken).ConfigureAwait(false); + var parentFolderId = folder.Id; + + var recordingTasks = results.SelectMany(i => i.ToList()).Select(i => CreateRecordingRecord(i.Item1, i.Item2.Name, parentFolderId, cancellationToken)); + + var idList = await Task.WhenAll(recordingTasks).ConfigureAwait(false); + + await CleanDatabaseInternal(idList.ToList(), new[] { typeof(LiveTvVideoRecording).Name, typeof(LiveTvAudioRecording).Name }, new Progress<double>(), cancellationToken).ConfigureAwait(false); + + _lastRecordingRefreshTime = DateTime.UtcNow; + } + finally + { + _refreshRecordingsLock.Release(); + } + } + + private QueryResult<BaseItem> GetEmbyRecordings(RecordingQuery query, DtoOptions dtoOptions, User user) + { + if (user == null) + { + return new QueryResult<BaseItem>(); + } + + if ((query.IsInProgress ?? false)) + { + return new QueryResult<BaseItem>(); + } + + var folders = EmbyTV.EmbyTV.Current.GetRecordingFolders() + .SelectMany(i => i.Locations) + .Distinct(StringComparer.OrdinalIgnoreCase) + .Select(i => _libraryManager.FindByPath(i, true)) + .Where(i => i != null) + .Where(i => i.IsVisibleStandalone(user)) + .ToList(); + + if (folders.Count == 0) + { + return new QueryResult<BaseItem>(); + } + + var includeItemTypes = new List<string>(); + var excludeItemTypes = new List<string>(); + var genres = new List<string>(); + + if (query.IsMovie.HasValue) + { + if (query.IsMovie.Value) + { + includeItemTypes.Add(typeof(Movie).Name); + } + else + { + excludeItemTypes.Add(typeof(Movie).Name); + } + } + if (query.IsSeries.HasValue) + { + if (query.IsSeries.Value) + { + includeItemTypes.Add(typeof(Episode).Name); + } + else + { + excludeItemTypes.Add(typeof(Episode).Name); + } + } + if (query.IsSports.HasValue) + { + if (query.IsSports.Value) + { + genres.Add("Sports"); + } + } + if (query.IsKids.HasValue) + { + if (query.IsKids.Value) + { + genres.Add("Kids"); + genres.Add("Children"); + genres.Add("Family"); + } + } + + return _libraryManager.GetItemsResult(new InternalItemsQuery(user) + { + MediaTypes = new[] { MediaType.Video }, + Recursive = true, + AncestorIds = folders.Select(i => i.Id.ToString("N")).ToArray(), + IsFolder = false, + ExcludeLocationTypes = new[] { LocationType.Virtual }, + Limit = query.Limit, + SortBy = new[] { ItemSortBy.DateCreated }, + SortOrder = SortOrder.Descending, + EnableTotalRecordCount = query.EnableTotalRecordCount, + IncludeItemTypes = includeItemTypes.ToArray(), + ExcludeItemTypes = excludeItemTypes.ToArray(), + Genres = genres.ToArray(), + DtoOptions = dtoOptions + }); + } + + public async Task<QueryResult<BaseItemDto>> GetRecordingSeries(RecordingQuery query, DtoOptions options, CancellationToken cancellationToken) + { + var user = string.IsNullOrEmpty(query.UserId) ? null : _userManager.GetUserById(query.UserId); + if (user != null && !IsLiveTvEnabled(user)) + { + return new QueryResult<BaseItemDto>(); + } + + if (_services.Count > 1) + { + return new QueryResult<BaseItemDto>(); + } + + if (user == null || (query.IsInProgress ?? false)) + { + return new QueryResult<BaseItemDto>(); + } + + var folders = EmbyTV.EmbyTV.Current.GetRecordingFolders() + .SelectMany(i => i.Locations) + .Distinct(StringComparer.OrdinalIgnoreCase) + .Select(i => _libraryManager.FindByPath(i, true)) + .Where(i => i != null) + .Where(i => i.IsVisibleStandalone(user)) + .ToList(); + + if (folders.Count == 0) + { + return new QueryResult<BaseItemDto>(); + } + + var includeItemTypes = new List<string>(); + var excludeItemTypes = new List<string>(); + + includeItemTypes.Add(typeof(Series).Name); + + var internalResult = _libraryManager.GetItemsResult(new InternalItemsQuery(user) + { + Recursive = true, + AncestorIds = folders.Select(i => i.Id.ToString("N")).ToArray(), + Limit = query.Limit, + SortBy = new[] { ItemSortBy.DateCreated }, + SortOrder = SortOrder.Descending, + EnableTotalRecordCount = query.EnableTotalRecordCount, + IncludeItemTypes = includeItemTypes.ToArray(), + ExcludeItemTypes = excludeItemTypes.ToArray() + }); + + RemoveFields(options); + + var returnArray = (await _dtoService.GetBaseItemDtos(internalResult.Items, options, user).ConfigureAwait(false)).ToArray(); + + return new QueryResult<BaseItemDto> + { + Items = returnArray, + TotalRecordCount = internalResult.TotalRecordCount + }; + } + + public async Task<QueryResult<BaseItem>> GetInternalRecordings(RecordingQuery query, CancellationToken cancellationToken) + { + var user = string.IsNullOrEmpty(query.UserId) ? null : _userManager.GetUserById(query.UserId); + if (user != null && !IsLiveTvEnabled(user)) + { + return new QueryResult<BaseItem>(); + } + + if (_services.Count == 1 && !(query.IsInProgress ?? false)) + { + return GetEmbyRecordings(query, new DtoOptions(), user); + } + + await RefreshRecordings(cancellationToken).ConfigureAwait(false); + + var internalQuery = new InternalItemsQuery(user) + { + IncludeItemTypes = new[] { typeof(LiveTvVideoRecording).Name, typeof(LiveTvAudioRecording).Name } + }; + + if (!string.IsNullOrEmpty(query.ChannelId)) + { + internalQuery.ChannelIds = new[] { query.ChannelId }; + } + + var queryResult = _libraryManager.GetItemList(internalQuery); + IEnumerable<ILiveTvRecording> recordings = queryResult.Cast<ILiveTvRecording>(); + + if (!string.IsNullOrWhiteSpace(query.Id)) + { + var guid = new Guid(query.Id); + + recordings = recordings + .Where(i => i.Id == guid); + } + + if (!string.IsNullOrWhiteSpace(query.GroupId)) + { + var guid = new Guid(query.GroupId); + + recordings = recordings.Where(i => GetRecordingGroupIds(i).Contains(guid)); + } + + if (query.IsInProgress.HasValue) + { + var val = query.IsInProgress.Value; + recordings = recordings.Where(i => i.Status == RecordingStatus.InProgress == val); + } + + if (query.Status.HasValue) + { + var val = query.Status.Value; + recordings = recordings.Where(i => i.Status == val); + } + + if (query.IsMovie.HasValue) + { + var val = query.IsMovie.Value; + recordings = recordings.Where(i => i.IsMovie == val); + } + + if (query.IsNews.HasValue) + { + var val = query.IsNews.Value; + recordings = recordings.Where(i => i.IsNews == val); + } + + if (query.IsSeries.HasValue) + { + var val = query.IsSeries.Value; + recordings = recordings.Where(i => i.IsSeries == val); + } + + if (query.IsKids.HasValue) + { + var val = query.IsKids.Value; + recordings = recordings.Where(i => i.IsKids == val); + } + + if (query.IsSports.HasValue) + { + var val = query.IsSports.Value; + recordings = recordings.Where(i => i.IsSports == val); + } + + if (!string.IsNullOrEmpty(query.SeriesTimerId)) + { + var guid = new Guid(query.SeriesTimerId); + + recordings = recordings + .Where(i => _tvDtoService.GetInternalSeriesTimerId(i.ServiceName, i.SeriesTimerId) == guid); + } + + recordings = recordings.OrderByDescending(i => i.StartDate); + + var entityList = recordings.ToList(); + IEnumerable<ILiveTvRecording> entities = entityList; + + if (query.StartIndex.HasValue) + { + entities = entities.Skip(query.StartIndex.Value); + } + + if (query.Limit.HasValue) + { + entities = entities.Take(query.Limit.Value); + } + + return new QueryResult<BaseItem> + { + Items = entities.Cast<BaseItem>().ToArray(), + TotalRecordCount = entityList.Count + }; + } + + public async Task AddInfoToProgramDto(List<Tuple<BaseItem, BaseItemDto>> tuples, List<ItemFields> fields, User user = null) + { + var programTuples = new List<Tuple<BaseItemDto, string, string, string>>(); + + foreach (var tuple in tuples) + { + var program = (LiveTvProgram)tuple.Item1; + var dto = tuple.Item2; + + dto.StartDate = program.StartDate; + dto.EpisodeTitle = program.EpisodeTitle; + + if (program.IsRepeat) + { + dto.IsRepeat = program.IsRepeat; + } + 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; + } + + if (fields.Contains(ItemFields.ChannelInfo)) + { + var channel = GetInternalChannel(program.ChannelId); + + if (channel != null) + { + dto.ChannelName = channel.Name; + dto.MediaType = channel.MediaType; + dto.ChannelNumber = channel.Number; + + if (channel.HasImage(ImageType.Primary)) + { + dto.ChannelPrimaryImageTag = _tvDtoService.GetImageTag(channel); + } + } + } + + var serviceName = program.ServiceName; + + if (fields.Contains(ItemFields.ServiceName)) + { + dto.ServiceName = serviceName; + } + + programTuples.Add(new Tuple<BaseItemDto, string, string, string>(dto, serviceName, program.ExternalId, program.ExternalSeriesIdLegacy)); + } + + await AddRecordingInfo(programTuples, CancellationToken.None).ConfigureAwait(false); + } + + public void AddInfoToRecordingDto(BaseItem item, BaseItemDto dto, User user = null) + { + var recording = (ILiveTvRecording)item; + var service = GetService(recording); + + var channel = string.IsNullOrWhiteSpace(recording.ChannelId) ? null : GetInternalChannel(recording.ChannelId); + + var info = recording; + + dto.SeriesTimerId = string.IsNullOrEmpty(info.SeriesTimerId) + ? null + : _tvDtoService.GetInternalSeriesTimerId(service.Name, info.SeriesTimerId).ToString("N"); + + dto.TimerId = string.IsNullOrEmpty(info.TimerId) + ? null + : _tvDtoService.GetInternalTimerId(service.Name, info.TimerId).ToString("N"); + + dto.StartDate = info.StartDate; + dto.RecordingStatus = info.Status; + dto.IsRepeat = info.IsRepeat; + dto.EpisodeTitle = info.EpisodeTitle; + dto.IsMovie = info.IsMovie; + dto.IsSeries = info.IsSeries; + dto.IsSports = info.IsSports; + dto.IsLive = info.IsLive; + dto.IsNews = info.IsNews; + dto.IsKids = info.IsKids; + dto.IsPremiere = info.IsPremiere; + + dto.CanDelete = user == null + ? recording.CanDelete() + : recording.CanDelete(user); + + if (dto.MediaSources == null) + { + dto.MediaSources = recording.GetMediaSources(true).ToList(); + } + + if (dto.MediaStreams == null) + { + dto.MediaStreams = dto.MediaSources.SelectMany(i => i.MediaStreams).ToList(); + } + + if (info.Status == RecordingStatus.InProgress && info.EndDate.HasValue) + { + var now = DateTime.UtcNow.Ticks; + var start = info.StartDate.Ticks; + var end = info.EndDate.Value.Ticks; + + var pct = now - start; + pct /= end; + pct *= 100; + dto.CompletionPercentage = pct; + } + + if (channel != null) + { + dto.ChannelName = channel.Name; + + if (channel.HasImage(ImageType.Primary)) + { + dto.ChannelPrimaryImageTag = _tvDtoService.GetImageTag(channel); + } + } + } + + public async Task<QueryResult<BaseItemDto>> GetRecordings(RecordingQuery query, DtoOptions options, CancellationToken cancellationToken) + { + var user = string.IsNullOrEmpty(query.UserId) ? null : _userManager.GetUserById(query.UserId); + + var internalResult = await GetInternalRecordings(query, cancellationToken).ConfigureAwait(false); + + RemoveFields(options); + + var returnArray = (await _dtoService.GetBaseItemDtos(internalResult.Items, options, user).ConfigureAwait(false)).ToArray(); + + return new QueryResult<BaseItemDto> + { + Items = returnArray, + TotalRecordCount = internalResult.TotalRecordCount + }; + } + + public async Task<QueryResult<TimerInfoDto>> GetTimers(TimerQuery query, CancellationToken cancellationToken) + { + var tasks = _services.Select(async i => + { + try + { + var recs = await i.GetTimersAsync(cancellationToken).ConfigureAwait(false); + return recs.Select(r => new Tuple<TimerInfo, ILiveTvService>(r, i)); + } + catch (Exception ex) + { + _logger.ErrorException("Error getting recordings", ex); + return new List<Tuple<TimerInfo, ILiveTvService>>(); + } + }); + var results = await Task.WhenAll(tasks).ConfigureAwait(false); + var timers = results.SelectMany(i => i.ToList()); + + if (query.IsActive.HasValue) + { + if (query.IsActive.Value) + { + timers = timers.Where(i => i.Item1.Status == RecordingStatus.InProgress); + } + else + { + timers = timers.Where(i => i.Item1.Status != RecordingStatus.InProgress); + } + } + + if (query.IsScheduled.HasValue) + { + if (query.IsScheduled.Value) + { + timers = timers.Where(i => i.Item1.Status == RecordingStatus.New); + } + else + { + timers = timers.Where(i => i.Item1.Status != RecordingStatus.New); + } + } + + if (!string.IsNullOrEmpty(query.ChannelId)) + { + var guid = new Guid(query.ChannelId); + timers = timers.Where(i => guid == _tvDtoService.GetInternalChannelId(i.Item2.Name, i.Item1.ChannelId)); + } + + if (!string.IsNullOrEmpty(query.SeriesTimerId)) + { + var guid = new Guid(query.SeriesTimerId); + + timers = timers + .Where(i => _tvDtoService.GetInternalSeriesTimerId(i.Item2.Name, i.Item1.SeriesTimerId) == guid); + } + + var returnList = new List<TimerInfoDto>(); + + foreach (var i in timers) + { + var program = string.IsNullOrEmpty(i.Item1.ProgramId) ? + null : + GetInternalProgram(_tvDtoService.GetInternalProgramId(i.Item2.Name, i.Item1.ProgramId).ToString("N")); + + var channel = string.IsNullOrEmpty(i.Item1.ChannelId) ? null : GetInternalChannel(_tvDtoService.GetInternalChannelId(i.Item2.Name, i.Item1.ChannelId)); + + returnList.Add(_tvDtoService.GetTimerInfoDto(i.Item1, i.Item2, program, channel)); + } + + var returnArray = returnList + .OrderBy(i => i.StartDate) + .ToArray(); + + return new QueryResult<TimerInfoDto> + { + Items = returnArray, + TotalRecordCount = returnArray.Length + }; + } + + public Task OnRecordingFileDeleted(BaseItem recording) + { + var service = GetService(recording); + + if (service is EmbyTV.EmbyTV) + { + // We can't trust that we'll be able to direct stream it through emby server, no matter what the provider says + return service.DeleteRecordingAsync(recording.ExternalId, CancellationToken.None); + } + + return Task.FromResult(true); + } + + public async Task DeleteRecording(string recordingId) + { + var recording = await GetInternalRecording(recordingId, CancellationToken.None).ConfigureAwait(false); + + if (recording == null) + { + throw new ResourceNotFoundException(string.Format("Recording with Id {0} not found", recordingId)); + } + + await DeleteRecording((BaseItem)recording).ConfigureAwait(false); + } + + public async Task DeleteRecording(BaseItem recording) + { + var service = GetService(recording.ServiceName); + + try + { + await service.DeleteRecordingAsync(recording.ExternalId, CancellationToken.None).ConfigureAwait(false); + } + catch (ResourceNotFoundException) + { + + } + + _lastRecordingRefreshTime = DateTime.MinValue; + + // This is the responsibility of the live tv service + await _libraryManager.DeleteItem((BaseItem)recording, new DeleteOptions + { + DeleteFileLocation = false + + }).ConfigureAwait(false); + + _lastRecordingRefreshTime = DateTime.MinValue; + } + + public async Task CancelTimer(string id) + { + var timer = await GetTimer(id, CancellationToken.None).ConfigureAwait(false); + + if (timer == null) + { + throw new ResourceNotFoundException(string.Format("Timer with Id {0} not found", id)); + } + + var service = GetService(timer.ServiceName); + + await service.CancelTimerAsync(timer.ExternalId, CancellationToken.None).ConfigureAwait(false); + _lastRecordingRefreshTime = DateTime.MinValue; + + EventHelper.QueueEventIfNotNull(TimerCancelled, this, new GenericEventArgs<TimerEventInfo> + { + Argument = new TimerEventInfo + { + Id = id + } + }, _logger); + } + + public async Task CancelSeriesTimer(string id) + { + var timer = await GetSeriesTimer(id, CancellationToken.None).ConfigureAwait(false); + + if (timer == null) + { + throw new ResourceNotFoundException(string.Format("Timer with Id {0} not found", id)); + } + + var service = GetService(timer.ServiceName); + + await service.CancelSeriesTimerAsync(timer.ExternalId, CancellationToken.None).ConfigureAwait(false); + _lastRecordingRefreshTime = DateTime.MinValue; + + EventHelper.QueueEventIfNotNull(SeriesTimerCancelled, this, new GenericEventArgs<TimerEventInfo> + { + Argument = new TimerEventInfo + { + Id = id + } + }, _logger); + } + + public async Task<BaseItemDto> GetRecording(string id, DtoOptions options, CancellationToken cancellationToken, User user = null) + { + var item = await GetInternalRecording(id, cancellationToken).ConfigureAwait(false); + + if (item == null) + { + return null; + } + + return _dtoService.GetBaseItemDto((BaseItem)item, options, user); + } + + public async Task<TimerInfoDto> GetTimer(string id, CancellationToken cancellationToken) + { + var results = await GetTimers(new TimerQuery(), cancellationToken).ConfigureAwait(false); + + return results.Items.FirstOrDefault(i => string.Equals(i.Id, id, StringComparison.OrdinalIgnoreCase)); + } + + public async Task<SeriesTimerInfoDto> GetSeriesTimer(string id, CancellationToken cancellationToken) + { + var results = await GetSeriesTimers(new SeriesTimerQuery(), cancellationToken).ConfigureAwait(false); + + return results.Items.FirstOrDefault(i => string.Equals(i.Id, id, StringComparison.OrdinalIgnoreCase)); + } + + private async Task<QueryResult<SeriesTimerInfo>> GetSeriesTimersInternal(SeriesTimerQuery query, CancellationToken cancellationToken) + { + var tasks = _services.Select(async i => + { + try + { + var recs = await i.GetSeriesTimersAsync(cancellationToken).ConfigureAwait(false); + return recs.Select(r => + { + r.ServiceName = i.Name; + return new Tuple<SeriesTimerInfo, ILiveTvService>(r, i); + }); + } + catch (Exception ex) + { + _logger.ErrorException("Error getting recordings", ex); + return new List<Tuple<SeriesTimerInfo, ILiveTvService>>(); + } + }); + var results = await Task.WhenAll(tasks).ConfigureAwait(false); + var timers = results.SelectMany(i => i.ToList()); + + if (string.Equals(query.SortBy, "Priority", StringComparison.OrdinalIgnoreCase)) + { + timers = query.SortOrder == SortOrder.Descending ? + timers.OrderBy(i => i.Item1.Priority).ThenByStringDescending(i => i.Item1.Name) : + timers.OrderByDescending(i => i.Item1.Priority).ThenByString(i => i.Item1.Name); + } + else + { + timers = query.SortOrder == SortOrder.Descending ? + timers.OrderByStringDescending(i => i.Item1.Name) : + timers.OrderByString(i => i.Item1.Name); + } + + var returnArray = timers + .Select(i => + { + return i.Item1; + + }) + .ToArray(); + + return new QueryResult<SeriesTimerInfo> + { + Items = returnArray, + TotalRecordCount = returnArray.Length + }; + } + + public async Task<QueryResult<SeriesTimerInfoDto>> GetSeriesTimers(SeriesTimerQuery query, CancellationToken cancellationToken) + { + var tasks = _services.Select(async i => + { + try + { + var recs = await i.GetSeriesTimersAsync(cancellationToken).ConfigureAwait(false); + return recs.Select(r => new Tuple<SeriesTimerInfo, ILiveTvService>(r, i)); + } + catch (Exception ex) + { + _logger.ErrorException("Error getting recordings", ex); + return new List<Tuple<SeriesTimerInfo, ILiveTvService>>(); + } + }); + var results = await Task.WhenAll(tasks).ConfigureAwait(false); + var timers = results.SelectMany(i => i.ToList()); + + if (string.Equals(query.SortBy, "Priority", StringComparison.OrdinalIgnoreCase)) + { + timers = query.SortOrder == SortOrder.Descending ? + timers.OrderBy(i => i.Item1.Priority).ThenByStringDescending(i => i.Item1.Name) : + timers.OrderByDescending(i => i.Item1.Priority).ThenByString(i => i.Item1.Name); + } + else + { + timers = query.SortOrder == SortOrder.Descending ? + timers.OrderByStringDescending(i => i.Item1.Name) : + timers.OrderByString(i => i.Item1.Name); + } + + var returnArray = timers + .Select(i => + { + string channelName = null; + + if (!string.IsNullOrEmpty(i.Item1.ChannelId)) + { + var internalChannelId = _tvDtoService.GetInternalChannelId(i.Item2.Name, i.Item1.ChannelId); + var channel = GetInternalChannel(internalChannelId); + channelName = channel == null ? null : channel.Name; + } + + return _tvDtoService.GetSeriesTimerInfoDto(i.Item1, i.Item2, channelName); + + }) + .ToArray(); + + return new QueryResult<SeriesTimerInfoDto> + { + Items = returnArray, + TotalRecordCount = returnArray.Length + }; + } + + public void AddChannelInfo(List<Tuple<BaseItemDto, LiveTvChannel>> tuples, DtoOptions options, User user) + { + var now = DateTime.UtcNow; + + var channelIds = tuples.Select(i => i.Item2.Id.ToString("N")).Distinct().ToArray(); + + var programs = options.AddCurrentProgram ? _libraryManager.GetItemList(new InternalItemsQuery(user) + { + IncludeItemTypes = new[] { typeof(LiveTvProgram).Name }, + ChannelIds = channelIds, + MaxStartDate = now, + MinEndDate = now, + Limit = channelIds.Length, + SortBy = new[] { "StartDate" }, + TopParentIds = new[] { GetInternalLiveTvFolder(CancellationToken.None).Result.Id.ToString("N") } + + }).ToList() : new List<BaseItem>(); + + RemoveFields(options); + + foreach (var tuple in tuples) + { + var dto = tuple.Item1; + var channel = tuple.Item2; + + dto.Number = channel.Number; + dto.ChannelNumber = channel.Number; + dto.ChannelType = channel.ChannelType; + dto.ServiceName = channel.ServiceName; + + if (options.Fields.Contains(ItemFields.MediaSources)) + { + dto.MediaSources = channel.GetMediaSources(true).ToList(); + } + + if (options.AddCurrentProgram) + { + var channelIdString = channel.Id.ToString("N"); + var currentProgram = programs.FirstOrDefault(i => string.Equals(i.ChannelId, channelIdString)); + + 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) ? + GetService(program) : + _services.FirstOrDefault(); + + ProgramInfo programInfo = null; + + if (program != null) + { + var channel = GetInternalChannel(program.ChannelId); + + programInfo = new ProgramInfo + { + Audio = program.Audio, + ChannelId = channel.ExternalId, + CommunityRating = program.CommunityRating, + EndDate = program.EndDate ?? DateTime.MinValue, + EpisodeTitle = program.EpisodeTitle, + Genres = program.Genres, + Id = program.ExternalId, + IsHD = program.IsHD, + IsKids = program.IsKids, + IsLive = program.IsLive, + IsMovie = program.IsMovie, + IsNews = program.IsNews, + IsPremiere = program.IsPremiere, + IsRepeat = program.IsRepeat, + IsSeries = program.IsSeries, + IsSports = program.IsSports, + OriginalAirDate = program.PremiereDate, + Overview = program.Overview, + StartDate = program.StartDate, + //ImagePath = program.ExternalImagePath, + Name = program.Name, + OfficialRating = program.OfficialRating + }; + } + + var info = await service.GetNewTimerDefaultsAsync(cancellationToken, programInfo).ConfigureAwait(false); + + info.RecordAnyTime = true; + info.Days = new List<DayOfWeek> + { + DayOfWeek.Sunday, + DayOfWeek.Monday, + DayOfWeek.Tuesday, + DayOfWeek.Wednesday, + DayOfWeek.Thursday, + DayOfWeek.Friday, + DayOfWeek.Saturday + }; + + info.Id = null; + + return new Tuple<SeriesTimerInfo, ILiveTvService>(info, service); + } + + public async Task<SeriesTimerInfoDto> GetNewTimerDefaults(CancellationToken cancellationToken) + { + var info = await GetNewTimerDefaultsInternal(cancellationToken).ConfigureAwait(false); + + var obj = _tvDtoService.GetSeriesTimerInfoDto(info.Item1, info.Item2, null); + + return obj; + } + + public async Task<SeriesTimerInfoDto> GetNewTimerDefaults(string programId, CancellationToken cancellationToken) + { + var program = GetInternalProgram(programId); + var programDto = await GetProgram(programId, cancellationToken).ConfigureAwait(false); + + var defaults = await GetNewTimerDefaultsInternal(cancellationToken, program).ConfigureAwait(false); + var info = _tvDtoService.GetSeriesTimerInfoDto(defaults.Item1, defaults.Item2, null); + + info.Days = defaults.Item1.Days; + + info.DayPattern = _tvDtoService.GetDayPattern(info.Days); + + info.Name = program.Name; + info.ChannelId = programDto.ChannelId; + info.ChannelName = programDto.ChannelName; + info.StartDate = program.StartDate; + info.Name = program.Name; + info.Overview = program.Overview; + info.ProgramId = programDto.Id; + info.ExternalProgramId = program.ExternalId; + + if (program.EndDate.HasValue) + { + info.EndDate = program.EndDate.Value; + } + + return info; + } + + public async Task CreateTimer(TimerInfoDto timer, CancellationToken cancellationToken) + { + var service = GetService(timer.ServiceName); + + var info = await _tvDtoService.GetTimerInfo(timer, true, this, cancellationToken).ConfigureAwait(false); + + // Set priority from default values + var defaultValues = await service.GetNewTimerDefaultsAsync(cancellationToken).ConfigureAwait(false); + info.Priority = defaultValues.Priority; + + string newTimerId = null; + var supportsNewTimerIds = service as ISupportsNewTimerIds; + if (supportsNewTimerIds != null) + { + newTimerId = await supportsNewTimerIds.CreateTimer(info, cancellationToken).ConfigureAwait(false); + newTimerId = _tvDtoService.GetInternalTimerId(timer.ServiceName, newTimerId).ToString("N"); + } + else + { + await service.CreateTimerAsync(info, cancellationToken).ConfigureAwait(false); + } + + _lastRecordingRefreshTime = DateTime.MinValue; + _logger.Info("New recording scheduled"); + + EventHelper.QueueEventIfNotNull(TimerCreated, this, new GenericEventArgs<TimerEventInfo> + { + Argument = new TimerEventInfo + { + ProgramId = _tvDtoService.GetInternalProgramId(timer.ServiceName, info.ProgramId).ToString("N"), + Id = newTimerId + } + }, _logger); + } + + public async Task CreateSeriesTimer(SeriesTimerInfoDto timer, CancellationToken cancellationToken) + { + var registration = await GetRegistrationInfo("seriesrecordings").ConfigureAwait(false); + + if (!registration.IsValid) + { + _logger.Info("Creating series recordings requires an active Emby Premiere subscription."); + return; + } + + var service = GetService(timer.ServiceName); + + var info = await _tvDtoService.GetSeriesTimerInfo(timer, true, this, cancellationToken).ConfigureAwait(false); + + // Set priority from default values + var defaultValues = await service.GetNewTimerDefaultsAsync(cancellationToken).ConfigureAwait(false); + info.Priority = defaultValues.Priority; + + string newTimerId = null; + var supportsNewTimerIds = service as ISupportsNewTimerIds; + if (supportsNewTimerIds != null) + { + newTimerId = await supportsNewTimerIds.CreateSeriesTimer(info, cancellationToken).ConfigureAwait(false); + newTimerId = _tvDtoService.GetInternalSeriesTimerId(timer.ServiceName, newTimerId).ToString("N"); + } + else + { + await service.CreateSeriesTimerAsync(info, cancellationToken).ConfigureAwait(false); + } + + _lastRecordingRefreshTime = DateTime.MinValue; + + EventHelper.QueueEventIfNotNull(SeriesTimerCreated, this, new GenericEventArgs<TimerEventInfo> + { + Argument = new TimerEventInfo + { + ProgramId = _tvDtoService.GetInternalProgramId(timer.ServiceName, info.ProgramId).ToString("N"), + Id = newTimerId + } + }, _logger); + } + + public async Task UpdateTimer(TimerInfoDto timer, CancellationToken cancellationToken) + { + var info = await _tvDtoService.GetTimerInfo(timer, false, this, cancellationToken).ConfigureAwait(false); + + var service = GetService(timer.ServiceName); + + await service.UpdateTimerAsync(info, cancellationToken).ConfigureAwait(false); + _lastRecordingRefreshTime = DateTime.MinValue; + } + + public async Task UpdateSeriesTimer(SeriesTimerInfoDto timer, CancellationToken cancellationToken) + { + var info = await _tvDtoService.GetSeriesTimerInfo(timer, false, this, cancellationToken).ConfigureAwait(false); + + var service = GetService(timer.ServiceName); + + await service.UpdateSeriesTimerAsync(info, cancellationToken).ConfigureAwait(false); + _lastRecordingRefreshTime = DateTime.MinValue; + } + + private IEnumerable<string> GetRecordingGroupNames(ILiveTvRecording recording) + { + var list = new List<string>(); + + if (recording.IsSeries) + { + list.Add(recording.Name); + } + + if (recording.IsKids) + { + list.Add("Kids"); + } + + if (recording.IsMovie) + { + list.Add("Movies"); + } + + if (recording.IsNews) + { + list.Add("News"); + } + + if (recording.IsSports) + { + list.Add("Sports"); + } + + if (!recording.IsSports && !recording.IsNews && !recording.IsMovie && !recording.IsKids && !recording.IsSeries) + { + list.Add("Others"); + } + + return list; + } + + private List<Guid> GetRecordingGroupIds(ILiveTvRecording recording) + { + return GetRecordingGroupNames(recording).Select(i => i.ToLower() + .GetMD5()) + .ToList(); + } + + public async Task<QueryResult<BaseItemDto>> GetRecordingGroups(RecordingGroupQuery query, CancellationToken cancellationToken) + { + var recordingResult = await GetInternalRecordings(new RecordingQuery + { + UserId = query.UserId + + }, cancellationToken).ConfigureAwait(false); + + var recordings = recordingResult.Items.OfType<ILiveTvRecording>().ToList(); + + var groups = new List<BaseItemDto>(); + + var series = recordings + .Where(i => i.IsSeries) + .ToLookup(i => i.Name, StringComparer.OrdinalIgnoreCase) + .ToList(); + + groups.AddRange(series.OrderByString(i => i.Key).Select(i => new BaseItemDto + { + Name = i.Key, + RecordingCount = i.Count() + })); + + groups.Add(new BaseItemDto + { + Name = "Kids", + RecordingCount = recordings.Count(i => i.IsKids) + }); + + groups.Add(new BaseItemDto + { + Name = "Movies", + RecordingCount = recordings.Count(i => i.IsMovie) + }); + + groups.Add(new BaseItemDto + { + Name = "News", + RecordingCount = recordings.Count(i => i.IsNews) + }); + + groups.Add(new BaseItemDto + { + Name = "Sports", + RecordingCount = recordings.Count(i => i.IsSports) + }); + + groups.Add(new BaseItemDto + { + Name = "Others", + RecordingCount = recordings.Count(i => !i.IsSports && !i.IsNews && !i.IsMovie && !i.IsKids && !i.IsSeries) + }); + + groups = groups + .Where(i => i.RecordingCount > 0) + .ToList(); + + foreach (var group in groups) + { + group.Id = group.Name.ToLower().GetMD5().ToString("N"); + } + + return new QueryResult<BaseItemDto> + { + Items = groups.ToArray(), + TotalRecordCount = groups.Count + }; + } + + public async Task CloseLiveStream(string id) + { + var parts = id.Split(new[] { '_' }, 2); + + var service = _services.FirstOrDefault(i => string.Equals(i.GetType().FullName.GetMD5().ToString("N"), parts[0], StringComparison.OrdinalIgnoreCase)); + + if (service == null) + { + throw new ArgumentException("Service not found."); + } + + id = parts[1]; + + _logger.Info("Closing live stream from {0}, stream Id: {1}", service.Name, id); + + await service.CloseLiveStream(id, CancellationToken.None).ConfigureAwait(false); + } + + public GuideInfo GetGuideInfo() + { + var startDate = DateTime.UtcNow; + var endDate = startDate.AddDays(14); + + return new GuideInfo + { + StartDate = startDate, + EndDate = endDate + }; + } + + /// <summary> + /// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources. + /// </summary> + public void Dispose() + { + Dispose(true); + } + + private bool _isDisposed = false; + /// <summary> + /// Releases unmanaged and - optionally - managed resources. + /// </summary> + /// <param name="dispose"><c>true</c> to release both managed and unmanaged resources; <c>false</c> to release only unmanaged resources.</param> + protected virtual void Dispose(bool dispose) + { + if (dispose) + { + _isDisposed = true; + } + } + + private async Task<IEnumerable<LiveTvServiceInfo>> GetServiceInfos(CancellationToken cancellationToken) + { + var tasks = Services.Select(i => GetServiceInfo(i, cancellationToken)); + + return await Task.WhenAll(tasks).ConfigureAwait(false); + } + + private async Task<LiveTvServiceInfo> GetServiceInfo(ILiveTvService service, CancellationToken cancellationToken) + { + var info = new LiveTvServiceInfo + { + Name = service.Name + }; + + var tunerIdPrefix = service.GetType().FullName.GetMD5().ToString("N") + "_"; + + try + { + var statusInfo = await service.GetStatusInfoAsync(cancellationToken).ConfigureAwait(false); + + info.Status = statusInfo.Status; + info.StatusMessage = statusInfo.StatusMessage; + info.Version = statusInfo.Version; + info.HasUpdateAvailable = statusInfo.HasUpdateAvailable; + info.HomePageUrl = service.HomePageUrl; + info.IsVisible = statusInfo.IsVisible; + + info.Tuners = statusInfo.Tuners.Select(i => + { + string channelName = null; + + if (!string.IsNullOrEmpty(i.ChannelId)) + { + var internalChannelId = _tvDtoService.GetInternalChannelId(service.Name, i.ChannelId); + var channel = GetInternalChannel(internalChannelId); + channelName = channel == null ? null : channel.Name; + } + + var dto = _tvDtoService.GetTunerInfoDto(service.Name, i, channelName); + + dto.Id = tunerIdPrefix + dto.Id; + + return dto; + + }).ToList(); + } + catch (Exception ex) + { + _logger.ErrorException("Error getting service status info from {0}", ex, service.Name ?? string.Empty); + + info.Status = LiveTvServiceStatus.Unavailable; + info.StatusMessage = ex.Message; + } + + return info; + } + + public async Task<LiveTvInfo> GetLiveTvInfo(CancellationToken cancellationToken) + { + var services = await GetServiceInfos(CancellationToken.None).ConfigureAwait(false); + var servicesList = services.ToList(); + + var info = new LiveTvInfo + { + Services = servicesList.ToList(), + IsEnabled = servicesList.Count > 0 + }; + + info.EnabledUsers = _userManager.Users + .Where(IsLiveTvEnabled) + .Select(i => i.Id.ToString("N")) + .ToList(); + + return info; + } + + private bool IsLiveTvEnabled(User user) + { + return user.Policy.EnableLiveTvAccess && (Services.Count > 1 || GetConfiguration().TunerHosts.Count(i => i.IsEnabled) > 0); + } + + public IEnumerable<User> GetEnabledUsers() + { + return _userManager.Users + .Where(IsLiveTvEnabled); + } + + /// <summary> + /// Resets the tuner. + /// </summary> + /// <param name="id">The identifier.</param> + /// <param name="cancellationToken">The cancellation token.</param> + /// <returns>Task.</returns> + public Task ResetTuner(string id, CancellationToken cancellationToken) + { + var parts = id.Split(new[] { '_' }, 2); + + var service = _services.FirstOrDefault(i => string.Equals(i.GetType().FullName.GetMD5().ToString("N"), parts[0], StringComparison.OrdinalIgnoreCase)); + + if (service == null) + { + throw new ArgumentException("Service not found."); + } + + return service.ResetTuner(parts[1], cancellationToken); + } + + public async Task<BaseItemDto> GetLiveTvFolder(string userId, CancellationToken cancellationToken) + { + var user = string.IsNullOrEmpty(userId) ? null : _userManager.GetUserById(userId); + + var folder = await GetInternalLiveTvFolder(cancellationToken).ConfigureAwait(false); + + return _dtoService.GetBaseItemDto(folder, new DtoOptions(), user); + } + + private void RemoveFields(DtoOptions options) + { + options.Fields.Remove(ItemFields.CanDelete); + options.Fields.Remove(ItemFields.CanDownload); + options.Fields.Remove(ItemFields.DisplayPreferencesId); + options.Fields.Remove(ItemFields.Etag); + } + + public async Task<Folder> GetInternalLiveTvFolder(CancellationToken cancellationToken) + { + var name = _localization.GetLocalizedString("ViewTypeLiveTV"); + return await _libraryManager.GetNamedView(name, CollectionType.LiveTv, name, cancellationToken).ConfigureAwait(false); + } + + public async Task<TunerHostInfo> SaveTunerHost(TunerHostInfo info, bool dataSourceChanged = true) + { + info = _jsonSerializer.DeserializeFromString<TunerHostInfo>(_jsonSerializer.SerializeToString(info)); + + var provider = _tunerHosts.FirstOrDefault(i => string.Equals(info.Type, i.Type, StringComparison.OrdinalIgnoreCase)); + + if (provider == null) + { + throw new ResourceNotFoundException(); + } + + var configurable = provider as IConfigurableTunerHost; + if (configurable != null) + { + await configurable.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); + + if (dataSourceChanged) + { + _taskManager.CancelIfRunningAndQueue<RefreshChannelsScheduledTask>(); + } + + return info; + } + + public async Task<ListingsProviderInfo> SaveListingProvider(ListingsProviderInfo info, bool validateLogin, bool validateListings) + { + info = _jsonSerializer.DeserializeFromString< ListingsProviderInfo>(_jsonSerializer.SerializeToString(info)); + + 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 void DeleteListingsProvider(string id) + { + var config = GetConfiguration(); + + config.ListingProviders = config.ListingProviders.Where(i => !string.Equals(id, i.Id, StringComparison.OrdinalIgnoreCase)).ToList(); + + _config.SaveConfiguration("livetv", config); + _taskManager.CancelIfRunningAndQueue<RefreshChannelsScheduledTask>(); + } + + public async Task<TunerChannelMapping> SetChannelMapping(string providerId, string tunerChannelNumber, string providerChannelNumber) + { + var config = GetConfiguration(); + + var listingsProviderInfo = config.ListingProviders.First(i => string.Equals(providerId, i.Id, StringComparison.OrdinalIgnoreCase)); + listingsProviderInfo.ChannelMappings = listingsProviderInfo.ChannelMappings.Where(i => !string.Equals(i.Name, tunerChannelNumber, StringComparison.OrdinalIgnoreCase)).ToArray(); + + if (!string.Equals(tunerChannelNumber, providerChannelNumber, StringComparison.OrdinalIgnoreCase)) + { + var list = listingsProviderInfo.ChannelMappings.ToList(); + list.Add(new NameValuePair + { + Name = tunerChannelNumber, + Value = providerChannelNumber + }); + listingsProviderInfo.ChannelMappings = list.ToArray(); + } + + _config.SaveConfiguration("livetv", config); + + var tunerChannels = await GetChannelsForListingsProvider(providerId, CancellationToken.None) + .ConfigureAwait(false); + + var providerChannels = await GetChannelsFromListingsProviderData(providerId, CancellationToken.None) + .ConfigureAwait(false); + + var mappings = listingsProviderInfo.ChannelMappings.ToList(); + + var tunerChannelMappings = + tunerChannels.Select(i => GetTunerChannelMapping(i, mappings, providerChannels)).ToList(); + + _taskManager.CancelIfRunningAndQueue<RefreshChannelsScheduledTask>(); + + return tunerChannelMappings.First(i => string.Equals(i.Number, tunerChannelNumber, StringComparison.OrdinalIgnoreCase)); + } + + public TunerChannelMapping GetTunerChannelMapping(ChannelInfo channel, List<NameValuePair> mappings, List<ChannelInfo> providerChannels) + { + var result = new TunerChannelMapping + { + Name = channel.Number + " " + channel.Name, + Number = channel.Number + }; + + var mapping = mappings.FirstOrDefault(i => string.Equals(i.Name, channel.Number, StringComparison.OrdinalIgnoreCase)); + var providerChannelNumber = channel.Number; + + if (mapping != null) + { + providerChannelNumber = mapping.Value; + } + + var providerChannel = providerChannels.FirstOrDefault(i => string.Equals(i.Number, providerChannelNumber, StringComparison.OrdinalIgnoreCase)); + + if (providerChannel != null) + { + result.ProviderChannelNumber = providerChannel.Number; + result.ProviderChannelName = providerChannel.Name; + } + + return result; + } + + public Task<List<NameIdPair>> GetLineups(string providerType, string providerId, string country, string location) + { + var config = GetConfiguration(); + + if (string.IsNullOrWhiteSpace(providerId)) + { + var provider = _listingProviders.FirstOrDefault(i => string.Equals(providerType, i.Type, StringComparison.OrdinalIgnoreCase)); + + if (provider == 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 feature) + { + if (string.Equals(feature, "seriesrecordings", StringComparison.OrdinalIgnoreCase)) + { + feature = "embytvseriesrecordings"; + } + + if (string.Equals(feature, "dvr-l", StringComparison.OrdinalIgnoreCase)) + { + var config = GetConfiguration(); + if (config.TunerHosts.Count(i => i.IsEnabled) > 0 && + config.ListingProviders.Count(i => (i.EnableAllTuners || i.EnabledTuners.Length > 0) && string.Equals(i.Type, SchedulesDirect.TypeName, StringComparison.OrdinalIgnoreCase)) > 0) + { + return Task.FromResult(new MBRegistrationRecord + { + IsRegistered = true, + IsValid = true + }); + } + } + + return _security.GetRegistrationStatus(feature); + } + + public List<NameValuePair> GetSatIniMappings() + { + return new List<NameValuePair>(); + //var names = GetType().Assembly.GetManifestResourceNames().Where(i => i.IndexOf("SatIp.ini", StringComparison.OrdinalIgnoreCase) != -1).ToList(); + + //return names.Select(GetSatIniMappings).Where(i => i != null).DistinctBy(i => i.Value.Split('|')[0]).ToList(); + } + + public NameValuePair GetSatIniMappings(string resource) + { + return new NameValuePair(); + //using (var stream = GetType().Assembly.GetManifestResourceStream(resource)) + //{ + // using (var reader = new StreamReader(stream)) + // { + // var parser = new StreamIniDataParser(); + // IniData data = parser.ReadData(reader); + + // var satType1 = data["SATTYPE"]["1"]; + // var satType2 = data["SATTYPE"]["2"]; + + // if (string.IsNullOrWhiteSpace(satType2)) + // { + // return null; + // } + + // var srch = "SatIp.ini."; + // var filename = Path.GetFileName(resource); + + // return new NameValuePair + // { + // Name = satType1 + " " + satType2, + // Value = satType2 + "|" + filename.Substring(filename.IndexOf(srch) + srch.Length) + // }; + // } + //} + } + + public Task<List<ChannelInfo>> GetSatChannelScanResult(TunerHostInfo info, CancellationToken cancellationToken) + { + return Task.FromResult(new List<ChannelInfo>()); + //return new TunerHosts.SatIp.ChannelScan(_logger).Scan(info, cancellationToken); + } + + public Task<List<ChannelInfo>> GetChannelsForListingsProvider(string id, CancellationToken cancellationToken) + { + var info = GetConfiguration().ListingProviders.First(i => string.Equals(i.Id, id, StringComparison.OrdinalIgnoreCase)); + return EmbyTV.EmbyTV.Current.GetChannelsForListingsProvider(info, cancellationToken); + } + + public Task<List<ChannelInfo>> GetChannelsFromListingsProviderData(string id, CancellationToken cancellationToken) + { + var info = GetConfiguration().ListingProviders.First(i => string.Equals(i.Id, id, StringComparison.OrdinalIgnoreCase)); + var provider = _listingProviders.First(i => string.Equals(i.Type, info.Type, StringComparison.OrdinalIgnoreCase)); + return provider.GetChannels(info, cancellationToken); + } + } +}
\ No newline at end of file diff --git a/Emby.Server.Implementations/LiveTv/LiveTvMediaSourceProvider.cs b/Emby.Server.Implementations/LiveTv/LiveTvMediaSourceProvider.cs new file mode 100644 index 000000000..e0a35686e --- /dev/null +++ b/Emby.Server.Implementations/LiveTv/LiveTvMediaSourceProvider.cs @@ -0,0 +1,219 @@ +using MediaBrowser.Controller; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.LiveTv; +using MediaBrowser.Controller.MediaEncoding; +using MediaBrowser.Model.Dto; +using MediaBrowser.Model.Logging; +using MediaBrowser.Model.MediaInfo; +using MediaBrowser.Model.Serialization; +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using MediaBrowser.Model.Dlna; + +namespace Emby.Server.Implementations.LiveTv +{ + public class LiveTvMediaSourceProvider : IMediaSourceProvider + { + private readonly ILiveTvManager _liveTvManager; + private readonly IJsonSerializer _jsonSerializer; + private readonly ILogger _logger; + private readonly IMediaSourceManager _mediaSourceManager; + private readonly IMediaEncoder _mediaEncoder; + private readonly IServerApplicationHost _appHost; + + public LiveTvMediaSourceProvider(ILiveTvManager liveTvManager, IJsonSerializer jsonSerializer, ILogManager logManager, IMediaSourceManager mediaSourceManager, IMediaEncoder mediaEncoder, IServerApplicationHost appHost) + { + _liveTvManager = liveTvManager; + _jsonSerializer = jsonSerializer; + _mediaSourceManager = mediaSourceManager; + _mediaEncoder = mediaEncoder; + _appHost = appHost; + _logger = logManager.GetLogger(GetType().Name); + } + + public Task<IEnumerable<MediaSourceInfo>> GetMediaSources(IHasMediaSources item, CancellationToken cancellationToken) + { + var baseItem = (BaseItem)item; + + if (baseItem.SourceType == SourceType.LiveTV) + { + if (string.IsNullOrWhiteSpace(baseItem.Path)) + { + return GetMediaSourcesInternal(item, cancellationToken); + } + } + + 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(IHasMediaSources item, CancellationToken cancellationToken) + { + IEnumerable<MediaSourceInfo> sources; + + var forceRequireOpening = false; + + try + { + if (item is ILiveTvRecording) + { + sources = await _liveTvManager.GetRecordingMediaSources(item, cancellationToken) + .ConfigureAwait(false); + } + else + { + sources = await _liveTvManager.GetChannelMediaSources(item, cancellationToken) + .ConfigureAwait(false); + } + } + catch (NotImplementedException) + { + var hasMediaSources = (IHasMediaSources)item; + + sources = _mediaSourceManager.GetStaticMediaSources(hasMediaSources, false) + .ToList(); + + forceRequireOpening = true; + } + + var list = sources.ToList(); + var serverUrl = await _appHost.GetLocalApiUrl().ConfigureAwait(false); + + foreach (var source in list) + { + source.Type = MediaSourceType.Default; + source.BufferMs = source.BufferMs ?? 1500; + + 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) + { + source.Path = serverUrl; + } + } + + _logger.Debug("MediaSources: {0}", _jsonSerializer.SerializeToString(list)); + + return list; + } + + public async Task<Tuple<MediaSourceInfo, IDirectStreamProvider>> OpenMediaSource(string openToken, CancellationToken cancellationToken) + { + MediaSourceInfo stream = null; + const bool isAudio = false; + + var keys = openToken.Split(new[] { StreamIdDelimeter }, 3); + var mediaSourceId = keys.Length >= 3 ? keys[2] : null; + IDirectStreamProvider directStreamProvider = null; + + if (string.Equals(keys[0], typeof(LiveTvChannel).Name, StringComparison.OrdinalIgnoreCase)) + { + var info = await _liveTvManager.GetChannelStream(keys[1], mediaSourceId, cancellationToken).ConfigureAwait(false); + stream = info.Item1; + directStreamProvider = info.Item2; + } + else + { + stream = await _liveTvManager.GetRecordingStream(keys[1], cancellationToken).ConfigureAwait(false); + } + + try + { + if (!stream.SupportsProbing || stream.MediaStreams.Any(i => i.Index != -1)) + { + await AddMediaInfo(stream, isAudio, cancellationToken).ConfigureAwait(false); + } + else + { + await new LiveStreamHelper(_mediaEncoder, _logger).AddMediaInfoWithProbe(stream, isAudio, cancellationToken).ConfigureAwait(false); + } + } + catch (Exception ex) + { + _logger.ErrorException("Error probing live tv stream", ex); + } + + return new Tuple<MediaSourceInfo, IDirectStreamProvider>(stream, directStreamProvider); + } + + private async Task AddMediaInfo(MediaSourceInfo mediaSource, bool isAudio, CancellationToken cancellationToken) + { + mediaSource.DefaultSubtitleStreamIndex = null; + + // Null this out so that it will be treated like a live stream + mediaSource.RunTimeTicks = null; + + var audioStream = mediaSource.MediaStreams.FirstOrDefault(i => i.Type == MediaBrowser.Model.Entities.MediaStreamType.Audio); + + if (audioStream == null || audioStream.Index == -1) + { + mediaSource.DefaultAudioStreamIndex = null; + } + else + { + mediaSource.DefaultAudioStreamIndex = audioStream.Index; + } + + var videoStream = mediaSource.MediaStreams.FirstOrDefault(i => i.Type == MediaBrowser.Model.Entities.MediaStreamType.Video); + if (videoStream != null) + { + if (!videoStream.BitRate.HasValue) + { + var width = videoStream.Width ?? 1920; + + if (width >= 1900) + { + videoStream.BitRate = 8000000; + } + + else if (width >= 1260) + { + videoStream.BitRate = 3000000; + } + + else if (width >= 700) + { + videoStream.BitRate = 1000000; + } + } + } + + // Try to estimate this + if (!mediaSource.Bitrate.HasValue) + { + var total = mediaSource.MediaStreams.Select(i => i.BitRate ?? 0).Sum(); + + if (total > 0) + { + mediaSource.Bitrate = total; + } + } + } + + public Task CloseMediaSource(string liveStreamId) + { + return _liveTvManager.CloseLiveStream(liveStreamId); + } + } +} diff --git a/Emby.Server.Implementations/LiveTv/RefreshChannelsScheduledTask.cs b/Emby.Server.Implementations/LiveTv/RefreshChannelsScheduledTask.cs new file mode 100644 index 000000000..f2806292d --- /dev/null +++ b/Emby.Server.Implementations/LiveTv/RefreshChannelsScheduledTask.cs @@ -0,0 +1,83 @@ +using MediaBrowser.Common.Configuration; +using MediaBrowser.Controller.LiveTv; +using MediaBrowser.Model.LiveTv; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using MediaBrowser.Model.Tasks; + +namespace Emby.Server.Implementations.LiveTv +{ + public class RefreshChannelsScheduledTask : IScheduledTask, IConfigurableScheduledTask + { + private readonly ILiveTvManager _liveTvManager; + private readonly IConfigurationManager _config; + + public RefreshChannelsScheduledTask(ILiveTvManager liveTvManager, IConfigurationManager config) + { + _liveTvManager = liveTvManager; + _config = config; + } + + public string Name + { + get { return "Refresh Guide"; } + } + + public string Description + { + get { return "Downloads channel information from live tv services."; } + } + + public string Category + { + get { return "Live TV"; } + } + + public Task Execute(System.Threading.CancellationToken cancellationToken, IProgress<double> progress) + { + var manager = (LiveTvManager)_liveTvManager; + + return manager.RefreshChannels(progress, cancellationToken); + } + + /// <summary> + /// Creates the triggers that define when the task will run + /// </summary> + /// <returns>IEnumerable{BaseTaskTrigger}.</returns> + public IEnumerable<TaskTriggerInfo> GetDefaultTriggers() + { + return new[] { + + // Every so often + new TaskTriggerInfo { Type = TaskTriggerInfo.TriggerInterval, IntervalTicks = TimeSpan.FromHours(12).Ticks} + }; + } + + private LiveTvOptions GetConfiguration() + { + return _config.GetConfiguration<LiveTvOptions>("livetv"); + } + + public bool IsHidden + { + get { return _liveTvManager.Services.Count == 1 && GetConfiguration().TunerHosts.Count(i => i.IsEnabled) == 0; } + } + + public bool IsEnabled + { + get { return true; } + } + + public bool IsLogged + { + get { return true; } + } + + public string Key + { + get { return "RefreshGuide"; } + } + } +} diff --git a/Emby.Server.Implementations/LiveTv/TunerHosts/BaseTunerHost.cs b/Emby.Server.Implementations/LiveTv/TunerHosts/BaseTunerHost.cs new file mode 100644 index 000000000..ad43a611b --- /dev/null +++ b/Emby.Server.Implementations/LiveTv/TunerHosts/BaseTunerHost.cs @@ -0,0 +1,249 @@ +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.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using MediaBrowser.Controller.Configuration; +using MediaBrowser.Controller.MediaEncoding; +using MediaBrowser.Model.Dlna; +using MediaBrowser.Model.Serialization; + +namespace Emby.Server.Implementations.LiveTv.TunerHosts +{ + public abstract class BaseTunerHost + { + protected readonly IServerConfigurationManager Config; + protected readonly ILogger Logger; + protected IJsonSerializer JsonSerializer; + protected readonly IMediaEncoder MediaEncoder; + + private readonly ConcurrentDictionary<string, ChannelCache> _channelCache = + new ConcurrentDictionary<string, ChannelCache>(StringComparer.OrdinalIgnoreCase); + + protected BaseTunerHost(IServerConfigurationManager config, ILogger logger, IJsonSerializer jsonSerializer, IMediaEncoder mediaEncoder) + { + Config = config; + Logger = logger; + JsonSerializer = jsonSerializer; + MediaEncoder = mediaEncoder; + } + + 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(); + Logger.Debug("Channels from {0}: {1}", tuner.Url, JsonSerializer.SerializeToString(list)); + + if (!string.IsNullOrWhiteSpace(key) && list.Count > 0) + { + cache = cache ?? new ChannelCache(); + cache.Date = DateTime.UtcNow; + cache.Channels = list; + _channelCache.AddOrUpdate(key, cache, (k, v) => cache); + } + + return list; + } + + protected virtual List<TunerHostInfo> GetTunerHosts() + { + return GetConfiguration().TunerHosts + .Where(i => i.IsEnabled && string.Equals(i.Type, Type, StringComparison.OrdinalIgnoreCase)) + .ToList(); + } + + public async Task<IEnumerable<ChannelInfo>> GetChannels(bool enableCache, CancellationToken cancellationToken) + { + var list = new List<ChannelInfo>(); + + var hosts = GetTunerHosts(); + + foreach (var host in hosts) + { + try + { + var channels = await GetChannels(host, enableCache, cancellationToken).ConfigureAwait(false); + var newChannels = channels.Where(i => !list.Any(l => string.Equals(i.Id, l.Id, StringComparison.OrdinalIgnoreCase))).ToList(); + + list.AddRange(newChannels); + } + 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) + { + try + { + var channels = await GetChannels(host, true, cancellationToken).ConfigureAwait(false); + + if (channels.Any(i => string.Equals(i.Id, channelId, StringComparison.OrdinalIgnoreCase))) + { + hostsWithChannel.Add(host); + } + } + catch (Exception ex) + { + Logger.Error("Error getting channels", ex); + } + } + + foreach (var host in hostsWithChannel) + { + try + { + // 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; + } + catch (Exception ex) + { + Logger.Error("Error opening tuner", ex); + } + } + } + + return new List<MediaSourceInfo>(); + } + + protected abstract Task<LiveStream> GetChannelStream(TunerHostInfo tuner, string channelId, string streamId, CancellationToken cancellationToken); + + public async Task<LiveStream> GetChannelStream(string channelId, string streamId, CancellationToken cancellationToken) + { + if (!IsValidChannelId(channelId)) + { + throw new FileNotFoundException(); + } + + var hosts = GetTunerHosts(); + + var hostsWithChannel = new List<TunerHostInfo>(); + + foreach (var host in hosts) + { + if (string.IsNullOrWhiteSpace(streamId)) + { + try + { + var channels = await GetChannels(host, true, cancellationToken).ConfigureAwait(false); + + if (channels.Any(i => string.Equals(i.Id, channelId, StringComparison.OrdinalIgnoreCase))) + { + hostsWithChannel.Add(host); + } + } + catch (Exception ex) + { + Logger.Error("Error getting channels", ex); + } + } + 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) + { + try + { + var liveStream = await GetChannelStream(host, channelId, streamId, cancellationToken).ConfigureAwait(false); + await liveStream.Open(cancellationToken).ConfigureAwait(false); + return liveStream; + } + catch (Exception ex) + { + Logger.Error("Error opening tuner", ex); + } + } + + throw new LiveTvConflictException(); + } + + protected virtual bool EnableMediaProbing + { + get { return false; } + } + + 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/Emby.Server.Implementations/LiveTv/TunerHosts/HdHomerun/HdHomerunDiscovery.cs b/Emby.Server.Implementations/LiveTv/TunerHosts/HdHomerun/HdHomerunDiscovery.cs new file mode 100644 index 000000000..f2e48fbc0 --- /dev/null +++ b/Emby.Server.Implementations/LiveTv/TunerHosts/HdHomerun/HdHomerunDiscovery.cs @@ -0,0 +1,159 @@ +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; +using MediaBrowser.Common.Net; +using MediaBrowser.Model.Dlna; +using MediaBrowser.Model.Events; +using MediaBrowser.Model.Serialization; + +namespace Emby.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); + private readonly IHttpClient _httpClient; + private readonly IJsonSerializer _json; + + public HdHomerunDiscovery(IDeviceDiscovery deviceDiscovery, IServerConfigurationManager config, ILogger logger, ILiveTvManager liveTvManager, IHttpClient httpClient, IJsonSerializer json) + { + _deviceDiscovery = deviceDiscovery; + _config = config; + _logger = logger; + _liveTvManager = liveTvManager; + _httpClient = httpClient; + _json = json; + } + + public void Run() + { + _deviceDiscovery.DeviceDiscovered += _deviceDiscovery_DeviceDiscovered; + } + + void _deviceDiscovery_DeviceDiscovered(object sender, GenericEventArgs<UpnpDeviceInfo> e) + { + string server = null; + var info = e.Argument; + + if (info.Headers.TryGetValue("SERVER", out server) && server.IndexOf("HDHomeRun", StringComparison.OrdinalIgnoreCase) != -1) + { + string location; + if (info.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('/'); + + // Test it by pulling down the lineup + using (var stream = await _httpClient.Get(new HttpRequestOptions + { + Url = string.Format("{0}/discover.json", url), + CancellationToken = CancellationToken.None, + BufferContent = false + })) + { + var response = _json.DeserializeFromStream<HdHomerunHost.DiscoverResponse>(stream); + + var existing = GetConfiguration().TunerHosts + .FirstOrDefault(i => string.Equals(i.Type, HdHomerunHost.DeviceType, StringComparison.OrdinalIgnoreCase) && string.Equals(i.DeviceId, response.DeviceID, StringComparison.OrdinalIgnoreCase)); + + if (existing == null) + { + await _liveTvManager.SaveTunerHost(new TunerHostInfo + { + Type = HdHomerunHost.DeviceType, + Url = url, + DataVersion = 1, + DeviceId = response.DeviceID + + }).ConfigureAwait(false); + } + else + { + if (!string.Equals(existing.Url, url, StringComparison.OrdinalIgnoreCase)) + { + existing.Url = url; + await _liveTvManager.SaveTunerHost(existing).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/Emby.Server.Implementations/LiveTv/TunerHosts/HdHomerun/HdHomerunHost.cs b/Emby.Server.Implementations/LiveTv/TunerHosts/HdHomerun/HdHomerunHost.cs new file mode 100644 index 000000000..2d75367d9 --- /dev/null +++ b/Emby.Server.Implementations/LiveTv/TunerHosts/HdHomerun/HdHomerunHost.cs @@ -0,0 +1,570 @@ +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; +using MediaBrowser.Model.IO; +using MediaBrowser.Common.Extensions; +using MediaBrowser.Common.IO; +using MediaBrowser.Controller; +using MediaBrowser.Controller.Configuration; +using MediaBrowser.Controller.IO; +using MediaBrowser.Controller.MediaEncoding; +using MediaBrowser.Model.Configuration; +using MediaBrowser.Model.Net; + +namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun +{ + public class HdHomerunHost : BaseTunerHost, ITunerHost, IConfigurableTunerHost + { + private readonly IHttpClient _httpClient; + private readonly IFileSystem _fileSystem; + private readonly IServerApplicationHost _appHost; + + public HdHomerunHost(IServerConfigurationManager config, ILogger logger, IJsonSerializer jsonSerializer, IMediaEncoder mediaEncoder, IHttpClient httpClient, IFileSystem fileSystem, IServerApplicationHost appHost) + : base(config, logger, jsonSerializer, mediaEncoder) + { + _httpClient = httpClient; + _fileSystem = fileSystem; + _appHost = appHost; + } + + 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_"; + + private string GetChannelId(TunerHostInfo info, Channels i) + { + var id = ChannelIdPrefix + i.GuideNumber; + + if (info.DataVersion >= 1) + { + id += '_' + (i.GuideName ?? string.Empty).GetMD5().ToString("N"); + } + + return id; + } + + private async Task<IEnumerable<Channels>> GetLineup(TunerHostInfo info, CancellationToken cancellationToken) + { + var options = new HttpRequestOptions + { + Url = string.Format("{0}/lineup.json", GetApiUrl(info, false)), + CancellationToken = cancellationToken, + BufferContent = false + }; + using (var stream = await _httpClient.Get(options)) + { + var lineup = JsonSerializer.DeserializeFromStream<List<Channels>>(stream) ?? new List<Channels>(); + + if (info.ImportFavoritesOnly) + { + lineup = lineup.Where(i => i.Favorite).ToList(); + } + + return lineup.Where(i => !i.DRM).ToList(); + } + } + + protected override async Task<IEnumerable<ChannelInfo>> GetChannelsInternal(TunerHostInfo info, CancellationToken cancellationToken) + { + var lineup = await GetLineup(info, cancellationToken).ConfigureAwait(false); + + return lineup.Select(i => new ChannelInfo + { + Name = i.GuideName, + Number = i.GuideNumber, + Id = GetChannelId(info, i), + IsFavorite = i.Favorite, + TunerHostId = info.Id, + IsHD = i.HD == 1, + AudioCodec = i.AudioCodec, + VideoCodec = i.VideoCodec + }); + } + + private readonly Dictionary<string, DiscoverResponse> _modelCache = new Dictionary<string, DiscoverResponse>(); + private async Task<string> GetModelInfo(TunerHostInfo info, CancellationToken cancellationToken) + { + lock (_modelCache) + { + DiscoverResponse response; + if (_modelCache.TryGetValue(info.Url, out response)) + { + return response.ModelNumber; + } + } + + try + { + using (var stream = await _httpClient.Get(new HttpRequestOptions() + { + Url = string.Format("{0}/discover.json", GetApiUrl(info, false)), + CancellationToken = cancellationToken, + CacheLength = TimeSpan.FromDays(1), + CacheMode = CacheMode.Unconditional, + TimeoutMs = Convert.ToInt32(TimeSpan.FromSeconds(5).TotalMilliseconds), + BufferContent = false + })) + { + var response = JsonSerializer.DeserializeFromStream<DiscoverResponse>(stream); + + lock (_modelCache) + { + _modelCache[info.Id] = response; + } + + return response.ModelNumber; + } + } + catch (HttpException ex) + { + if (ex.StatusCode.HasValue && ex.StatusCode.Value == System.Net.HttpStatusCode.NotFound) + { + var defaultValue = "HDHR"; + // HDHR4 doesn't have this api + lock (_modelCache) + { + _modelCache[info.Id] = new DiscoverResponse + { + ModelNumber = defaultValue + }; + } + return defaultValue; + } + + throw; + } + } + + 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, + TimeoutMs = Convert.ToInt32(TimeSpan.FromSeconds(5).TotalMilliseconds), + BufferContent = false + })) + { + 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 VideoCodec { get; set; } + public string AudioCodec { get; set; } + public string URL { get; set; } + public bool Favorite { get; set; } + public bool DRM { get; set; } + public int HD { get; set; } + } + + private async Task<MediaSourceInfo> GetMediaSource(TunerHostInfo info, string channelId, string profile) + { + int? width = null; + int? height = null; + bool isInterlaced = true; + string videoCodec = null; + string audioCodec = "ac3"; + + int? videoBitrate = null; + int? audioBitrate = 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 = 15000000; + } + else if (string.Equals(profile, "internet540", StringComparison.OrdinalIgnoreCase)) + { + width = 960; + height = 546; + 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 channels = await GetChannels(info, true, CancellationToken.None).ConfigureAwait(false); + var channel = channels.FirstOrDefault(i => string.Equals(i.Number, channelId, StringComparison.OrdinalIgnoreCase)); + if (channel != null) + { + if (string.IsNullOrWhiteSpace(videoCodec)) + { + videoCodec = channel.VideoCodec; + } + audioCodec = channel.AudioCodec; + + if (!videoBitrate.HasValue) + { + videoBitrate = (channel.IsHD ?? true) ? 15000000 : 2000000; + } + audioBitrate = (channel.IsHD ?? true) ? 448000 : 192000; + } + + // normalize + if (string.Equals(videoCodec, "mpeg2", StringComparison.OrdinalIgnoreCase)) + { + videoCodec = "mpeg2video"; + } + + string nal = null; + if (string.Equals(videoCodec, "h264", StringComparison.OrdinalIgnoreCase)) + { + nal = "0"; + } + + var url = GetApiUrl(info, true) + "/auto/v" + channelId; + + if (!string.IsNullOrWhiteSpace(profile) && !string.Equals(profile, "native", StringComparison.OrdinalIgnoreCase)) + { + url += "?transcode=" + profile; + } + + var id = profile; + if (string.IsNullOrWhiteSpace(id)) + { + id = "native"; + } + id += "_" + url.GetMD5().ToString("N"); + + 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, + NalLengthSize = nal + + }, + new MediaStream + { + Type = MediaStreamType.Audio, + // Set the index to -1 because we don't know the exact index of the audio stream within the container + Index = -1, + Codec = audioCodec, + BitRate = audioBitrate + } + }, + RequiresOpening = true, + RequiresClosing = false, + BufferMs = 0, + Container = "ts", + Id = id, + SupportsDirectPlay = false, + SupportsDirectStream = true, + SupportsTranscoding = true, + IsInfiniteStream = true + }; + + return mediaSource; + } + + protected EncodingOptions GetEncodingOptions() + { + return Config.GetConfiguration<EncodingOptions>("encoding"); + } + + private string GetHdHrIdFromChannelId(string channelId) + { + return channelId.Split('_')[1]; + } + + 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; + } + var hdhrId = GetHdHrIdFromChannelId(channelId); + + list.Add(await GetMediaSource(info, hdhrId, "native").ConfigureAwait(false)); + + try + { + if (info.AllowHWTranscoding) + { + string model = await GetModelInfo(info, cancellationToken).ConfigureAwait(false); + model = model ?? string.Empty; + + if ((model.IndexOf("hdtc", StringComparison.OrdinalIgnoreCase) != -1)) + { + list.Add(await GetMediaSource(info, hdhrId, "heavy").ConfigureAwait(false)); + + list.Add(await GetMediaSource(info, hdhrId, "internet540").ConfigureAwait(false)); + list.Add(await GetMediaSource(info, hdhrId, "internet480").ConfigureAwait(false)); + list.Add(await GetMediaSource(info, hdhrId, "internet360").ConfigureAwait(false)); + list.Add(await GetMediaSource(info, hdhrId, "internet240").ConfigureAwait(false)); + list.Add(await GetMediaSource(info, hdhrId, "mobile").ConfigureAwait(false)); + } + } + } + catch + { + + } + + return list; + } + + protected override bool IsValidChannelId(string channelId) + { + if (string.IsNullOrWhiteSpace(channelId)) + { + throw new ArgumentNullException("channelId"); + } + + return channelId.StartsWith(ChannelIdPrefix, StringComparison.OrdinalIgnoreCase); + } + + protected override async Task<LiveStream> GetChannelStream(TunerHostInfo info, string channelId, string streamId, CancellationToken cancellationToken) + { + var profile = streamId.Split('_')[0]; + + Logger.Info("GetChannelStream: channel id: {0}. stream id: {1} profile: {2}", channelId, streamId, profile); + + if (!channelId.StartsWith(ChannelIdPrefix, StringComparison.OrdinalIgnoreCase)) + { + throw new ArgumentException("Channel not found"); + } + var hdhrId = GetHdHrIdFromChannelId(channelId); + + var mediaSource = await GetMediaSource(info, hdhrId, profile).ConfigureAwait(false); + + var liveStream = new HdHomerunLiveStream(mediaSource, streamId, _fileSystem, _httpClient, Logger, Config.ApplicationPaths, _appHost); + liveStream.EnableStreamSharing = true; + return liveStream; + } + + public async Task Validate(TunerHostInfo info) + { + if (!info.IsEnabled) + { + return; + } + + lock (_modelCache) + { + _modelCache.Clear(); + } + + try + { + // Test it by pulling down the lineup + using (var stream = await _httpClient.Get(new HttpRequestOptions + { + Url = string.Format("{0}/discover.json", GetApiUrl(info, false)), + CancellationToken = CancellationToken.None, + BufferContent = false + })) + { + var response = JsonSerializer.DeserializeFromStream<DiscoverResponse>(stream); + + info.DeviceId = response.DeviceID; + } + } + catch (HttpException ex) + { + if (ex.StatusCode.HasValue && ex.StatusCode.Value == System.Net.HttpStatusCode.NotFound) + { + // HDHR4 doesn't have this api + return; + } + + throw; + } + } + + 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); + } + + public class DiscoverResponse + { + public string FriendlyName { get; set; } + public string ModelNumber { get; set; } + public string FirmwareName { get; set; } + public string FirmwareVersion { get; set; } + public string DeviceID { get; set; } + public string DeviceAuth { get; set; } + public string BaseURL { get; set; } + public string LineupURL { get; set; } + } + } +} diff --git a/Emby.Server.Implementations/LiveTv/TunerHosts/HdHomerun/HdHomerunLiveStream.cs b/Emby.Server.Implementations/LiveTv/TunerHosts/HdHomerun/HdHomerunLiveStream.cs new file mode 100644 index 000000000..1e8057f87 --- /dev/null +++ b/Emby.Server.Implementations/LiveTv/TunerHosts/HdHomerun/HdHomerunLiveStream.cs @@ -0,0 +1,141 @@ +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using MediaBrowser.Model.IO; +using MediaBrowser.Common.Net; +using MediaBrowser.Controller; +using MediaBrowser.Controller.LiveTv; +using MediaBrowser.Controller.Library; +using MediaBrowser.Model.Dto; +using MediaBrowser.Model.Logging; +using MediaBrowser.Model.MediaInfo; + +namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun +{ + public class HdHomerunLiveStream : LiveStream, IDirectStreamProvider + { + private readonly ILogger _logger; + private readonly IHttpClient _httpClient; + private readonly IFileSystem _fileSystem; + private readonly IServerApplicationPaths _appPaths; + private readonly IServerApplicationHost _appHost; + + private readonly CancellationTokenSource _liveStreamCancellationTokenSource = new CancellationTokenSource(); + private readonly TaskCompletionSource<bool> _liveStreamTaskCompletionSource = new TaskCompletionSource<bool>(); + private readonly MulticastStream _multicastStream; + + + public HdHomerunLiveStream(MediaSourceInfo mediaSource, string originalStreamId, IFileSystem fileSystem, IHttpClient httpClient, ILogger logger, IServerApplicationPaths appPaths, IServerApplicationHost appHost) + : base(mediaSource) + { + _fileSystem = fileSystem; + _httpClient = httpClient; + _logger = logger; + _appPaths = appPaths; + _appHost = appHost; + OriginalStreamId = originalStreamId; + _multicastStream = new MulticastStream(_logger); + } + + protected override async Task OpenInternal(CancellationToken openCancellationToken) + { + _liveStreamCancellationTokenSource.Token.ThrowIfCancellationRequested(); + + var mediaSource = OriginalMediaSource; + + var url = mediaSource.Path; + + _logger.Info("Opening HDHR Live stream from {0}", url); + + var taskCompletionSource = new TaskCompletionSource<bool>(); + + StartStreaming(url, taskCompletionSource, _liveStreamCancellationTokenSource.Token); + + //OpenedMediaSource.Protocol = MediaProtocol.File; + //OpenedMediaSource.Path = tempFile; + //OpenedMediaSource.ReadAtNativeFramerate = true; + + OpenedMediaSource.Path = _appHost.GetLocalApiUrl("127.0.0.1") + "/LiveTv/LiveStreamFiles/" + UniqueId + "/stream.ts"; + OpenedMediaSource.Protocol = MediaProtocol.Http; + OpenedMediaSource.SupportsDirectPlay = false; + OpenedMediaSource.SupportsDirectStream = true; + OpenedMediaSource.SupportsTranscoding = true; + + await taskCompletionSource.Task.ConfigureAwait(false); + + //await Task.Delay(5000).ConfigureAwait(false); + } + + public override Task Close() + { + _logger.Info("Closing HDHR live stream"); + _liveStreamCancellationTokenSource.Cancel(); + + return _liveStreamTaskCompletionSource.Task; + } + + private async Task StartStreaming(string url, TaskCompletionSource<bool> openTaskCompletionSource, CancellationToken cancellationToken) + { + await Task.Run(async () => + { + var isFirstAttempt = true; + + while (!cancellationToken.IsCancellationRequested) + { + try + { + using (var response = await _httpClient.SendAsync(new HttpRequestOptions + { + Url = url, + CancellationToken = cancellationToken, + BufferContent = false + + }, "GET").ConfigureAwait(false)) + { + _logger.Info("Opened HDHR stream from {0}", url); + + if (!cancellationToken.IsCancellationRequested) + { + _logger.Info("Beginning multicastStream.CopyUntilCancelled"); + + Action onStarted = null; + if (isFirstAttempt) + { + onStarted = () => openTaskCompletionSource.TrySetResult(true); + } + + await _multicastStream.CopyUntilCancelled(response.Content, onStarted, cancellationToken).ConfigureAwait(false); + } + } + } + catch (OperationCanceledException) + { + break; + } + catch (Exception ex) + { + if (isFirstAttempt) + { + _logger.ErrorException("Error opening live stream:", ex); + openTaskCompletionSource.TrySetException(ex); + break; + } + + _logger.ErrorException("Error copying live stream, will reopen", ex); + } + + isFirstAttempt = false; + } + + _liveStreamTaskCompletionSource.TrySetResult(true); + + }).ConfigureAwait(false); + } + + public Task CopyToAsync(Stream stream, CancellationToken cancellationToken) + { + return _multicastStream.CopyToAsync(stream); + } + } +} diff --git a/Emby.Server.Implementations/LiveTv/TunerHosts/M3UTunerHost.cs b/Emby.Server.Implementations/LiveTv/TunerHosts/M3UTunerHost.cs new file mode 100644 index 000000000..756c3377c --- /dev/null +++ b/Emby.Server.Implementations/LiveTv/TunerHosts/M3UTunerHost.cs @@ -0,0 +1,166 @@ +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.Linq; +using System.Threading; +using System.Threading.Tasks; +using MediaBrowser.Common.IO; +using MediaBrowser.Model.IO; +using MediaBrowser.Common.Net; +using MediaBrowser.Controller; +using MediaBrowser.Controller.Configuration; +using MediaBrowser.Controller.IO; +using MediaBrowser.Controller.MediaEncoding; +using MediaBrowser.Model.Serialization; + +namespace Emby.Server.Implementations.LiveTv.TunerHosts +{ + public class M3UTunerHost : BaseTunerHost, ITunerHost, IConfigurableTunerHost + { + private readonly IFileSystem _fileSystem; + private readonly IHttpClient _httpClient; + private readonly IServerApplicationHost _appHost; + + public M3UTunerHost(IServerConfigurationManager config, ILogger logger, IJsonSerializer jsonSerializer, IMediaEncoder mediaEncoder, IFileSystem fileSystem, IHttpClient httpClient, IServerApplicationHost appHost) + : base(config, logger, jsonSerializer, mediaEncoder) + { + _fileSystem = fileSystem; + _httpClient = httpClient; + _appHost = appHost; + } + + 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) + { + return await new M3uParser(Logger, _fileSystem, _httpClient, _appHost).Parse(info.Url, ChannelIdPrefix, info.Id, cancellationToken).ConfigureAwait(false); + } + + public Task<List<LiveTvTunerInfo>> GetTunerInfos(CancellationToken cancellationToken) + { + var list = GetTunerHosts() + .Select(i => new LiveTvTunerInfo() + { + Name = Name, + SourceType = Type, + Status = LiveTvTunerStatus.Available, + Id = i.Url.GetMD5().ToString("N"), + Url = i.Url + }) + .ToList(); + + return Task.FromResult(list); + } + + protected override async Task<LiveStream> GetChannelStream(TunerHostInfo info, string channelId, string streamId, CancellationToken cancellationToken) + { + var sources = await GetChannelStreamMediaSources(info, channelId, cancellationToken).ConfigureAwait(false); + + var liveStream = new LiveStream(sources.First()); + return liveStream; + } + + public async Task Validate(TunerHostInfo info) + { + using (var stream = await new M3uParser(Logger, _fileSystem, _httpClient, _appHost).GetListingsStream(info.Url, CancellationToken.None).ConfigureAwait(false)) + { + + } + } + + 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; + } + + 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; + } + else if (path.StartsWith("udp", StringComparison.OrdinalIgnoreCase)) + { + protocol = MediaProtocol.Udp; + } + + 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, + + ReadAtNativeFramerate = false, + + Id = channel.Path.GetMD5().ToString("N"), + IsInfiniteStream = true + }; + + return new List<MediaSourceInfo> { mediaSource }; + } + return new List<MediaSourceInfo>(); + } + + protected override Task<bool> IsAvailableInternal(TunerHostInfo tuner, string channelId, CancellationToken cancellationToken) + { + return Task.FromResult(true); + } + } +} diff --git a/Emby.Server.Implementations/LiveTv/TunerHosts/M3uParser.cs b/Emby.Server.Implementations/LiveTv/TunerHosts/M3uParser.cs new file mode 100644 index 000000000..8784d5753 --- /dev/null +++ b/Emby.Server.Implementations/LiveTv/TunerHosts/M3uParser.cs @@ -0,0 +1,169 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; +using MediaBrowser.Model.IO; +using MediaBrowser.Common.Extensions; +using MediaBrowser.Common.IO; +using MediaBrowser.Common.Net; +using MediaBrowser.Controller; +using MediaBrowser.Controller.IO; +using MediaBrowser.Controller.LiveTv; +using MediaBrowser.Model.Logging; + +namespace Emby.Server.Implementations.LiveTv.TunerHosts +{ + public class M3uParser + { + private readonly ILogger _logger; + private readonly IFileSystem _fileSystem; + private readonly IHttpClient _httpClient; + private readonly IServerApplicationHost _appHost; + + public M3uParser(ILogger logger, IFileSystem fileSystem, IHttpClient httpClient, IServerApplicationHost appHost) + { + _logger = logger; + _fileSystem = fileSystem; + _httpClient = httpClient; + _appHost = appHost; + } + + public async Task<List<M3UChannel>> Parse(string url, string channelIdPrefix, string tunerHostId, CancellationToken cancellationToken) + { + var urlHash = url.GetMD5().ToString("N"); + + // Read the file and display it line by line. + using (var reader = new StreamReader(await GetListingsStream(url, cancellationToken).ConfigureAwait(false))) + { + return GetChannels(reader, urlHash, channelIdPrefix, tunerHostId); + } + } + + public Task<Stream> GetListingsStream(string url, CancellationToken cancellationToken) + { + if (url.StartsWith("http", StringComparison.OrdinalIgnoreCase)) + { + return _httpClient.Get(new HttpRequestOptions + { + Url = url, + CancellationToken = cancellationToken, + // Some data providers will require a user agent + UserAgent = _appHost.FriendlyName + "/" + _appHost.ApplicationVersion + }); + } + return Task.FromResult(_fileSystem.OpenRead(url)); + } + + private List<M3UChannel> GetChannels(StreamReader reader, string urlHash, string channelIdPrefix, string tunerHostId) + { + var channels = new List<M3UChannel>(); + string line; + string extInf = ""; + while ((line = reader.ReadLine()) != null) + { + line = line.Trim(); + if (string.IsNullOrWhiteSpace(line)) + { + continue; + } + + if (line.StartsWith("#EXTM3U", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + if (line.StartsWith("#EXTINF:", StringComparison.OrdinalIgnoreCase)) + { + extInf = line.Substring(8).Trim(); + _logger.Info("Found m3u channel: {0}", extInf); + } + else if (!string.IsNullOrWhiteSpace(extInf) && !line.StartsWith("#", StringComparison.OrdinalIgnoreCase)) + { + var channel = GetChannelnfo(extInf, tunerHostId, line); + channel.Id = channelIdPrefix + urlHash + line.GetMD5().ToString("N"); + channel.Path = line; + channels.Add(channel); + extInf = ""; + } + } + return channels; + } + private M3UChannel GetChannelnfo(string extInf, string tunerHostId, string mediaUrl) + { + var titleIndex = extInf.LastIndexOf(','); + var channel = new M3UChannel(); + channel.TunerHostId = tunerHostId; + + channel.Number = extInf.Trim().Split(' ')[0] ?? "0"; + channel.Name = extInf.Substring(titleIndex + 1); + + //Check for channel number with the format from SatIp + int number; + var numberIndex = channel.Name.IndexOf('.'); + if (numberIndex > 0) + { + if (int.TryParse(channel.Name.Substring(0, numberIndex), out number)) + { + channel.Number = number.ToString(); + channel.Name = channel.Name.Substring(numberIndex + 1); + } + } + + if (string.Equals(channel.Number, "-1", StringComparison.OrdinalIgnoreCase) && !string.IsNullOrWhiteSpace(mediaUrl)) + { + channel.Number = Path.GetFileNameWithoutExtension(mediaUrl.Split('/').Last()); + } + + if (string.Equals(channel.Number, "-1", StringComparison.OrdinalIgnoreCase)) + { + channel.Number = "0"; + } + + channel.ImageUrl = FindProperty("tvg-logo", extInf); + + var name = FindProperty("tvg-name", extInf); + if (string.IsNullOrWhiteSpace(name)) + { + name = FindProperty("tvg-id", extInf); + } + + channel.Name = name; + + var numberString = FindProperty("tvg-id", extInf); + if (string.IsNullOrWhiteSpace(numberString)) + { + numberString = FindProperty("channel-id", extInf); + } + + if (!string.IsNullOrWhiteSpace(numberString)) + { + channel.Number = numberString; + } + + return channel; + + } + private string FindProperty(string property, string properties) + { + var reg = new Regex(@"([a-z0-9\-_]+)=\""([^""]+)\""", RegexOptions.IgnoreCase); + var matches = reg.Matches(properties); + foreach (Match match in matches) + { + if (match.Groups[1].Value == property) + { + return match.Groups[2].Value; + } + } + return null; + } + } + + + public class M3UChannel : ChannelInfo + { + public string Path { get; set; } + } +}
\ No newline at end of file diff --git a/Emby.Server.Implementations/LiveTv/TunerHosts/MulticastStream.cs b/Emby.Server.Implementations/LiveTv/TunerHosts/MulticastStream.cs new file mode 100644 index 000000000..360a2cee7 --- /dev/null +++ b/Emby.Server.Implementations/LiveTv/TunerHosts/MulticastStream.cs @@ -0,0 +1,96 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using MediaBrowser.Model.Logging; + +namespace Emby.Server.Implementations.LiveTv.TunerHosts +{ + public class MulticastStream + { + private readonly List<QueueStream> _outputStreams = new List<QueueStream>(); + private const int BufferSize = 81920; + private CancellationToken _cancellationToken; + private readonly ILogger _logger; + + public MulticastStream(ILogger logger) + { + _logger = logger; + } + + public async Task CopyUntilCancelled(Stream source, Action onStarted, CancellationToken cancellationToken) + { + _cancellationToken = cancellationToken; + + while (!cancellationToken.IsCancellationRequested) + { + byte[] buffer = new byte[BufferSize]; + + var bytesRead = await source.ReadAsync(buffer, 0, buffer.Length, cancellationToken).ConfigureAwait(false); + + if (bytesRead > 0) + { + byte[] copy = new byte[bytesRead]; + Buffer.BlockCopy(buffer, 0, copy, 0, bytesRead); + + List<QueueStream> streams = null; + + lock (_outputStreams) + { + streams = _outputStreams.ToList(); + } + + foreach (var stream in streams) + { + stream.Queue(copy); + } + + if (onStarted != null) + { + var onStartedCopy = onStarted; + onStarted = null; + Task.Run(onStartedCopy); + } + } + + else + { + await Task.Delay(100).ConfigureAwait(false); + } + } + } + + public Task CopyToAsync(Stream stream) + { + var result = new QueueStream(stream, _logger) + { + OnFinished = OnFinished + }; + + lock (_outputStreams) + { + _outputStreams.Add(result); + } + + result.Start(_cancellationToken); + + return result.TaskCompletion.Task; + } + + public void RemoveOutputStream(QueueStream stream) + { + lock (_outputStreams) + { + _outputStreams.Remove(stream); + } + } + + private void OnFinished(QueueStream queueStream) + { + RemoveOutputStream(queueStream); + } + } +} diff --git a/Emby.Server.Implementations/LiveTv/TunerHosts/QueueStream.cs b/Emby.Server.Implementations/LiveTv/TunerHosts/QueueStream.cs new file mode 100644 index 000000000..7605641b2 --- /dev/null +++ b/Emby.Server.Implementations/LiveTv/TunerHosts/QueueStream.cs @@ -0,0 +1,93 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using MediaBrowser.Model.Logging; + +namespace Emby.Server.Implementations.LiveTv.TunerHosts +{ + public class QueueStream + { + private readonly Stream _outputStream; + private readonly ConcurrentQueue<byte[]> _queue = new ConcurrentQueue<byte[]>(); + private CancellationToken _cancellationToken; + public TaskCompletionSource<bool> TaskCompletion { get; private set; } + + public Action<QueueStream> OnFinished { get; set; } + private readonly ILogger _logger; + + public QueueStream(Stream outputStream, ILogger logger) + { + _outputStream = outputStream; + _logger = logger; + TaskCompletion = new TaskCompletionSource<bool>(); + } + + public void Queue(byte[] bytes) + { + _queue.Enqueue(bytes); + } + + public void Start(CancellationToken cancellationToken) + { + _cancellationToken = cancellationToken; + Task.Run(() => StartInternal()); + } + + private byte[] Dequeue() + { + byte[] bytes; + if (_queue.TryDequeue(out bytes)) + { + return bytes; + } + + return null; + } + + private async Task StartInternal() + { + var cancellationToken = _cancellationToken; + + try + { + while (!cancellationToken.IsCancellationRequested) + { + var bytes = Dequeue(); + if (bytes != null) + { + await _outputStream.WriteAsync(bytes, 0, bytes.Length, cancellationToken).ConfigureAwait(false); + } + else + { + await Task.Delay(50, cancellationToken).ConfigureAwait(false); + } + } + + TaskCompletion.TrySetResult(true); + _logger.Debug("QueueStream complete"); + } + catch (OperationCanceledException) + { + _logger.Debug("QueueStream cancelled"); + TaskCompletion.TrySetCanceled(); + } + catch (Exception ex) + { + _logger.ErrorException("Error in QueueStream", ex); + TaskCompletion.TrySetException(ex); + } + finally + { + if (OnFinished != null) + { + OnFinished(this); + } + } + } + } +} diff --git a/Emby.Server.Implementations/Session/HttpSessionController.cs b/Emby.Server.Implementations/Session/HttpSessionController.cs new file mode 100644 index 000000000..cea5d9b40 --- /dev/null +++ b/Emby.Server.Implementations/Session/HttpSessionController.cs @@ -0,0 +1,186 @@ +using MediaBrowser.Common.Net; +using MediaBrowser.Controller.Session; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.Serialization; +using MediaBrowser.Model.Session; +using MediaBrowser.Model.System; +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Net; +using System.Threading; +using System.Threading.Tasks; + +namespace Emby.Server.Implementations.Session +{ + public class HttpSessionController : ISessionController, IDisposable + { + private readonly IHttpClient _httpClient; + private readonly IJsonSerializer _json; + private readonly ISessionManager _sessionManager; + + public SessionInfo Session { get; private set; } + + private readonly string _postUrl; + + public HttpSessionController(IHttpClient httpClient, + IJsonSerializer json, + SessionInfo session, + string postUrl, ISessionManager sessionManager) + { + _httpClient = httpClient; + _json = json; + Session = session; + _postUrl = postUrl; + _sessionManager = sessionManager; + } + + public void OnActivity() + { + } + + private string PostUrl + { + get + { + return string.Format("http://{0}{1}", Session.RemoteEndPoint, _postUrl); + } + } + + public bool IsSessionActive + { + get + { + return (DateTime.UtcNow - Session.LastActivityDate).TotalMinutes <= 10; + } + } + + public bool SupportsMediaControl + { + get { return true; } + } + + private Task SendMessage(string name, CancellationToken cancellationToken) + { + return SendMessage(name, new Dictionary<string, string>(), cancellationToken); + } + + private async Task SendMessage(string name, + Dictionary<string, string> args, + CancellationToken cancellationToken) + { + var url = PostUrl + "/" + name + ToQueryString(args); + + await _httpClient.Post(new HttpRequestOptions + { + Url = url, + CancellationToken = cancellationToken, + BufferContent = false + + }).ConfigureAwait(false); + } + + public Task SendSessionEndedNotification(SessionInfoDto sessionInfo, CancellationToken cancellationToken) + { + return Task.FromResult(true); + } + + public Task SendPlaybackStartNotification(SessionInfoDto sessionInfo, CancellationToken cancellationToken) + { + return Task.FromResult(true); + } + + public Task SendPlaybackStoppedNotification(SessionInfoDto sessionInfo, CancellationToken cancellationToken) + { + return Task.FromResult(true); + } + + public Task SendPlayCommand(PlayRequest command, CancellationToken cancellationToken) + { + var dict = new Dictionary<string, string>(); + + dict["ItemIds"] = string.Join(",", command.ItemIds); + + if (command.StartPositionTicks.HasValue) + { + dict["StartPositionTicks"] = command.StartPositionTicks.Value.ToString(CultureInfo.InvariantCulture); + } + + return SendMessage(command.PlayCommand.ToString(), dict, cancellationToken); + } + + public Task SendPlaystateCommand(PlaystateRequest command, CancellationToken cancellationToken) + { + var args = new Dictionary<string, string>(); + + if (command.Command == PlaystateCommand.Seek) + { + if (!command.SeekPositionTicks.HasValue) + { + throw new ArgumentException("SeekPositionTicks cannot be null"); + } + + args["SeekPositionTicks"] = command.SeekPositionTicks.Value.ToString(CultureInfo.InvariantCulture); + } + + return SendMessage(command.Command.ToString(), args, cancellationToken); + } + + public Task SendLibraryUpdateInfo(LibraryUpdateInfo info, CancellationToken cancellationToken) + { + return Task.FromResult(true); + } + + public Task SendRestartRequiredNotification(SystemInfo info, CancellationToken cancellationToken) + { + return SendMessage("RestartRequired", cancellationToken); + } + + public Task SendUserDataChangeInfo(UserDataChangeInfo info, CancellationToken cancellationToken) + { + return Task.FromResult(true); + } + + public Task SendServerShutdownNotification(CancellationToken cancellationToken) + { + return SendMessage("ServerShuttingDown", cancellationToken); + } + + public Task SendServerRestartNotification(CancellationToken cancellationToken) + { + return SendMessage("ServerRestarting", cancellationToken); + } + + public Task SendGeneralCommand(GeneralCommand command, CancellationToken cancellationToken) + { + return SendMessage(command.Name, command.Arguments, cancellationToken); + } + + public Task SendMessage<T>(string name, T data, CancellationToken cancellationToken) + { + // Not supported or needed right now + return Task.FromResult(true); + } + + private string ToQueryString(Dictionary<string, string> nvc) + { + var array = (from item in nvc + select string.Format("{0}={1}", WebUtility.UrlEncode(item.Key), WebUtility.UrlEncode(item.Value))) + .ToArray(); + + var args = string.Join("&", array); + + if (string.IsNullOrEmpty(args)) + { + return args; + } + + return "?" + args; + } + + public void Dispose() + { + } + } +} diff --git a/Emby.Server.Implementations/Session/SessionManager.cs b/Emby.Server.Implementations/Session/SessionManager.cs new file mode 100644 index 000000000..960fe0739 --- /dev/null +++ b/Emby.Server.Implementations/Session/SessionManager.cs @@ -0,0 +1,1933 @@ +using MediaBrowser.Common.Events; +using MediaBrowser.Common.Extensions; +using MediaBrowser.Common.Net; +using MediaBrowser.Controller; +using MediaBrowser.Controller.Devices; +using MediaBrowser.Controller.Drawing; +using MediaBrowser.Controller.Dto; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.Audio; +using MediaBrowser.Controller.Entities.TV; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.LiveTv; +using MediaBrowser.Controller.Persistence; +using MediaBrowser.Controller.Security; +using MediaBrowser.Controller.Session; +using MediaBrowser.Model.Devices; +using MediaBrowser.Model.Dto; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.Events; +using MediaBrowser.Model.Library; +using MediaBrowser.Model.Logging; +using MediaBrowser.Model.Serialization; +using MediaBrowser.Model.Session; +using MediaBrowser.Model.Users; +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using MediaBrowser.Controller.Net; +using MediaBrowser.Model.Threading; + +namespace Emby.Server.Implementations.Session +{ + /// <summary> + /// Class SessionManager + /// </summary> + public class SessionManager : ISessionManager + { + /// <summary> + /// The _user data repository + /// </summary> + private readonly IUserDataManager _userDataManager; + + /// <summary> + /// The _logger + /// </summary> + private readonly ILogger _logger; + + private readonly ILibraryManager _libraryManager; + private readonly IUserManager _userManager; + private readonly IMusicManager _musicManager; + private readonly IDtoService _dtoService; + private readonly IImageProcessor _imageProcessor; + private readonly IMediaSourceManager _mediaSourceManager; + + private readonly IHttpClient _httpClient; + private readonly IJsonSerializer _jsonSerializer; + private readonly IServerApplicationHost _appHost; + + private readonly IAuthenticationRepository _authRepo; + private readonly IDeviceManager _deviceManager; + private readonly ITimerFactory _timerFactory; + + /// <summary> + /// The _active connections + /// </summary> + private readonly ConcurrentDictionary<string, SessionInfo> _activeConnections = + new ConcurrentDictionary<string, SessionInfo>(StringComparer.OrdinalIgnoreCase); + + public event EventHandler<GenericEventArgs<AuthenticationRequest>> AuthenticationFailed; + + public event EventHandler<GenericEventArgs<AuthenticationRequest>> AuthenticationSucceeded; + + /// <summary> + /// Occurs when [playback start]. + /// </summary> + public event EventHandler<PlaybackProgressEventArgs> PlaybackStart; + /// <summary> + /// Occurs when [playback progress]. + /// </summary> + public event EventHandler<PlaybackProgressEventArgs> PlaybackProgress; + /// <summary> + /// Occurs when [playback stopped]. + /// </summary> + public event EventHandler<PlaybackStopEventArgs> PlaybackStopped; + + public event EventHandler<SessionEventArgs> SessionStarted; + public event EventHandler<SessionEventArgs> CapabilitiesChanged; + public event EventHandler<SessionEventArgs> SessionEnded; + public event EventHandler<SessionEventArgs> SessionActivity; + + private IEnumerable<ISessionControllerFactory> _sessionFactories = new List<ISessionControllerFactory>(); + + private readonly SemaphoreSlim _sessionLock = new SemaphoreSlim(1, 1); + + public SessionManager(IUserDataManager userDataManager, ILogger logger, ILibraryManager libraryManager, IUserManager userManager, IMusicManager musicManager, IDtoService dtoService, IImageProcessor imageProcessor, IJsonSerializer jsonSerializer, IServerApplicationHost appHost, IHttpClient httpClient, IAuthenticationRepository authRepo, IDeviceManager deviceManager, IMediaSourceManager mediaSourceManager, ITimerFactory timerFactory) + { + _userDataManager = userDataManager; + _logger = logger; + _libraryManager = libraryManager; + _userManager = userManager; + _musicManager = musicManager; + _dtoService = dtoService; + _imageProcessor = imageProcessor; + _jsonSerializer = jsonSerializer; + _appHost = appHost; + _httpClient = httpClient; + _authRepo = authRepo; + _deviceManager = deviceManager; + _mediaSourceManager = mediaSourceManager; + _timerFactory = timerFactory; + + _deviceManager.DeviceOptionsUpdated += _deviceManager_DeviceOptionsUpdated; + } + + void _deviceManager_DeviceOptionsUpdated(object sender, GenericEventArgs<DeviceInfo> e) + { + foreach (var session in Sessions) + { + if (string.Equals(session.DeviceId, e.Argument.Id)) + { + session.DeviceName = e.Argument.Name; + } + } + } + + /// <summary> + /// Adds the parts. + /// </summary> + /// <param name="sessionFactories">The session factories.</param> + public void AddParts(IEnumerable<ISessionControllerFactory> sessionFactories) + { + _sessionFactories = sessionFactories.ToList(); + } + + /// <summary> + /// Gets all connections. + /// </summary> + /// <value>All connections.</value> + public IEnumerable<SessionInfo> Sessions + { + get { return _activeConnections.Values.OrderByDescending(c => c.LastActivityDate).ToList(); } + } + + private void OnSessionStarted(SessionInfo info) + { + EventHelper.QueueEventIfNotNull(SessionStarted, this, new SessionEventArgs + { + SessionInfo = info + + }, _logger); + + if (!string.IsNullOrWhiteSpace(info.DeviceId)) + { + var capabilities = GetSavedCapabilities(info.DeviceId); + + if (capabilities != null) + { + info.AppIconUrl = capabilities.IconUrl; + ReportCapabilities(info, capabilities, false); + } + } + } + + private async void OnSessionEnded(SessionInfo info) + { + try + { + await SendSessionEndedNotification(info, CancellationToken.None).ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.ErrorException("Error in SendSessionEndedNotification", ex); + } + + EventHelper.QueueEventIfNotNull(SessionEnded, this, new SessionEventArgs + { + SessionInfo = info + + }, _logger); + + var disposable = info.SessionController as IDisposable; + + if (disposable != null) + { + _logger.Debug("Disposing session controller {0}", disposable.GetType().Name); + + try + { + disposable.Dispose(); + } + catch (Exception ex) + { + _logger.ErrorException("Error disposing session controller", ex); + } + } + } + + /// <summary> + /// Logs the user activity. + /// </summary> + /// <param name="appName">Type of the client.</param> + /// <param name="appVersion">The app version.</param> + /// <param name="deviceId">The device id.</param> + /// <param name="deviceName">Name of the device.</param> + /// <param name="remoteEndPoint">The remote end point.</param> + /// <param name="user">The user.</param> + /// <returns>Task.</returns> + /// <exception cref="System.ArgumentNullException">user</exception> + /// <exception cref="System.UnauthorizedAccessException"></exception> + public async Task<SessionInfo> LogSessionActivity(string appName, + string appVersion, + string deviceId, + string deviceName, + string remoteEndPoint, + User user) + { + if (string.IsNullOrEmpty(appName)) + { + throw new ArgumentNullException("appName"); + } + if (string.IsNullOrEmpty(appVersion)) + { + throw new ArgumentNullException("appVersion"); + } + if (string.IsNullOrEmpty(deviceId)) + { + throw new ArgumentNullException("deviceId"); + } + if (string.IsNullOrEmpty(deviceName)) + { + throw new ArgumentNullException("deviceName"); + } + + var activityDate = DateTime.UtcNow; + var session = await GetSessionInfo(appName, appVersion, deviceId, deviceName, remoteEndPoint, user).ConfigureAwait(false); + var lastActivityDate = session.LastActivityDate; + session.LastActivityDate = activityDate; + + if (user != null) + { + var userLastActivityDate = user.LastActivityDate ?? DateTime.MinValue; + user.LastActivityDate = activityDate; + + if ((activityDate - userLastActivityDate).TotalSeconds > 60) + { + try + { + await _userManager.UpdateUser(user).ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.ErrorException("Error updating user", ex); + } + } + } + + if ((activityDate - lastActivityDate).TotalSeconds > 10) + { + EventHelper.FireEventIfNotNull(SessionActivity, this, new SessionEventArgs + { + SessionInfo = session + + }, _logger); + } + + var controller = session.SessionController; + if (controller != null) + { + controller.OnActivity(); + } + + return session; + } + + public async void ReportSessionEnded(string sessionId) + { + await _sessionLock.WaitAsync(CancellationToken.None).ConfigureAwait(false); + + try + { + var session = GetSession(sessionId, false); + + if (session != null) + { + var key = GetSessionKey(session.Client, session.DeviceId); + + SessionInfo removed; + _activeConnections.TryRemove(key, out removed); + + OnSessionEnded(session); + } + } + finally + { + _sessionLock.Release(); + } + } + + private Task<MediaSourceInfo> GetMediaSource(IHasMediaSources item, string mediaSourceId, string liveStreamId) + { + return _mediaSourceManager.GetMediaSource(item, mediaSourceId, liveStreamId, false, CancellationToken.None); + } + + /// <summary> + /// Updates the now playing item id. + /// </summary> + /// <param name="session">The session.</param> + /// <param name="info">The information.</param> + /// <param name="libraryItem">The library item.</param> + private async Task UpdateNowPlayingItem(SessionInfo session, PlaybackProgressInfo info, BaseItem libraryItem) + { + if (string.IsNullOrWhiteSpace(info.MediaSourceId)) + { + info.MediaSourceId = info.ItemId; + } + + if (!string.IsNullOrWhiteSpace(info.ItemId) && info.Item == null && libraryItem != null) + { + var current = session.NowPlayingItem; + + if (current == null || !string.Equals(current.Id, info.ItemId, StringComparison.OrdinalIgnoreCase)) + { + var runtimeTicks = libraryItem.RunTimeTicks; + + MediaSourceInfo mediaSource = null; + var hasMediaSources = libraryItem as IHasMediaSources; + if (hasMediaSources != null) + { + mediaSource = await GetMediaSource(hasMediaSources, info.MediaSourceId, info.LiveStreamId).ConfigureAwait(false); + + if (mediaSource != null) + { + runtimeTicks = mediaSource.RunTimeTicks; + } + } + + info.Item = GetItemInfo(libraryItem, libraryItem, mediaSource); + + info.Item.RunTimeTicks = runtimeTicks; + } + else + { + info.Item = current; + } + } + + session.NowPlayingItem = info.Item; + session.LastActivityDate = DateTime.UtcNow; + session.LastPlaybackCheckIn = DateTime.UtcNow; + + session.PlayState.IsPaused = info.IsPaused; + session.PlayState.PositionTicks = info.PositionTicks; + session.PlayState.MediaSourceId = info.MediaSourceId; + session.PlayState.CanSeek = info.CanSeek; + session.PlayState.IsMuted = info.IsMuted; + session.PlayState.VolumeLevel = info.VolumeLevel; + session.PlayState.AudioStreamIndex = info.AudioStreamIndex; + session.PlayState.SubtitleStreamIndex = info.SubtitleStreamIndex; + session.PlayState.PlayMethod = info.PlayMethod; + session.PlayState.RepeatMode = info.RepeatMode; + } + + /// <summary> + /// Removes the now playing item id. + /// </summary> + /// <param name="session">The session.</param> + /// <exception cref="System.ArgumentNullException">item</exception> + private void RemoveNowPlayingItem(SessionInfo session) + { + session.NowPlayingItem = null; + session.PlayState = new PlayerStateInfo(); + + if (!string.IsNullOrEmpty(session.DeviceId)) + { + ClearTranscodingInfo(session.DeviceId); + } + } + + private string GetSessionKey(string appName, string deviceId) + { + return appName + deviceId; + } + + /// <summary> + /// Gets the connection. + /// </summary> + /// <param name="appName">Type of the client.</param> + /// <param name="appVersion">The app version.</param> + /// <param name="deviceId">The device id.</param> + /// <param name="deviceName">Name of the device.</param> + /// <param name="remoteEndPoint">The remote end point.</param> + /// <param name="user">The user.</param> + /// <returns>SessionInfo.</returns> + private async Task<SessionInfo> GetSessionInfo(string appName, string appVersion, string deviceId, string deviceName, string remoteEndPoint, User user) + { + if (string.IsNullOrWhiteSpace(deviceId)) + { + throw new ArgumentNullException("deviceId"); + } + var key = GetSessionKey(appName, deviceId); + + await _sessionLock.WaitAsync(CancellationToken.None).ConfigureAwait(false); + + var userId = user == null ? (Guid?)null : user.Id; + var username = user == null ? null : user.Name; + + try + { + SessionInfo sessionInfo; + DeviceInfo device = null; + + if (!_activeConnections.TryGetValue(key, out sessionInfo)) + { + sessionInfo = new SessionInfo + { + Client = appName, + DeviceId = deviceId, + ApplicationVersion = appVersion, + Id = key.GetMD5().ToString("N") + }; + + sessionInfo.DeviceName = deviceName; + sessionInfo.UserId = userId; + sessionInfo.UserName = username; + sessionInfo.RemoteEndPoint = remoteEndPoint; + + OnSessionStarted(sessionInfo); + + _activeConnections.TryAdd(key, sessionInfo); + + if (!string.IsNullOrEmpty(deviceId)) + { + var userIdString = userId.HasValue ? userId.Value.ToString("N") : null; + device = await _deviceManager.RegisterDevice(deviceId, deviceName, appName, appVersion, userIdString).ConfigureAwait(false); + } + } + + device = device ?? _deviceManager.GetDevice(deviceId); + + if (device == null) + { + var userIdString = userId.HasValue ? userId.Value.ToString("N") : null; + device = await _deviceManager.RegisterDevice(deviceId, deviceName, appName, appVersion, userIdString).ConfigureAwait(false); + } + + if (device != null) + { + if (!string.IsNullOrEmpty(device.CustomName)) + { + deviceName = device.CustomName; + } + } + + sessionInfo.DeviceName = deviceName; + sessionInfo.UserId = userId; + sessionInfo.UserName = username; + sessionInfo.RemoteEndPoint = remoteEndPoint; + sessionInfo.ApplicationVersion = appVersion; + + if (!userId.HasValue) + { + sessionInfo.AdditionalUsers.Clear(); + } + + if (sessionInfo.SessionController == null) + { + sessionInfo.SessionController = _sessionFactories + .Select(i => i.GetSessionController(sessionInfo)) + .FirstOrDefault(i => i != null); + } + + return sessionInfo; + } + finally + { + _sessionLock.Release(); + } + } + + private List<User> GetUsers(SessionInfo session) + { + var users = new List<User>(); + + if (session.UserId.HasValue) + { + var user = _userManager.GetUserById(session.UserId.Value); + + if (user == null) + { + throw new InvalidOperationException("User not found"); + } + + users.Add(user); + + var additionalUsers = session.AdditionalUsers + .Select(i => _userManager.GetUserById(i.UserId)) + .Where(i => i != null); + + users.AddRange(additionalUsers); + } + + return users; + } + + private ITimer _idleTimer; + + private void StartIdleCheckTimer() + { + if (_idleTimer == null) + { + _idleTimer = _timerFactory.Create(CheckForIdlePlayback, null, TimeSpan.FromMinutes(5), TimeSpan.FromMinutes(5)); + } + } + private void StopIdleCheckTimer() + { + if (_idleTimer != null) + { + _idleTimer.Dispose(); + _idleTimer = null; + } + } + + private async void CheckForIdlePlayback(object state) + { + var playingSessions = Sessions.Where(i => i.NowPlayingItem != null) + .ToList(); + + if (playingSessions.Count > 0) + { + var idle = playingSessions + .Where(i => (DateTime.UtcNow - i.LastPlaybackCheckIn).TotalMinutes > 5) + .ToList(); + + foreach (var session in idle) + { + _logger.Debug("Session {0} has gone idle while playing", session.Id); + + try + { + await OnPlaybackStopped(new PlaybackStopInfo + { + Item = session.NowPlayingItem, + ItemId = session.NowPlayingItem == null ? null : session.NowPlayingItem.Id, + SessionId = session.Id, + MediaSourceId = session.PlayState == null ? null : session.PlayState.MediaSourceId, + PositionTicks = session.PlayState == null ? null : session.PlayState.PositionTicks + }); + } + catch (Exception ex) + { + _logger.Debug("Error calling OnPlaybackStopped", ex); + } + } + + playingSessions = Sessions.Where(i => i.NowPlayingItem != null) + .ToList(); + } + + if (playingSessions.Count == 0) + { + StopIdleCheckTimer(); + } + } + + /// <summary> + /// Used to report that playback has started for an item + /// </summary> + /// <param name="info">The info.</param> + /// <returns>Task.</returns> + /// <exception cref="System.ArgumentNullException">info</exception> + public async Task OnPlaybackStart(PlaybackStartInfo info) + { + if (info == null) + { + throw new ArgumentNullException("info"); + } + + var session = GetSession(info.SessionId); + + var libraryItem = string.IsNullOrWhiteSpace(info.ItemId) + ? null + : _libraryManager.GetItemById(new Guid(info.ItemId)); + + await UpdateNowPlayingItem(session, info, libraryItem).ConfigureAwait(false); + + if (!string.IsNullOrEmpty(session.DeviceId) && info.PlayMethod != PlayMethod.Transcode) + { + ClearTranscodingInfo(session.DeviceId); + } + + session.QueueableMediaTypes = info.QueueableMediaTypes; + + var users = GetUsers(session); + + if (libraryItem != null) + { + foreach (var user in users) + { + await OnPlaybackStart(user.Id, libraryItem).ConfigureAwait(false); + } + } + + // Nothing to save here + // Fire events to inform plugins + EventHelper.QueueEventIfNotNull(PlaybackStart, this, new PlaybackProgressEventArgs + { + Item = libraryItem, + Users = users, + MediaSourceId = info.MediaSourceId, + MediaInfo = info.Item, + DeviceName = session.DeviceName, + ClientName = session.Client, + DeviceId = session.DeviceId + + }, _logger); + + await SendPlaybackStartNotification(session, CancellationToken.None).ConfigureAwait(false); + + StartIdleCheckTimer(); + } + + /// <summary> + /// Called when [playback start]. + /// </summary> + /// <param name="userId">The user identifier.</param> + /// <param name="item">The item.</param> + /// <returns>Task.</returns> + private async Task OnPlaybackStart(Guid userId, IHasUserData item) + { + var data = _userDataManager.GetUserData(userId, item); + + data.PlayCount++; + data.LastPlayedDate = DateTime.UtcNow; + + if (item.SupportsPlayedStatus) + { + if (!(item is Video)) + { + data.Played = true; + } + } + else + { + data.Played = false; + } + + await _userDataManager.SaveUserData(userId, item, data, UserDataSaveReason.PlaybackStart, CancellationToken.None).ConfigureAwait(false); + } + + /// <summary> + /// Used to report playback progress for an item + /// </summary> + /// <param name="info">The info.</param> + /// <returns>Task.</returns> + /// <exception cref="System.ArgumentNullException"></exception> + /// <exception cref="System.ArgumentOutOfRangeException">positionTicks</exception> + public async Task OnPlaybackProgress(PlaybackProgressInfo info) + { + if (info == null) + { + throw new ArgumentNullException("info"); + } + + var session = GetSession(info.SessionId); + + var libraryItem = string.IsNullOrWhiteSpace(info.ItemId) + ? null + : _libraryManager.GetItemById(new Guid(info.ItemId)); + + await UpdateNowPlayingItem(session, info, libraryItem).ConfigureAwait(false); + + var users = GetUsers(session); + + if (libraryItem != null) + { + foreach (var user in users) + { + await OnPlaybackProgress(user, libraryItem, info).ConfigureAwait(false); + } + } + + if (!string.IsNullOrWhiteSpace(info.LiveStreamId)) + { + try + { + await _mediaSourceManager.PingLiveStream(info.LiveStreamId, CancellationToken.None).ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.ErrorException("Error closing live stream", ex); + } + } + + EventHelper.FireEventIfNotNull(PlaybackProgress, this, new PlaybackProgressEventArgs + { + Item = libraryItem, + Users = users, + PlaybackPositionTicks = session.PlayState.PositionTicks, + MediaSourceId = session.PlayState.MediaSourceId, + MediaInfo = info.Item, + DeviceName = session.DeviceName, + ClientName = session.Client, + DeviceId = session.DeviceId, + IsPaused = info.IsPaused, + PlaySessionId = info.PlaySessionId + + }, _logger); + + StartIdleCheckTimer(); + } + + private async Task OnPlaybackProgress(User user, BaseItem item, PlaybackProgressInfo info) + { + var data = _userDataManager.GetUserData(user.Id, item); + + var positionTicks = info.PositionTicks; + + if (positionTicks.HasValue) + { + _userDataManager.UpdatePlayState(item, data, positionTicks.Value); + + UpdatePlaybackSettings(user, info, data); + + await _userDataManager.SaveUserData(user.Id, item, data, UserDataSaveReason.PlaybackProgress, CancellationToken.None).ConfigureAwait(false); + } + } + + private void UpdatePlaybackSettings(User user, PlaybackProgressInfo info, UserItemData data) + { + if (user.Configuration.RememberAudioSelections) + { + data.AudioStreamIndex = info.AudioStreamIndex; + } + else + { + data.AudioStreamIndex = null; + } + + if (user.Configuration.RememberSubtitleSelections) + { + data.SubtitleStreamIndex = info.SubtitleStreamIndex; + } + else + { + data.SubtitleStreamIndex = null; + } + } + + /// <summary> + /// Used to report that playback has ended for an item + /// </summary> + /// <param name="info">The info.</param> + /// <returns>Task.</returns> + /// <exception cref="System.ArgumentNullException">info</exception> + /// <exception cref="System.ArgumentOutOfRangeException">positionTicks</exception> + public async Task OnPlaybackStopped(PlaybackStopInfo info) + { + if (info == null) + { + throw new ArgumentNullException("info"); + } + + if (info.PositionTicks.HasValue && info.PositionTicks.Value < 0) + { + throw new ArgumentOutOfRangeException("positionTicks"); + } + + var session = GetSession(info.SessionId); + + var libraryItem = string.IsNullOrWhiteSpace(info.ItemId) + ? null + : _libraryManager.GetItemById(new Guid(info.ItemId)); + + // Normalize + if (string.IsNullOrWhiteSpace(info.MediaSourceId)) + { + info.MediaSourceId = info.ItemId; + } + + if (!string.IsNullOrWhiteSpace(info.ItemId) && info.Item == null && libraryItem != null) + { + var current = session.NowPlayingItem; + + if (current == null || !string.Equals(current.Id, info.ItemId, StringComparison.OrdinalIgnoreCase)) + { + MediaSourceInfo mediaSource = null; + + var hasMediaSources = libraryItem as IHasMediaSources; + if (hasMediaSources != null) + { + mediaSource = await GetMediaSource(hasMediaSources, info.MediaSourceId, info.LiveStreamId).ConfigureAwait(false); + } + + info.Item = GetItemInfo(libraryItem, libraryItem, mediaSource); + } + else + { + info.Item = current; + } + } + + RemoveNowPlayingItem(session); + + var users = GetUsers(session); + var playedToCompletion = false; + + if (libraryItem != null) + { + foreach (var user in users) + { + playedToCompletion = await OnPlaybackStopped(user.Id, libraryItem, info.PositionTicks, info.Failed).ConfigureAwait(false); + } + } + + if (!string.IsNullOrWhiteSpace(info.LiveStreamId)) + { + try + { + await _mediaSourceManager.CloseLiveStream(info.LiveStreamId).ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.ErrorException("Error closing live stream", ex); + } + } + + EventHelper.QueueEventIfNotNull(PlaybackStopped, this, new PlaybackStopEventArgs + { + Item = libraryItem, + Users = users, + PlaybackPositionTicks = info.PositionTicks, + PlayedToCompletion = playedToCompletion, + MediaSourceId = info.MediaSourceId, + MediaInfo = info.Item, + DeviceName = session.DeviceName, + ClientName = session.Client, + DeviceId = session.DeviceId + + }, _logger); + + await SendPlaybackStoppedNotification(session, CancellationToken.None).ConfigureAwait(false); + } + + private async Task<bool> OnPlaybackStopped(Guid userId, BaseItem item, long? positionTicks, bool playbackFailed) + { + bool playedToCompletion = false; + + if (!playbackFailed) + { + var data = _userDataManager.GetUserData(userId, item); + + if (positionTicks.HasValue) + { + playedToCompletion = _userDataManager.UpdatePlayState(item, data, positionTicks.Value); + } + else + { + // If the client isn't able to report this, then we'll just have to make an assumption + data.PlayCount++; + data.Played = item.SupportsPlayedStatus; + data.PlaybackPositionTicks = 0; + playedToCompletion = true; + } + + await _userDataManager.SaveUserData(userId, item, data, UserDataSaveReason.PlaybackFinished, CancellationToken.None).ConfigureAwait(false); + } + + return playedToCompletion; + } + + /// <summary> + /// Gets the session. + /// </summary> + /// <param name="sessionId">The session identifier.</param> + /// <param name="throwOnMissing">if set to <c>true</c> [throw on missing].</param> + /// <returns>SessionInfo.</returns> + /// <exception cref="ResourceNotFoundException"></exception> + private SessionInfo GetSession(string sessionId, bool throwOnMissing = true) + { + var session = Sessions.FirstOrDefault(i => string.Equals(i.Id, sessionId)); + + if (session == null && throwOnMissing) + { + throw new ResourceNotFoundException(string.Format("Session {0} not found.", sessionId)); + } + + return session; + } + + private SessionInfo GetSessionToRemoteControl(string sessionId) + { + // Accept either device id or session id + var session = Sessions.FirstOrDefault(i => string.Equals(i.Id, sessionId)); + + if (session == null) + { + throw new ResourceNotFoundException(string.Format("Session {0} not found.", sessionId)); + } + + return session; + } + + public Task SendMessageCommand(string controllingSessionId, string sessionId, MessageCommand command, CancellationToken cancellationToken) + { + var generalCommand = new GeneralCommand + { + Name = GeneralCommandType.DisplayMessage.ToString() + }; + + generalCommand.Arguments["Header"] = command.Header; + generalCommand.Arguments["Text"] = command.Text; + + if (command.TimeoutMs.HasValue) + { + generalCommand.Arguments["TimeoutMs"] = command.TimeoutMs.Value.ToString(CultureInfo.InvariantCulture); + } + + return SendGeneralCommand(controllingSessionId, sessionId, generalCommand, cancellationToken); + } + + public Task SendGeneralCommand(string controllingSessionId, string sessionId, GeneralCommand command, CancellationToken cancellationToken) + { + var session = GetSessionToRemoteControl(sessionId); + + var controllingSession = GetSession(controllingSessionId); + AssertCanControl(session, controllingSession); + + return session.SessionController.SendGeneralCommand(command, cancellationToken); + } + + public async Task SendPlayCommand(string controllingSessionId, string sessionId, PlayRequest command, CancellationToken cancellationToken) + { + var session = GetSessionToRemoteControl(sessionId); + + var user = session.UserId.HasValue ? _userManager.GetUserById(session.UserId.Value) : null; + + List<BaseItem> items; + + if (command.PlayCommand == PlayCommand.PlayInstantMix) + { + items = command.ItemIds.SelectMany(i => TranslateItemForInstantMix(i, user)) + .Where(i => i.LocationType != LocationType.Virtual) + .ToList(); + + command.PlayCommand = PlayCommand.PlayNow; + } + else + { + var list = new List<BaseItem>(); + foreach (var itemId in command.ItemIds) + { + var subItems = await TranslateItemForPlayback(itemId, user).ConfigureAwait(false); + list.AddRange(subItems); + } + + items = list + .Where(i => i.LocationType != LocationType.Virtual) + .ToList(); + } + + if (command.PlayCommand == PlayCommand.PlayShuffle) + { + items = items.OrderBy(i => Guid.NewGuid()).ToList(); + command.PlayCommand = PlayCommand.PlayNow; + } + + command.ItemIds = items.Select(i => i.Id.ToString("N")).ToArray(); + + if (user != null) + { + if (items.Any(i => i.GetPlayAccess(user) != PlayAccess.Full)) + { + throw new ArgumentException(string.Format("{0} is not allowed to play media.", user.Name)); + } + } + + if (command.PlayCommand != PlayCommand.PlayNow) + { + if (items.Any(i => !session.QueueableMediaTypes.Contains(i.MediaType, StringComparer.OrdinalIgnoreCase))) + { + throw new ArgumentException(string.Format("{0} is unable to queue the requested media type.", session.DeviceName ?? session.Id)); + } + } + else + { + if (items.Any(i => !session.PlayableMediaTypes.Contains(i.MediaType, StringComparer.OrdinalIgnoreCase))) + { + throw new ArgumentException(string.Format("{0} is unable to play the requested media type.", session.DeviceName ?? session.Id)); + } + } + + if (user != null && command.ItemIds.Length == 1 && user.Configuration.EnableNextEpisodeAutoPlay) + { + var episode = _libraryManager.GetItemById(command.ItemIds[0]) as Episode; + if (episode != null) + { + var series = episode.Series; + if (series != null) + { + var episodes = series.GetEpisodes(user) + .Where(i => !i.IsVirtualItem) + .SkipWhile(i => i.Id != episode.Id) + .ToList(); + + if (episodes.Count > 0) + { + command.ItemIds = episodes.Select(i => i.Id.ToString("N")).ToArray(); + } + } + } + } + + var controllingSession = GetSession(controllingSessionId); + AssertCanControl(session, controllingSession); + if (controllingSession.UserId.HasValue) + { + command.ControllingUserId = controllingSession.UserId.Value.ToString("N"); + } + + await session.SessionController.SendPlayCommand(command, cancellationToken).ConfigureAwait(false); + } + + private async Task<List<BaseItem>> TranslateItemForPlayback(string id, User user) + { + var item = _libraryManager.GetItemById(id); + + if (item == null) + { + _logger.Error("A non-existant item Id {0} was passed into TranslateItemForPlayback", id); + return new List<BaseItem>(); + } + + var byName = item as IItemByName; + + if (byName != null) + { + var items = byName.GetTaggedItems(new InternalItemsQuery(user) + { + IsFolder = false, + Recursive = true + }); + + return FilterToSingleMediaType(items) + .OrderBy(i => i.SortName) + .ToList(); + } + + if (item.IsFolder) + { + var folder = (Folder)item; + + var itemsResult = await folder.GetItems(new InternalItemsQuery(user) + { + Recursive = true, + IsFolder = false + + }).ConfigureAwait(false); + + return FilterToSingleMediaType(itemsResult.Items) + .OrderBy(i => i.SortName) + .ToList(); + } + + return new List<BaseItem> { item }; + } + + private IEnumerable<BaseItem> FilterToSingleMediaType(IEnumerable<BaseItem> items) + { + return items + .Where(i => !string.IsNullOrWhiteSpace(i.MediaType)) + .ToLookup(i => i.MediaType, StringComparer.OrdinalIgnoreCase) + .OrderByDescending(i => i.Count()) + .FirstOrDefault(); + } + + private IEnumerable<BaseItem> TranslateItemForInstantMix(string id, User user) + { + var item = _libraryManager.GetItemById(id); + + if (item == null) + { + _logger.Error("A non-existant item Id {0} was passed into TranslateItemForInstantMix", id); + return new List<BaseItem>(); + } + + return _musicManager.GetInstantMixFromItem(item, user); + } + + public Task SendBrowseCommand(string controllingSessionId, string sessionId, BrowseRequest command, CancellationToken cancellationToken) + { + var generalCommand = new GeneralCommand + { + Name = GeneralCommandType.DisplayContent.ToString() + }; + + generalCommand.Arguments["ItemId"] = command.ItemId; + generalCommand.Arguments["ItemName"] = command.ItemName; + generalCommand.Arguments["ItemType"] = command.ItemType; + + return SendGeneralCommand(controllingSessionId, sessionId, generalCommand, cancellationToken); + } + + public Task SendPlaystateCommand(string controllingSessionId, string sessionId, PlaystateRequest command, CancellationToken cancellationToken) + { + var session = GetSessionToRemoteControl(sessionId); + + var controllingSession = GetSession(controllingSessionId); + AssertCanControl(session, controllingSession); + if (controllingSession.UserId.HasValue) + { + command.ControllingUserId = controllingSession.UserId.Value.ToString("N"); + } + + return session.SessionController.SendPlaystateCommand(command, cancellationToken); + } + + private void AssertCanControl(SessionInfo session, SessionInfo controllingSession) + { + if (session == null) + { + throw new ArgumentNullException("session"); + } + if (controllingSession == null) + { + throw new ArgumentNullException("controllingSession"); + } + } + + /// <summary> + /// Sends the restart required message. + /// </summary> + /// <param name="cancellationToken">The cancellation token.</param> + /// <returns>Task.</returns> + public async Task SendRestartRequiredNotification(CancellationToken cancellationToken) + { + var sessions = Sessions.Where(i => i.IsActive && i.SessionController != null).ToList(); + + var info = await _appHost.GetSystemInfo().ConfigureAwait(false); + + var tasks = sessions.Select(session => Task.Run(async () => + { + try + { + await session.SessionController.SendRestartRequiredNotification(info, cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.ErrorException("Error in SendRestartRequiredNotification.", ex); + } + + }, cancellationToken)); + + await Task.WhenAll(tasks).ConfigureAwait(false); + } + + /// <summary> + /// Sends the server shutdown notification. + /// </summary> + /// <param name="cancellationToken">The cancellation token.</param> + /// <returns>Task.</returns> + public Task SendServerShutdownNotification(CancellationToken cancellationToken) + { + var sessions = Sessions.Where(i => i.IsActive && i.SessionController != null).ToList(); + + var tasks = sessions.Select(session => Task.Run(async () => + { + try + { + await session.SessionController.SendServerShutdownNotification(cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.ErrorException("Error in SendServerShutdownNotification.", ex); + } + + }, cancellationToken)); + + return Task.WhenAll(tasks); + } + + /// <summary> + /// Sends the server restart notification. + /// </summary> + /// <param name="cancellationToken">The cancellation token.</param> + /// <returns>Task.</returns> + public Task SendServerRestartNotification(CancellationToken cancellationToken) + { + _logger.Debug("Beginning SendServerRestartNotification"); + + var sessions = Sessions.Where(i => i.IsActive && i.SessionController != null).ToList(); + + var tasks = sessions.Select(session => Task.Run(async () => + { + try + { + await session.SessionController.SendServerRestartNotification(cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.ErrorException("Error in SendServerRestartNotification.", ex); + } + + }, cancellationToken)); + + return Task.WhenAll(tasks); + } + + public Task SendSessionEndedNotification(SessionInfo sessionInfo, CancellationToken cancellationToken) + { + var sessions = Sessions.Where(i => i.IsActive && i.SessionController != null).ToList(); + var dto = GetSessionInfoDto(sessionInfo); + + var tasks = sessions.Select(session => Task.Run(async () => + { + try + { + await session.SessionController.SendSessionEndedNotification(dto, cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.ErrorException("Error in SendSessionEndedNotification.", ex); + } + + }, cancellationToken)); + + return Task.WhenAll(tasks); + } + + public Task SendPlaybackStartNotification(SessionInfo sessionInfo, CancellationToken cancellationToken) + { + var sessions = Sessions.Where(i => i.IsActive && i.SessionController != null).ToList(); + var dto = GetSessionInfoDto(sessionInfo); + + var tasks = sessions.Select(session => Task.Run(async () => + { + try + { + await session.SessionController.SendPlaybackStartNotification(dto, cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.ErrorException("Error in SendPlaybackStartNotification.", ex); + } + + }, cancellationToken)); + + return Task.WhenAll(tasks); + } + + public Task SendPlaybackStoppedNotification(SessionInfo sessionInfo, CancellationToken cancellationToken) + { + var sessions = Sessions.Where(i => i.IsActive && i.SessionController != null).ToList(); + var dto = GetSessionInfoDto(sessionInfo); + + var tasks = sessions.Select(session => Task.Run(async () => + { + try + { + await session.SessionController.SendPlaybackStoppedNotification(dto, cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.ErrorException("Error in SendPlaybackStoppedNotification.", ex); + } + + }, cancellationToken)); + + return Task.WhenAll(tasks); + } + + /// <summary> + /// Adds the additional user. + /// </summary> + /// <param name="sessionId">The session identifier.</param> + /// <param name="userId">The user identifier.</param> + /// <exception cref="System.UnauthorizedAccessException">Cannot modify additional users without authenticating first.</exception> + /// <exception cref="System.ArgumentException">The requested user is already the primary user of the session.</exception> + public void AddAdditionalUser(string sessionId, string userId) + { + var session = GetSession(sessionId); + + if (session.UserId.HasValue && session.UserId.Value == new Guid(userId)) + { + throw new ArgumentException("The requested user is already the primary user of the session."); + } + + if (session.AdditionalUsers.All(i => new Guid(i.UserId) != new Guid(userId))) + { + var user = _userManager.GetUserById(userId); + + session.AdditionalUsers.Add(new SessionUserInfo + { + UserId = userId, + UserName = user.Name + }); + } + } + + /// <summary> + /// Removes the additional user. + /// </summary> + /// <param name="sessionId">The session identifier.</param> + /// <param name="userId">The user identifier.</param> + /// <exception cref="System.UnauthorizedAccessException">Cannot modify additional users without authenticating first.</exception> + /// <exception cref="System.ArgumentException">The requested user is already the primary user of the session.</exception> + public void RemoveAdditionalUser(string sessionId, string userId) + { + var session = GetSession(sessionId); + + if (session.UserId.HasValue && session.UserId.Value == new Guid(userId)) + { + throw new ArgumentException("The requested user is already the primary user of the session."); + } + + var user = session.AdditionalUsers.FirstOrDefault(i => new Guid(i.UserId) == new Guid(userId)); + + if (user != null) + { + session.AdditionalUsers.Remove(user); + } + } + + /// <summary> + /// Authenticates the new session. + /// </summary> + /// <param name="request">The request.</param> + /// <returns>Task{SessionInfo}.</returns> + public Task<AuthenticationResult> AuthenticateNewSession(AuthenticationRequest request) + { + return AuthenticateNewSessionInternal(request, true); + } + + public Task<AuthenticationResult> CreateNewSession(AuthenticationRequest request) + { + return AuthenticateNewSessionInternal(request, false); + } + + private async Task<AuthenticationResult> AuthenticateNewSessionInternal(AuthenticationRequest request, bool enforcePassword) + { + User user = null; + if (!string.IsNullOrWhiteSpace(request.UserId)) + { + var idGuid = new Guid(request.UserId); + user = _userManager.Users + .FirstOrDefault(i => i.Id == idGuid); + } + + if (user == null) + { + user = _userManager.Users + .FirstOrDefault(i => string.Equals(request.Username, i.Name, StringComparison.OrdinalIgnoreCase)); + } + + if (user != null && !string.IsNullOrWhiteSpace(request.DeviceId)) + { + if (!_deviceManager.CanAccessDevice(user.Id.ToString("N"), request.DeviceId)) + { + throw new SecurityException("User is not allowed access from this device."); + } + } + + if (enforcePassword) + { + var result = await _userManager.AuthenticateUser(request.Username, request.PasswordSha1, request.PasswordMd5, request.RemoteEndPoint).ConfigureAwait(false); + + if (!result) + { + EventHelper.FireEventIfNotNull(AuthenticationFailed, this, new GenericEventArgs<AuthenticationRequest>(request), _logger); + + throw new SecurityException("Invalid user or password entered."); + } + } + + var token = await GetAuthorizationToken(user.Id.ToString("N"), request.DeviceId, request.App, request.AppVersion, request.DeviceName).ConfigureAwait(false); + + EventHelper.FireEventIfNotNull(AuthenticationSucceeded, this, new GenericEventArgs<AuthenticationRequest>(request), _logger); + + var session = await LogSessionActivity(request.App, + request.AppVersion, + request.DeviceId, + request.DeviceName, + request.RemoteEndPoint, + user) + .ConfigureAwait(false); + + return new AuthenticationResult + { + User = _userManager.GetUserDto(user, request.RemoteEndPoint), + SessionInfo = GetSessionInfoDto(session), + AccessToken = token, + ServerId = _appHost.SystemId + }; + } + + + private async Task<string> GetAuthorizationToken(string userId, string deviceId, string app, string appVersion, string deviceName) + { + var existing = _authRepo.Get(new AuthenticationInfoQuery + { + DeviceId = deviceId, + IsActive = true, + UserId = userId, + Limit = 1 + }); + + if (existing.Items.Length > 0) + { + var token = existing.Items[0].AccessToken; + _logger.Info("Reissuing access token: " + token); + return token; + } + + var newToken = new AuthenticationInfo + { + AppName = app, + AppVersion = appVersion, + DateCreated = DateTime.UtcNow, + DeviceId = deviceId, + DeviceName = deviceName, + UserId = userId, + IsActive = true, + AccessToken = Guid.NewGuid().ToString("N") + }; + + _logger.Info("Creating new access token for user {0}", userId); + await _authRepo.Create(newToken, CancellationToken.None).ConfigureAwait(false); + + return newToken.AccessToken; + } + + public async Task Logout(string accessToken) + { + if (string.IsNullOrWhiteSpace(accessToken)) + { + throw new ArgumentNullException("accessToken"); + } + + _logger.Info("Logging out access token {0}", accessToken); + + var existing = _authRepo.Get(new AuthenticationInfoQuery + { + Limit = 1, + AccessToken = accessToken + + }).Items.FirstOrDefault(); + + if (existing != null) + { + existing.IsActive = false; + + await _authRepo.Update(existing, CancellationToken.None).ConfigureAwait(false); + + var sessions = Sessions + .Where(i => string.Equals(i.DeviceId, existing.DeviceId, StringComparison.OrdinalIgnoreCase)) + .ToList(); + + foreach (var session in sessions) + { + try + { + ReportSessionEnded(session.Id); + } + catch (Exception ex) + { + _logger.ErrorException("Error reporting session ended", ex); + } + } + } + } + + public async Task RevokeUserTokens(string userId, string currentAccessToken) + { + var existing = _authRepo.Get(new AuthenticationInfoQuery + { + IsActive = true, + UserId = userId + }); + + foreach (var info in existing.Items) + { + if (!string.Equals(currentAccessToken, info.AccessToken, StringComparison.OrdinalIgnoreCase)) + { + await Logout(info.AccessToken).ConfigureAwait(false); + } + } + } + + public Task RevokeToken(string token) + { + return Logout(token); + } + + /// <summary> + /// Reports the capabilities. + /// </summary> + /// <param name="sessionId">The session identifier.</param> + /// <param name="capabilities">The capabilities.</param> + public void ReportCapabilities(string sessionId, ClientCapabilities capabilities) + { + var session = GetSession(sessionId); + + ReportCapabilities(session, capabilities, true); + } + + private async void ReportCapabilities(SessionInfo session, + ClientCapabilities capabilities, + bool saveCapabilities) + { + session.Capabilities = capabilities; + + if (!string.IsNullOrWhiteSpace(capabilities.MessageCallbackUrl)) + { + var controller = session.SessionController as HttpSessionController; + + if (controller == null) + { + session.SessionController = new HttpSessionController(_httpClient, _jsonSerializer, session, capabilities.MessageCallbackUrl, this); + } + } + + EventHelper.FireEventIfNotNull(CapabilitiesChanged, this, new SessionEventArgs + { + SessionInfo = session + + }, _logger); + + if (saveCapabilities) + { + try + { + await SaveCapabilities(session.DeviceId, capabilities).ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.ErrorException("Error saving device capabilities", ex); + } + } + } + + private ClientCapabilities GetSavedCapabilities(string deviceId) + { + return _deviceManager.GetCapabilities(deviceId); + } + + private Task SaveCapabilities(string deviceId, ClientCapabilities capabilities) + { + return _deviceManager.SaveCapabilities(deviceId, capabilities); + } + + public SessionInfoDto GetSessionInfoDto(SessionInfo session) + { + var dto = new SessionInfoDto + { + Client = session.Client, + DeviceId = session.DeviceId, + DeviceName = session.DeviceName, + Id = session.Id, + LastActivityDate = session.LastActivityDate, + NowViewingItem = session.NowViewingItem, + ApplicationVersion = session.ApplicationVersion, + QueueableMediaTypes = session.QueueableMediaTypes, + PlayableMediaTypes = session.PlayableMediaTypes, + AdditionalUsers = session.AdditionalUsers, + SupportedCommands = session.SupportedCommands, + UserName = session.UserName, + NowPlayingItem = session.NowPlayingItem, + SupportsRemoteControl = session.SupportsMediaControl, + PlayState = session.PlayState, + AppIconUrl = session.AppIconUrl, + TranscodingInfo = session.NowPlayingItem == null ? null : session.TranscodingInfo + }; + + if (session.UserId.HasValue) + { + dto.UserId = session.UserId.Value.ToString("N"); + + var user = _userManager.GetUserById(session.UserId.Value); + + if (user != null) + { + dto.UserPrimaryImageTag = GetImageCacheTag(user, ImageType.Primary); + } + } + + return dto; + } + + /// <summary> + /// Converts a BaseItem to a BaseItemInfo + /// </summary> + /// <param name="item">The item.</param> + /// <param name="chapterOwner">The chapter owner.</param> + /// <param name="mediaSource">The media source.</param> + /// <returns>BaseItemInfo.</returns> + /// <exception cref="System.ArgumentNullException">item</exception> + private BaseItemInfo GetItemInfo(BaseItem item, BaseItem chapterOwner, MediaSourceInfo mediaSource) + { + if (item == null) + { + throw new ArgumentNullException("item"); + } + + var info = new BaseItemInfo + { + Id = GetDtoId(item), + Name = item.Name, + MediaType = item.MediaType, + Type = item.GetClientTypeName(), + RunTimeTicks = item.RunTimeTicks, + IndexNumber = item.IndexNumber, + ParentIndexNumber = item.ParentIndexNumber, + PremiereDate = item.PremiereDate, + ProductionYear = item.ProductionYear, + IsThemeMedia = item.IsThemeMedia + }; + + info.PrimaryImageTag = GetImageCacheTag(item, ImageType.Primary); + if (info.PrimaryImageTag != null) + { + info.PrimaryImageItemId = GetDtoId(item); + } + + var episode = item as Episode; + if (episode != null) + { + info.IndexNumberEnd = episode.IndexNumberEnd; + } + + var hasSeries = item as IHasSeries; + if (hasSeries != null) + { + info.SeriesName = hasSeries.SeriesName; + } + + var recording = item as ILiveTvRecording; + if (recording != null) + { + if (recording.IsSeries) + { + info.Name = recording.EpisodeTitle; + info.SeriesName = recording.Name; + + if (string.IsNullOrWhiteSpace(info.Name)) + { + info.Name = recording.Name; + } + } + } + + var audio = item as Audio; + if (audio != null) + { + info.Album = audio.Album; + info.Artists = audio.Artists; + + if (info.PrimaryImageTag == null) + { + var album = audio.AlbumEntity; + + if (album != null && album.HasImage(ImageType.Primary)) + { + info.PrimaryImageTag = GetImageCacheTag(album, ImageType.Primary); + if (info.PrimaryImageTag != null) + { + info.PrimaryImageItemId = GetDtoId(album); + } + } + } + } + + var musicVideo = item as MusicVideo; + if (musicVideo != null) + { + info.Album = musicVideo.Album; + info.Artists = musicVideo.Artists.ToList(); + } + + var backropItem = item.HasImage(ImageType.Backdrop) ? item : null; + var thumbItem = item.HasImage(ImageType.Thumb) ? item : null; + var logoItem = item.HasImage(ImageType.Logo) ? item : null; + + if (thumbItem == null) + { + if (episode != null) + { + var series = episode.Series; + + if (series != null && series.HasImage(ImageType.Thumb)) + { + thumbItem = series; + } + } + } + + if (backropItem == null) + { + if (episode != null) + { + var series = episode.Series; + + if (series != null && series.HasImage(ImageType.Backdrop)) + { + backropItem = series; + } + } + } + + if (backropItem == null) + { + backropItem = item.GetParents().FirstOrDefault(i => i.HasImage(ImageType.Backdrop)); + } + + if (thumbItem == null) + { + thumbItem = item.GetParents().FirstOrDefault(i => i.HasImage(ImageType.Thumb)); + } + + if (logoItem == null) + { + logoItem = item.GetParents().FirstOrDefault(i => i.HasImage(ImageType.Logo)); + } + + if (thumbItem != null) + { + info.ThumbImageTag = GetImageCacheTag(thumbItem, ImageType.Thumb); + info.ThumbItemId = GetDtoId(thumbItem); + } + + if (backropItem != null) + { + info.BackdropImageTag = GetImageCacheTag(backropItem, ImageType.Backdrop); + info.BackdropItemId = GetDtoId(backropItem); + } + + if (logoItem != null) + { + info.LogoImageTag = GetImageCacheTag(logoItem, ImageType.Logo); + info.LogoItemId = GetDtoId(logoItem); + } + + if (chapterOwner != null) + { + info.ChapterImagesItemId = chapterOwner.Id.ToString("N"); + + info.Chapters = _dtoService.GetChapterInfoDtos(chapterOwner).ToList(); + } + + if (mediaSource != null) + { + info.MediaStreams = mediaSource.MediaStreams; + } + + return info; + } + + private string GetImageCacheTag(BaseItem item, ImageType type) + { + try + { + return _imageProcessor.GetImageCacheTag(item, type); + } + catch (Exception ex) + { + _logger.ErrorException("Error getting {0} image info", ex, type); + return null; + } + } + + private string GetDtoId(BaseItem item) + { + return _dtoService.GetDtoId(item); + } + + public void ReportNowViewingItem(string sessionId, string itemId) + { + if (string.IsNullOrWhiteSpace(itemId)) + { + throw new ArgumentNullException("itemId"); + } + + var item = _libraryManager.GetItemById(new Guid(itemId)); + + var info = GetItemInfo(item, null, null); + + ReportNowViewingItem(sessionId, info); + } + + public void ReportNowViewingItem(string sessionId, BaseItemInfo item) + { + var session = GetSession(sessionId); + + session.NowViewingItem = item; + } + + public void ReportTranscodingInfo(string deviceId, TranscodingInfo info) + { + var session = Sessions.FirstOrDefault(i => string.Equals(i.DeviceId, deviceId)); + + if (session != null) + { + session.TranscodingInfo = info; + } + } + + public void ClearTranscodingInfo(string deviceId) + { + ReportTranscodingInfo(deviceId, null); + } + + public SessionInfo GetSession(string deviceId, string client, string version) + { + return Sessions.FirstOrDefault(i => string.Equals(i.DeviceId, deviceId) && + string.Equals(i.Client, client)); + } + + public Task<SessionInfo> GetSessionByAuthenticationToken(AuthenticationInfo info, string deviceId, string remoteEndpoint, string appVersion) + { + if (info == null) + { + throw new ArgumentNullException("info"); + } + + var user = string.IsNullOrWhiteSpace(info.UserId) + ? null + : _userManager.GetUserById(info.UserId); + + appVersion = string.IsNullOrWhiteSpace(appVersion) + ? info.AppVersion + : appVersion; + + var deviceName = info.DeviceName; + var appName = info.AppName; + + if (!string.IsNullOrWhiteSpace(deviceId)) + { + // Replace the info from the token with more recent info + var device = _deviceManager.GetDevice(deviceId); + if (device != null) + { + deviceName = device.Name; + appName = device.AppName; + + if (!string.IsNullOrWhiteSpace(device.AppVersion)) + { + appVersion = device.AppVersion; + } + } + } + else + { + deviceId = info.DeviceId; + } + + // Prevent argument exception + if (string.IsNullOrWhiteSpace(appVersion)) + { + appVersion = "1"; + } + + return LogSessionActivity(appName, appVersion, deviceId, deviceName, remoteEndpoint, user); + } + + public Task<SessionInfo> GetSessionByAuthenticationToken(string token, string deviceId, string remoteEndpoint) + { + var result = _authRepo.Get(new AuthenticationInfoQuery + { + AccessToken = token + }); + + var info = result.Items.FirstOrDefault(); + + if (info == null) + { + return Task.FromResult<SessionInfo>(null); + } + + return GetSessionByAuthenticationToken(info, deviceId, remoteEndpoint, null); + } + + public Task SendMessageToAdminSessions<T>(string name, T data, CancellationToken cancellationToken) + { + var adminUserIds = _userManager.Users.Where(i => i.Policy.IsAdministrator).Select(i => i.Id.ToString("N")).ToList(); + + return SendMessageToUserSessions(adminUserIds, name, data, cancellationToken); + } + + public Task SendMessageToUserSessions<T>(List<string> userIds, string name, T data, + CancellationToken cancellationToken) + { + var sessions = Sessions.Where(i => i.IsActive && i.SessionController != null && userIds.Any(i.ContainsUser)).ToList(); + + var tasks = sessions.Select(session => Task.Run(async () => + { + try + { + await session.SessionController.SendMessage(name, data, cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.ErrorException("Error sending message", ex); + } + + }, cancellationToken)); + + return Task.WhenAll(tasks); + } + + public Task SendMessageToUserDeviceSessions<T>(string deviceId, string name, T data, + CancellationToken cancellationToken) + { + var sessions = Sessions.Where(i => i.IsActive && i.SessionController != null && string.Equals(i.DeviceId, deviceId, StringComparison.OrdinalIgnoreCase)).ToList(); + + var tasks = sessions.Select(session => Task.Run(async () => + { + try + { + await session.SessionController.SendMessage(name, data, cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.ErrorException("Error sending message", ex); + } + + }, cancellationToken)); + + return Task.WhenAll(tasks); + } + } +}
\ No newline at end of file diff --git a/Emby.Server.Implementations/Session/SessionWebSocketListener.cs b/Emby.Server.Implementations/Session/SessionWebSocketListener.cs new file mode 100644 index 000000000..336c2caee --- /dev/null +++ b/Emby.Server.Implementations/Session/SessionWebSocketListener.cs @@ -0,0 +1,485 @@ +using MediaBrowser.Controller.Net; +using MediaBrowser.Controller.Session; +using MediaBrowser.Model.Events; +using MediaBrowser.Model.Logging; +using MediaBrowser.Model.Serialization; +using MediaBrowser.Model.Session; +using System; +using System.Collections.Specialized; +using System.Globalization; +using System.Linq; +using System.Threading.Tasks; +using MediaBrowser.Model.Services; + +namespace Emby.Server.Implementations.Session +{ + /// <summary> + /// Class SessionWebSocketListener + /// </summary> + public class SessionWebSocketListener : IWebSocketListener, IDisposable + { + /// <summary> + /// The _true task result + /// </summary> + private readonly Task _trueTaskResult = Task.FromResult(true); + + /// <summary> + /// The _session manager + /// </summary> + private readonly ISessionManager _sessionManager; + + /// <summary> + /// The _logger + /// </summary> + private readonly ILogger _logger; + + /// <summary> + /// The _dto service + /// </summary> + private readonly IJsonSerializer _json; + + private readonly IHttpServer _httpServer; + private readonly IServerManager _serverManager; + + + /// <summary> + /// Initializes a new instance of the <see cref="SessionWebSocketListener" /> class. + /// </summary> + /// <param name="sessionManager">The session manager.</param> + /// <param name="logManager">The log manager.</param> + /// <param name="json">The json.</param> + /// <param name="httpServer">The HTTP server.</param> + /// <param name="serverManager">The server manager.</param> + public SessionWebSocketListener(ISessionManager sessionManager, ILogManager logManager, IJsonSerializer json, IHttpServer httpServer, IServerManager serverManager) + { + _sessionManager = sessionManager; + _logger = logManager.GetLogger(GetType().Name); + _json = json; + _httpServer = httpServer; + _serverManager = serverManager; + httpServer.WebSocketConnecting += _httpServer_WebSocketConnecting; + serverManager.WebSocketConnected += _serverManager_WebSocketConnected; + } + + async void _serverManager_WebSocketConnected(object sender, GenericEventArgs<IWebSocketConnection> e) + { + var session = await GetSession(e.Argument.QueryString, e.Argument.RemoteEndPoint).ConfigureAwait(false); + + if (session != null) + { + var controller = session.SessionController as WebSocketController; + + if (controller == null) + { + controller = new WebSocketController(session, _logger, _sessionManager); + } + + controller.AddWebSocket(e.Argument); + + session.SessionController = controller; + } + else + { + _logger.Warn("Unable to determine session based on url: {0}", e.Argument.Url); + } + } + + async void _httpServer_WebSocketConnecting(object sender, WebSocketConnectingEventArgs e) + { + //var token = e.QueryString["api_key"]; + //if (!string.IsNullOrWhiteSpace(token)) + //{ + // try + // { + // var session = await GetSession(e.QueryString, e.Endpoint).ConfigureAwait(false); + + // if (session == null) + // { + // e.AllowConnection = false; + // } + // } + // catch (Exception ex) + // { + // _logger.ErrorException("Error getting session info", ex); + // } + //} + } + + private Task<SessionInfo> GetSession(QueryParamCollection queryString, string remoteEndpoint) + { + if (queryString == null) + { + throw new ArgumentNullException("queryString"); + } + + var token = queryString["api_key"]; + if (string.IsNullOrWhiteSpace(token)) + { + return Task.FromResult<SessionInfo>(null); + } + var deviceId = queryString["deviceId"]; + return _sessionManager.GetSessionByAuthenticationToken(token, deviceId, remoteEndpoint); + } + + public void Dispose() + { + _httpServer.WebSocketConnecting -= _httpServer_WebSocketConnecting; + _serverManager.WebSocketConnected -= _serverManager_WebSocketConnected; + } + + /// <summary> + /// Processes the message. + /// </summary> + /// <param name="message">The message.</param> + /// <returns>Task.</returns> + public Task ProcessMessage(WebSocketMessageInfo message) + { + if (string.Equals(message.MessageType, "Identity", StringComparison.OrdinalIgnoreCase)) + { + ProcessIdentityMessage(message); + } + else if (string.Equals(message.MessageType, "Context", StringComparison.OrdinalIgnoreCase)) + { + ProcessContextMessage(message); + } + else if (string.Equals(message.MessageType, "PlaybackStart", StringComparison.OrdinalIgnoreCase)) + { + OnPlaybackStart(message); + } + else if (string.Equals(message.MessageType, "PlaybackProgress", StringComparison.OrdinalIgnoreCase)) + { + OnPlaybackProgress(message); + } + else if (string.Equals(message.MessageType, "PlaybackStopped", StringComparison.OrdinalIgnoreCase)) + { + OnPlaybackStopped(message); + } + else if (string.Equals(message.MessageType, "ReportPlaybackStart", StringComparison.OrdinalIgnoreCase)) + { + ReportPlaybackStart(message); + } + else if (string.Equals(message.MessageType, "ReportPlaybackProgress", StringComparison.OrdinalIgnoreCase)) + { + ReportPlaybackProgress(message); + } + else if (string.Equals(message.MessageType, "ReportPlaybackStopped", StringComparison.OrdinalIgnoreCase)) + { + ReportPlaybackStopped(message); + } + + return _trueTaskResult; + } + + /// <summary> + /// Processes the identity message. + /// </summary> + /// <param name="message">The message.</param> + private async void ProcessIdentityMessage(WebSocketMessageInfo message) + { + _logger.Debug("Received Identity message: " + message.Data); + + var vals = message.Data.Split('|'); + + if (vals.Length < 3) + { + _logger.Error("Client sent invalid identity message."); + return; + } + + var client = vals[0]; + var deviceId = vals[1]; + var version = vals[2]; + var deviceName = vals.Length > 3 ? vals[3] : string.Empty; + + var session = _sessionManager.GetSession(deviceId, client, version); + + if (session == null && !string.IsNullOrEmpty(deviceName)) + { + _logger.Debug("Logging session activity"); + + session = await _sessionManager.LogSessionActivity(client, version, deviceId, deviceName, message.Connection.RemoteEndPoint, null).ConfigureAwait(false); + } + + if (session != null) + { + var controller = session.SessionController as WebSocketController; + + if (controller == null) + { + controller = new WebSocketController(session, _logger, _sessionManager); + } + + controller.AddWebSocket(message.Connection); + + session.SessionController = controller; + } + else + { + _logger.Warn("Unable to determine session based on identity message: {0}", message.Data); + } + } + + /// <summary> + /// Processes the context message. + /// </summary> + /// <param name="message">The message.</param> + private void ProcessContextMessage(WebSocketMessageInfo message) + { + var session = GetSessionFromMessage(message); + + if (session != null) + { + var vals = message.Data.Split('|'); + + var itemId = vals[1]; + + if (!string.IsNullOrWhiteSpace(itemId)) + { + _sessionManager.ReportNowViewingItem(session.Id, itemId); + } + } + } + + /// <summary> + /// Gets the session from message. + /// </summary> + /// <param name="message">The message.</param> + /// <returns>SessionInfo.</returns> + private SessionInfo GetSessionFromMessage(WebSocketMessageInfo message) + { + var result = _sessionManager.Sessions.FirstOrDefault(i => + { + var controller = i.SessionController as WebSocketController; + + if (controller != null) + { + if (controller.Sockets.Any(s => s.Id == message.Connection.Id)) + { + return true; + } + } + + return false; + + }); + + if (result == null) + { + _logger.Error("Unable to find session based on web socket message"); + } + + return result; + } + + private readonly CultureInfo _usCulture = new CultureInfo("en-US"); + + /// <summary> + /// Reports the playback start. + /// </summary> + /// <param name="message">The message.</param> + private void OnPlaybackStart(WebSocketMessageInfo message) + { + _logger.Debug("Received PlaybackStart message"); + + var session = GetSessionFromMessage(message); + + if (session != null && session.UserId.HasValue) + { + var vals = message.Data.Split('|'); + + var itemId = vals[0]; + + var queueableMediaTypes = string.Empty; + var canSeek = true; + + if (vals.Length > 1) + { + canSeek = string.Equals(vals[1], "true", StringComparison.OrdinalIgnoreCase); + } + if (vals.Length > 2) + { + queueableMediaTypes = vals[2]; + } + + var info = new PlaybackStartInfo + { + CanSeek = canSeek, + ItemId = itemId, + SessionId = session.Id, + QueueableMediaTypes = queueableMediaTypes.Split(',').ToList() + }; + + if (vals.Length > 3) + { + info.MediaSourceId = vals[3]; + } + + if (vals.Length > 4 && !string.IsNullOrWhiteSpace(vals[4])) + { + info.AudioStreamIndex = int.Parse(vals[4], _usCulture); + } + + if (vals.Length > 5 && !string.IsNullOrWhiteSpace(vals[5])) + { + info.SubtitleStreamIndex = int.Parse(vals[5], _usCulture); + } + + _sessionManager.OnPlaybackStart(info); + } + } + + private void ReportPlaybackStart(WebSocketMessageInfo message) + { + _logger.Debug("Received ReportPlaybackStart message"); + + var session = GetSessionFromMessage(message); + + if (session != null && session.UserId.HasValue) + { + var info = _json.DeserializeFromString<PlaybackStartInfo>(message.Data); + + info.SessionId = session.Id; + + _sessionManager.OnPlaybackStart(info); + } + } + + private void ReportPlaybackProgress(WebSocketMessageInfo message) + { + //_logger.Debug("Received ReportPlaybackProgress message"); + + var session = GetSessionFromMessage(message); + + if (session != null && session.UserId.HasValue) + { + var info = _json.DeserializeFromString<PlaybackProgressInfo>(message.Data); + + info.SessionId = session.Id; + + _sessionManager.OnPlaybackProgress(info); + } + } + + /// <summary> + /// Reports the playback progress. + /// </summary> + /// <param name="message">The message.</param> + private void OnPlaybackProgress(WebSocketMessageInfo message) + { + var session = GetSessionFromMessage(message); + + if (session != null && session.UserId.HasValue) + { + var vals = message.Data.Split('|'); + + var itemId = vals[0]; + + long? positionTicks = null; + + if (vals.Length > 1) + { + long pos; + + if (long.TryParse(vals[1], out pos)) + { + positionTicks = pos; + } + } + + var isPaused = vals.Length > 2 && string.Equals(vals[2], "true", StringComparison.OrdinalIgnoreCase); + var isMuted = vals.Length > 3 && string.Equals(vals[3], "true", StringComparison.OrdinalIgnoreCase); + + var info = new PlaybackProgressInfo + { + ItemId = itemId, + PositionTicks = positionTicks, + IsMuted = isMuted, + IsPaused = isPaused, + SessionId = session.Id + }; + + if (vals.Length > 4) + { + info.MediaSourceId = vals[4]; + } + + if (vals.Length > 5 && !string.IsNullOrWhiteSpace(vals[5])) + { + info.VolumeLevel = int.Parse(vals[5], _usCulture); + } + + if (vals.Length > 5 && !string.IsNullOrWhiteSpace(vals[6])) + { + info.AudioStreamIndex = int.Parse(vals[6], _usCulture); + } + + if (vals.Length > 7 && !string.IsNullOrWhiteSpace(vals[7])) + { + info.SubtitleStreamIndex = int.Parse(vals[7], _usCulture); + } + + _sessionManager.OnPlaybackProgress(info); + } + } + + private void ReportPlaybackStopped(WebSocketMessageInfo message) + { + _logger.Debug("Received ReportPlaybackStopped message"); + + var session = GetSessionFromMessage(message); + + if (session != null && session.UserId.HasValue) + { + var info = _json.DeserializeFromString<PlaybackStopInfo>(message.Data); + + info.SessionId = session.Id; + + _sessionManager.OnPlaybackStopped(info); + } + } + + /// <summary> + /// Reports the playback stopped. + /// </summary> + /// <param name="message">The message.</param> + private void OnPlaybackStopped(WebSocketMessageInfo message) + { + _logger.Debug("Received PlaybackStopped message"); + + var session = GetSessionFromMessage(message); + + if (session != null && session.UserId.HasValue) + { + var vals = message.Data.Split('|'); + + var itemId = vals[0]; + + long? positionTicks = null; + + if (vals.Length > 1) + { + long pos; + + if (long.TryParse(vals[1], out pos)) + { + positionTicks = pos; + } + } + + var info = new PlaybackStopInfo + { + ItemId = itemId, + PositionTicks = positionTicks, + SessionId = session.Id + }; + + if (vals.Length > 2) + { + info.MediaSourceId = vals[2]; + } + + _sessionManager.OnPlaybackStopped(info); + } + } + } +} diff --git a/Emby.Server.Implementations/Session/WebSocketController.cs b/Emby.Server.Implementations/Session/WebSocketController.cs new file mode 100644 index 000000000..f0ff0b5dd --- /dev/null +++ b/Emby.Server.Implementations/Session/WebSocketController.cs @@ -0,0 +1,288 @@ +using MediaBrowser.Controller.Net; +using MediaBrowser.Controller.Session; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.Logging; +using MediaBrowser.Model.Net; +using MediaBrowser.Model.Session; +using MediaBrowser.Model.System; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +namespace Emby.Server.Implementations.Session +{ + public class WebSocketController : ISessionController, IDisposable + { + public SessionInfo Session { get; private set; } + public IReadOnlyList<IWebSocketConnection> Sockets { get; private set; } + + private readonly ILogger _logger; + + private readonly ISessionManager _sessionManager; + + public WebSocketController(SessionInfo session, ILogger logger, ISessionManager sessionManager) + { + Session = session; + _logger = logger; + _sessionManager = sessionManager; + Sockets = new List<IWebSocketConnection>(); + } + + private bool HasOpenSockets + { + get { return GetActiveSockets().Any(); } + } + + public bool SupportsMediaControl + { + get { return HasOpenSockets; } + } + + private bool _isActive; + private DateTime _lastActivityDate; + public bool IsSessionActive + { + get + { + if (HasOpenSockets) + { + return true; + } + + //return false; + return _isActive && (DateTime.UtcNow - _lastActivityDate).TotalMinutes <= 10; + } + } + + public void OnActivity() + { + _isActive = true; + _lastActivityDate = DateTime.UtcNow; + } + + private IEnumerable<IWebSocketConnection> GetActiveSockets() + { + return Sockets + .OrderByDescending(i => i.LastActivityDate) + .Where(i => i.State == WebSocketState.Open); + } + + public void AddWebSocket(IWebSocketConnection connection) + { + var sockets = Sockets.ToList(); + sockets.Add(connection); + + Sockets = sockets; + + connection.Closed += connection_Closed; + } + + void connection_Closed(object sender, EventArgs e) + { + if (!GetActiveSockets().Any()) + { + _isActive = false; + + try + { + _sessionManager.ReportSessionEnded(Session.Id); + } + catch (Exception ex) + { + _logger.ErrorException("Error reporting session ended.", ex); + } + } + } + + private IWebSocketConnection GetActiveSocket() + { + var socket = GetActiveSockets() + .FirstOrDefault(); + + if (socket == null) + { + throw new InvalidOperationException("The requested session does not have an open web socket."); + } + + return socket; + } + + public Task SendPlayCommand(PlayRequest command, CancellationToken cancellationToken) + { + return SendMessageInternal(new WebSocketMessage<PlayRequest> + { + MessageType = "Play", + Data = command + + }, cancellationToken); + } + + public Task SendPlaystateCommand(PlaystateRequest command, CancellationToken cancellationToken) + { + return SendMessageInternal(new WebSocketMessage<PlaystateRequest> + { + MessageType = "Playstate", + Data = command + + }, cancellationToken); + } + + public Task SendLibraryUpdateInfo(LibraryUpdateInfo info, CancellationToken cancellationToken) + { + return SendMessagesInternal(new WebSocketMessage<LibraryUpdateInfo> + { + MessageType = "LibraryChanged", + Data = info + + }, cancellationToken); + } + + /// <summary> + /// Sends the restart required message. + /// </summary> + /// <param name="info">The information.</param> + /// <param name="cancellationToken">The cancellation token.</param> + /// <returns>Task.</returns> + public Task SendRestartRequiredNotification(SystemInfo info, CancellationToken cancellationToken) + { + return SendMessagesInternal(new WebSocketMessage<SystemInfo> + { + MessageType = "RestartRequired", + Data = info + + }, cancellationToken); + } + + + /// <summary> + /// Sends the user data change info. + /// </summary> + /// <param name="info">The info.</param> + /// <param name="cancellationToken">The cancellation token.</param> + /// <returns>Task.</returns> + public Task SendUserDataChangeInfo(UserDataChangeInfo info, CancellationToken cancellationToken) + { + return SendMessagesInternal(new WebSocketMessage<UserDataChangeInfo> + { + MessageType = "UserDataChanged", + Data = info + + }, cancellationToken); + } + + /// <summary> + /// Sends the server shutdown notification. + /// </summary> + /// <param name="cancellationToken">The cancellation token.</param> + /// <returns>Task.</returns> + public Task SendServerShutdownNotification(CancellationToken cancellationToken) + { + return SendMessagesInternal(new WebSocketMessage<string> + { + MessageType = "ServerShuttingDown", + Data = string.Empty + + }, cancellationToken); + } + + /// <summary> + /// Sends the server restart notification. + /// </summary> + /// <param name="cancellationToken">The cancellation token.</param> + /// <returns>Task.</returns> + public Task SendServerRestartNotification(CancellationToken cancellationToken) + { + return SendMessagesInternal(new WebSocketMessage<string> + { + MessageType = "ServerRestarting", + Data = string.Empty + + }, cancellationToken); + } + + public Task SendGeneralCommand(GeneralCommand command, CancellationToken cancellationToken) + { + return SendMessageInternal(new WebSocketMessage<GeneralCommand> + { + MessageType = "GeneralCommand", + Data = command + + }, cancellationToken); + } + + public Task SendSessionEndedNotification(SessionInfoDto sessionInfo, CancellationToken cancellationToken) + { + return SendMessagesInternal(new WebSocketMessage<SessionInfoDto> + { + MessageType = "SessionEnded", + Data = sessionInfo + + }, cancellationToken); + } + + public Task SendPlaybackStartNotification(SessionInfoDto sessionInfo, CancellationToken cancellationToken) + { + return SendMessagesInternal(new WebSocketMessage<SessionInfoDto> + { + MessageType = "PlaybackStart", + Data = sessionInfo + + }, cancellationToken); + } + + public Task SendPlaybackStoppedNotification(SessionInfoDto sessionInfo, CancellationToken cancellationToken) + { + return SendMessagesInternal(new WebSocketMessage<SessionInfoDto> + { + MessageType = "PlaybackStopped", + Data = sessionInfo + + }, cancellationToken); + } + + public Task SendMessage<T>(string name, T data, CancellationToken cancellationToken) + { + return SendMessagesInternal(new WebSocketMessage<T> + { + Data = data, + MessageType = name + + }, cancellationToken); + } + + private Task SendMessageInternal<T>(WebSocketMessage<T> message, CancellationToken cancellationToken) + { + var socket = GetActiveSocket(); + + return socket.SendAsync(message, cancellationToken); + } + + private Task SendMessagesInternal<T>(WebSocketMessage<T> message, CancellationToken cancellationToken) + { + var tasks = GetActiveSockets().Select(i => Task.Run(async () => + { + try + { + await i.SendAsync(message, cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.ErrorException("Error sending web socket message", ex); + } + + }, cancellationToken)); + + return Task.WhenAll(tasks); + } + + public void Dispose() + { + foreach (var socket in Sockets.ToList()) + { + socket.Closed -= connection_Closed; + } + } + } +} diff --git a/Emby.Server.Implementations/project.json b/Emby.Server.Implementations/project.json index 9d80f8ce4..0f4463ed2 100644 --- a/Emby.Server.Implementations/project.json +++ b/Emby.Server.Implementations/project.json @@ -1,6 +1,7 @@ { "supports": {}, "dependencies": { + "Emby.XmlTv": "1.0.0.63", "MediaBrowser.Naming": "1.0.0.59", "UniversalDetector": "1.0.1" }, |
