Merge pull request #10838 from barronpm/livetv-project

Move Live TV code to Jellyfin.LiveTv
This commit is contained in:
Cody Robibero
2024-01-12 09:27:47 -07:00
committed by GitHub
101 changed files with 218 additions and 134 deletions

View File

@@ -15,7 +15,6 @@ using System.Security.Cryptography.X509Certificates;
using System.Threading.Tasks;
using Emby.Naming.Common;
using Emby.Photos;
using Emby.Server.Implementations.Channels;
using Emby.Server.Implementations.Collections;
using Emby.Server.Implementations.Configuration;
using Emby.Server.Implementations.Cryptography;
@@ -25,7 +24,6 @@ using Emby.Server.Implementations.Dto;
using Emby.Server.Implementations.HttpServer.Security;
using Emby.Server.Implementations.IO;
using Emby.Server.Implementations.Library;
using Emby.Server.Implementations.LiveTv;
using Emby.Server.Implementations.Localization;
using Emby.Server.Implementations.Playlists;
using Emby.Server.Implementations.Plugins;
@@ -504,8 +502,6 @@ namespace Emby.Server.Implementations
serviceCollection.AddSingleton(_xmlSerializer);
serviceCollection.AddSingleton<IStreamHelper, StreamHelper>();
serviceCollection.AddSingleton<ICryptoProvider, CryptographyProvider>();
serviceCollection.AddSingleton<ISocketFactory, SocketFactory>();
@@ -557,8 +553,6 @@ namespace Emby.Server.Implementations
serviceCollection.AddTransient(provider => new Lazy<ILiveTvManager>(provider.GetRequiredService<ILiveTvManager>));
serviceCollection.AddSingleton<IDtoService, DtoService>();
serviceCollection.AddSingleton<IChannelManager, ChannelManager>();
serviceCollection.AddSingleton<ISessionManager, SessionManager>();
serviceCollection.AddSingleton<ICollectionManager, CollectionManager>();
@@ -567,9 +561,6 @@ namespace Emby.Server.Implementations
serviceCollection.AddSingleton<ISyncPlayManager, SyncPlayManager>();
serviceCollection.AddSingleton<LiveTvDtoService>();
serviceCollection.AddSingleton<ILiveTvManager, LiveTvManager>();
serviceCollection.AddSingleton<IUserViewManager, UserViewManager>();
serviceCollection.AddSingleton<IChapterManager, ChapterManager>();

View File

@@ -1,43 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using MediaBrowser.Controller.Channels;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Library;
using MediaBrowser.Model.Dto;
namespace Emby.Server.Implementations.Channels
{
/// <summary>
/// A media source provider for channels.
/// </summary>
public class ChannelDynamicMediaSourceProvider : IMediaSourceProvider
{
private readonly ChannelManager _channelManager;
/// <summary>
/// Initializes a new instance of the <see cref="ChannelDynamicMediaSourceProvider"/> class.
/// </summary>
/// <param name="channelManager">The channel manager.</param>
public ChannelDynamicMediaSourceProvider(IChannelManager channelManager)
{
_channelManager = (ChannelManager)channelManager;
}
/// <inheritdoc />
public Task<IEnumerable<MediaSourceInfo>> GetMediaSources(BaseItem item, CancellationToken cancellationToken)
{
return item.SourceType == SourceType.Channel
? _channelManager.GetDynamicMediaSources(item, cancellationToken)
: Task.FromResult(Enumerable.Empty<MediaSourceInfo>());
}
/// <inheritdoc />
public Task<ILiveStream> OpenMediaSource(string openToken, List<ILiveStream> currentLiveStreams, CancellationToken cancellationToken)
{
throw new NotImplementedException();
}
}
}

View File

@@ -1,64 +0,0 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using MediaBrowser.Controller.Channels;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.Entities;
namespace Emby.Server.Implementations.Channels
{
/// <summary>
/// An image provider for channels.
/// </summary>
public class ChannelImageProvider : IDynamicImageProvider, IHasItemChangeMonitor
{
private readonly IChannelManager _channelManager;
/// <summary>
/// Initializes a new instance of the <see cref="ChannelImageProvider"/> class.
/// </summary>
/// <param name="channelManager">The channel manager.</param>
public ChannelImageProvider(IChannelManager channelManager)
{
_channelManager = channelManager;
}
/// <inheritdoc />
public string Name => "Channel Image Provider";
/// <inheritdoc />
public IEnumerable<ImageType> GetSupportedImages(BaseItem item)
{
return GetChannel(item).GetSupportedChannelImages();
}
/// <inheritdoc />
public Task<DynamicImageResponse> GetImage(BaseItem item, ImageType type, CancellationToken cancellationToken)
{
var channel = GetChannel(item);
return channel.GetChannelImage(type, cancellationToken);
}
/// <inheritdoc />
public bool Supports(BaseItem item)
{
return item is Channel;
}
private IChannel GetChannel(BaseItem item)
{
var channel = (Channel)item;
return ((ChannelManager)_channelManager).GetChannelProvider(channel);
}
/// <inheritdoc />
public bool HasChanged(BaseItem item, IDirectoryService directoryService)
{
return GetSupportedImages(item).Any(i => !item.HasImage(i));
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,100 +0,0 @@
using System;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Data.Enums;
using MediaBrowser.Controller.Channels;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Library;
using Microsoft.Extensions.Logging;
namespace Emby.Server.Implementations.Channels
{
/// <summary>
/// A task to remove all non-installed channels from the database.
/// </summary>
public class ChannelPostScanTask
{
private readonly IChannelManager _channelManager;
private readonly ILogger _logger;
private readonly ILibraryManager _libraryManager;
/// <summary>
/// Initializes a new instance of the <see cref="ChannelPostScanTask"/> class.
/// </summary>
/// <param name="channelManager">The channel manager.</param>
/// <param name="logger">The logger.</param>
/// <param name="libraryManager">The library manager.</param>
public ChannelPostScanTask(IChannelManager channelManager, ILogger logger, ILibraryManager libraryManager)
{
_channelManager = channelManager;
_logger = logger;
_libraryManager = libraryManager;
}
/// <summary>
/// Runs this task.
/// </summary>
/// <param name="progress">The progress.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>The completed task.</returns>
public Task Run(IProgress<double> progress, CancellationToken cancellationToken)
{
CleanDatabase(cancellationToken);
progress.Report(100);
return Task.CompletedTask;
}
private void CleanDatabase(CancellationToken cancellationToken)
{
var installedChannelIds = ((ChannelManager)_channelManager).GetInstalledChannelIds();
var uninstalledChannels = _libraryManager.GetItemList(new InternalItemsQuery
{
IncludeItemTypes = new[] { BaseItemKind.Channel },
ExcludeItemIds = installedChannelIds.ToArray()
});
foreach (var channel in uninstalledChannels)
{
cancellationToken.ThrowIfCancellationRequested();
CleanChannel((Channel)channel, cancellationToken);
}
}
private void CleanChannel(Channel channel, CancellationToken cancellationToken)
{
_logger.LogInformation("Cleaning channel {0} from database", channel.Id);
// Delete all channel items
var items = _libraryManager.GetItemList(new InternalItemsQuery
{
ChannelIds = new[] { channel.Id }
});
foreach (var item in items)
{
cancellationToken.ThrowIfCancellationRequested();
_libraryManager.DeleteItem(
item,
new DeleteOptions
{
DeleteFileLocation = false
},
false);
}
// Finally, delete the channel itself
_libraryManager.DeleteItem(
channel,
new DeleteOptions
{
DeleteFileLocation = false
},
false);
}
}
}

View File

@@ -1,88 +0,0 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using MediaBrowser.Common.Progress;
using MediaBrowser.Controller.Channels;
using MediaBrowser.Controller.Library;
using MediaBrowser.Model.Globalization;
using MediaBrowser.Model.Tasks;
using Microsoft.Extensions.Logging;
namespace Emby.Server.Implementations.Channels
{
/// <summary>
/// The "Refresh Channels" scheduled task.
/// </summary>
public class RefreshChannelsScheduledTask : IScheduledTask, IConfigurableScheduledTask
{
private readonly IChannelManager _channelManager;
private readonly ILogger<RefreshChannelsScheduledTask> _logger;
private readonly ILibraryManager _libraryManager;
private readonly ILocalizationManager _localization;
/// <summary>
/// Initializes a new instance of the <see cref="RefreshChannelsScheduledTask"/> class.
/// </summary>
/// <param name="channelManager">The channel manager.</param>
/// <param name="logger">The logger.</param>
/// <param name="libraryManager">The library manager.</param>
/// <param name="localization">The localization manager.</param>
public RefreshChannelsScheduledTask(
IChannelManager channelManager,
ILogger<RefreshChannelsScheduledTask> logger,
ILibraryManager libraryManager,
ILocalizationManager localization)
{
_channelManager = channelManager;
_logger = logger;
_libraryManager = libraryManager;
_localization = localization;
}
/// <inheritdoc />
public string Name => _localization.GetLocalizedString("TasksRefreshChannels");
/// <inheritdoc />
public string Description => _localization.GetLocalizedString("TasksRefreshChannelsDescription");
/// <inheritdoc />
public string Category => _localization.GetLocalizedString("TasksChannelsCategory");
/// <inheritdoc />
public bool IsHidden => ((ChannelManager)_channelManager).Channels.Length == 0;
/// <inheritdoc />
public bool IsEnabled => true;
/// <inheritdoc />
public bool IsLogged => true;
/// <inheritdoc />
public string Key => "RefreshInternetChannels";
/// <inheritdoc />
public async Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken)
{
var manager = (ChannelManager)_channelManager;
await manager.RefreshChannels(new SimpleProgress<double>(), cancellationToken).ConfigureAwait(false);
await new ChannelPostScanTask(_channelManager, _logger, _libraryManager).Run(progress, cancellationToken)
.ConfigureAwait(false);
}
/// <inheritdoc />
public IEnumerable<TaskTriggerInfo> GetDefaultTriggers()
{
return new[]
{
// Every so often
new TaskTriggerInfo
{
Type = TaskTriggerInfo.TriggerInterval, IntervalTicks = TimeSpan.FromHours(24).Ticks
}
};
}
}
}

View File

@@ -22,7 +22,6 @@
<ItemGroup>
<PackageReference Include="DiscUtils.Udf" />
<PackageReference Include="Jellyfin.XmlTv" />
<PackageReference Include="Microsoft.Data.Sqlite" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
<PackageReference Include="Microsoft.Extensions.Caching.Memory" />

View File

@@ -1,93 +0,0 @@
#nullable disable
#pragma warning disable CS1591
using System;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Data.Enums;
using Jellyfin.Data.Events;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.LiveTv;
using MediaBrowser.Controller.Plugins;
using MediaBrowser.Controller.Session;
using MediaBrowser.Model.Session;
using Microsoft.Extensions.Logging;
namespace Emby.Server.Implementations.EntryPoints
{
public sealed class RecordingNotifier : IServerEntryPoint
{
private readonly ILiveTvManager _liveTvManager;
private readonly ISessionManager _sessionManager;
private readonly IUserManager _userManager;
private readonly ILogger<RecordingNotifier> _logger;
public RecordingNotifier(
ISessionManager sessionManager,
IUserManager userManager,
ILogger<RecordingNotifier> logger,
ILiveTvManager liveTvManager)
{
_sessionManager = sessionManager;
_userManager = userManager;
_logger = logger;
_liveTvManager = liveTvManager;
}
/// <inheritdoc />
public Task RunAsync()
{
_liveTvManager.TimerCancelled += OnLiveTvManagerTimerCancelled;
_liveTvManager.SeriesTimerCancelled += OnLiveTvManagerSeriesTimerCancelled;
_liveTvManager.TimerCreated += OnLiveTvManagerTimerCreated;
_liveTvManager.SeriesTimerCreated += OnLiveTvManagerSeriesTimerCreated;
return Task.CompletedTask;
}
private async void OnLiveTvManagerSeriesTimerCreated(object sender, GenericEventArgs<TimerEventInfo> e)
{
await SendMessage(SessionMessageType.SeriesTimerCreated, e.Argument).ConfigureAwait(false);
}
private async void OnLiveTvManagerTimerCreated(object sender, GenericEventArgs<TimerEventInfo> e)
{
await SendMessage(SessionMessageType.TimerCreated, e.Argument).ConfigureAwait(false);
}
private async void OnLiveTvManagerSeriesTimerCancelled(object sender, GenericEventArgs<TimerEventInfo> e)
{
await SendMessage(SessionMessageType.SeriesTimerCancelled, e.Argument).ConfigureAwait(false);
}
private async void OnLiveTvManagerTimerCancelled(object sender, GenericEventArgs<TimerEventInfo> e)
{
await SendMessage(SessionMessageType.TimerCancelled, e.Argument).ConfigureAwait(false);
}
private async Task SendMessage(SessionMessageType name, TimerEventInfo info)
{
var users = _userManager.Users.Where(i => i.HasPermission(PermissionKind.EnableLiveTvAccess)).Select(i => i.Id).ToList();
try
{
await _sessionManager.SendMessageToUserSessions(users, name, info, CancellationToken.None).ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error sending message");
}
}
/// <inheritdoc />
public void Dispose()
{
_liveTvManager.TimerCancelled -= OnLiveTvManagerTimerCancelled;
_liveTvManager.SeriesTimerCancelled -= OnLiveTvManagerSeriesTimerCancelled;
_liveTvManager.TimerCreated -= OnLiveTvManagerTimerCreated;
_liveTvManager.SeriesTimerCreated -= OnLiveTvManagerSeriesTimerCreated;
}
}
}

View File

@@ -1,150 +0,0 @@
#pragma warning disable CS1591
using System;
using System.Buffers;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using MediaBrowser.Model.IO;
namespace Emby.Server.Implementations.IO
{
public class StreamHelper : IStreamHelper
{
public async Task CopyToAsync(Stream source, Stream destination, int bufferSize, Action? onStarted, CancellationToken cancellationToken)
{
byte[] buffer = ArrayPool<byte>.Shared.Rent(bufferSize);
try
{
int read;
while ((read = await source.ReadAsync(buffer, cancellationToken).ConfigureAwait(false)) != 0)
{
cancellationToken.ThrowIfCancellationRequested();
await destination.WriteAsync(buffer.AsMemory(0, read), cancellationToken).ConfigureAwait(false);
if (onStarted is not null)
{
onStarted();
onStarted = null;
}
}
}
finally
{
ArrayPool<byte>.Shared.Return(buffer);
}
}
public async Task CopyToAsync(Stream source, Stream destination, int bufferSize, int emptyReadLimit, CancellationToken cancellationToken)
{
byte[] buffer = ArrayPool<byte>.Shared.Rent(bufferSize);
try
{
if (emptyReadLimit <= 0)
{
int read;
while ((read = await source.ReadAsync(buffer, cancellationToken).ConfigureAwait(false)) != 0)
{
cancellationToken.ThrowIfCancellationRequested();
await destination.WriteAsync(buffer.AsMemory(0, read), cancellationToken).ConfigureAwait(false);
}
return;
}
var eofCount = 0;
while (eofCount < emptyReadLimit)
{
cancellationToken.ThrowIfCancellationRequested();
var bytesRead = await source.ReadAsync(buffer, cancellationToken).ConfigureAwait(false);
if (bytesRead == 0)
{
eofCount++;
await Task.Delay(50, cancellationToken).ConfigureAwait(false);
}
else
{
eofCount = 0;
await destination.WriteAsync(buffer.AsMemory(0, bytesRead), cancellationToken).ConfigureAwait(false);
}
}
}
finally
{
ArrayPool<byte>.Shared.Return(buffer);
}
}
public async Task CopyToAsync(Stream source, Stream destination, long copyLength, CancellationToken cancellationToken)
{
byte[] buffer = ArrayPool<byte>.Shared.Rent(IODefaults.CopyToBufferSize);
try
{
int bytesRead;
while ((bytesRead = await source.ReadAsync(buffer, cancellationToken).ConfigureAwait(false)) != 0)
{
var bytesToWrite = Math.Min(bytesRead, copyLength);
if (bytesToWrite > 0)
{
await destination.WriteAsync(buffer.AsMemory(0, Convert.ToInt32(bytesToWrite)), cancellationToken).ConfigureAwait(false);
}
copyLength -= bytesToWrite;
if (copyLength <= 0)
{
break;
}
}
}
finally
{
ArrayPool<byte>.Shared.Return(buffer);
}
}
public async Task CopyUntilCancelled(Stream source, Stream target, int bufferSize, CancellationToken cancellationToken)
{
byte[] buffer = ArrayPool<byte>.Shared.Rent(bufferSize);
try
{
while (!cancellationToken.IsCancellationRequested)
{
var bytesRead = await CopyToAsyncInternal(source, target, buffer, cancellationToken).ConfigureAwait(false);
if (bytesRead == 0)
{
await Task.Delay(100, cancellationToken).ConfigureAwait(false);
}
}
}
finally
{
ArrayPool<byte>.Shared.Return(buffer);
}
}
private static async Task<int> CopyToAsyncInternal(Stream source, Stream destination, byte[] buffer, CancellationToken cancellationToken)
{
int bytesRead;
int totalBytesRead = 0;
while ((bytesRead = await source.ReadAsync(buffer, cancellationToken).ConfigureAwait(false)) != 0)
{
await destination.WriteAsync(buffer.AsMemory(0, bytesRead), cancellationToken).ConfigureAwait(false);
totalBytesRead += bytesRead;
}
return totalBytesRead;
}
}
}

View File

@@ -1,60 +0,0 @@
#nullable disable
#pragma warning disable CS1591
using System;
using System.Globalization;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using MediaBrowser.Controller.Library;
using MediaBrowser.Model.Dto;
namespace Emby.Server.Implementations.Library
{
public sealed class ExclusiveLiveStream : ILiveStream
{
private readonly Func<Task> _closeFn;
public ExclusiveLiveStream(MediaSourceInfo mediaSource, Func<Task> closeFn)
{
MediaSource = mediaSource;
EnableStreamSharing = false;
_closeFn = closeFn;
ConsumerCount = 1;
UniqueId = Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture);
}
public int ConsumerCount { get; set; }
public string OriginalStreamId { get; set; }
public string TunerHostId => null;
public bool EnableStreamSharing { get; set; }
public MediaSourceInfo MediaSource { get; set; }
public string UniqueId { get; }
public Task Close()
{
return _closeFn();
}
public Stream GetStream()
{
throw new NotSupportedException();
}
public Task Open(CancellationToken openCancellationToken)
{
return Task.CompletedTask;
}
/// <inheritdoc />
public void Dispose()
{
}
}
}

View File

@@ -11,14 +11,15 @@ using System.Linq;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using EasyCaching.Core.Configurations;
using Jellyfin.Data.Entities;
using Jellyfin.Data.Enums;
using Jellyfin.Extensions.Json;
using MediaBrowser.Common.Configuration;
using MediaBrowser.Common.Extensions;
using MediaBrowser.Controller;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.LiveTv;
using MediaBrowser.Controller.MediaEncoding;
using MediaBrowser.Controller.Persistence;
using MediaBrowser.Controller.Providers;
@@ -37,6 +38,7 @@ namespace Emby.Server.Implementations.Library
// Do not use a pipe here because Roku http requests to the server will fail, without any explicit error message.
private const char LiveStreamIdDelimeter = '_';
private readonly IServerApplicationHost _appHost;
private readonly IItemRepository _itemRepo;
private readonly IUserManager _userManager;
private readonly ILibraryManager _libraryManager;
@@ -55,6 +57,7 @@ namespace Emby.Server.Implementations.Library
private IMediaSourceProvider[] _providers;
public MediaSourceManager(
IServerApplicationHost appHost,
IItemRepository itemRepo,
IApplicationPaths applicationPaths,
ILocalizationManager localizationManager,
@@ -66,6 +69,7 @@ namespace Emby.Server.Implementations.Library
IMediaEncoder mediaEncoder,
IDirectoryService directoryService)
{
_appHost = appHost;
_itemRepo = itemRepo;
_userManager = userManager;
_libraryManager = libraryManager;
@@ -799,6 +803,35 @@ namespace Emby.Server.Implementations.Library
return result.Item1;
}
public async Task<List<MediaSourceInfo>> GetRecordingStreamMediaSources(ActiveRecordingInfo info, CancellationToken cancellationToken)
{
var stream = new MediaSourceInfo
{
EncoderPath = _appHost.GetApiUrlForLocalAccess() + "/LiveTv/LiveRecordings/" + info.Id + "/stream",
EncoderProtocol = MediaProtocol.Http,
Path = info.Path,
Protocol = MediaProtocol.File,
Id = info.Id,
SupportsDirectPlay = false,
SupportsDirectStream = true,
SupportsTranscoding = true,
IsInfiniteStream = true,
RequiresOpening = false,
RequiresClosing = false,
BufferMs = 0,
IgnoreDts = true,
IgnoreIndex = true
};
await new LiveStreamHelper(_mediaEncoder, _logger, _appPaths)
.AddMediaInfoWithProbe(stream, false, false, cancellationToken).ConfigureAwait(false);
return new List<MediaSourceInfo>
{
stream
};
}
public async Task CloseLiveStream(string id)
{
ArgumentException.ThrowIfNullOrEmpty(id);

View File

@@ -1,119 +0,0 @@
#pragma warning disable CS1591
using System;
using System.IO;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Api.Helpers;
using MediaBrowser.Common.Net;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Streaming;
using MediaBrowser.Model.Dto;
using MediaBrowser.Model.IO;
using Microsoft.Extensions.Logging;
namespace Emby.Server.Implementations.LiveTv.EmbyTV
{
public sealed class DirectRecorder : IRecorder
{
private readonly ILogger _logger;
private readonly IHttpClientFactory _httpClientFactory;
private readonly IStreamHelper _streamHelper;
public DirectRecorder(ILogger logger, IHttpClientFactory httpClientFactory, IStreamHelper streamHelper)
{
_logger = logger;
_httpClientFactory = httpClientFactory;
_streamHelper = streamHelper;
}
public string GetOutputPath(MediaSourceInfo mediaSource, string targetFile)
{
return targetFile;
}
public Task Record(IDirectStreamProvider? directStreamProvider, MediaSourceInfo mediaSource, string targetFile, TimeSpan duration, Action onStarted, CancellationToken cancellationToken)
{
if (directStreamProvider is not null)
{
return RecordFromDirectStreamProvider(directStreamProvider, targetFile, duration, onStarted, cancellationToken);
}
return RecordFromMediaSource(mediaSource, targetFile, duration, onStarted, cancellationToken);
}
private async Task RecordFromDirectStreamProvider(IDirectStreamProvider directStreamProvider, string targetFile, TimeSpan duration, Action onStarted, CancellationToken cancellationToken)
{
Directory.CreateDirectory(Path.GetDirectoryName(targetFile) ?? throw new ArgumentException("Path can't be a root directory.", nameof(targetFile)));
var output = new FileStream(
targetFile,
FileMode.CreateNew,
FileAccess.Write,
FileShare.Read,
IODefaults.FileStreamBufferSize,
FileOptions.Asynchronous);
await using (output.ConfigureAwait(false))
{
onStarted();
_logger.LogInformation("Copying recording to file {FilePath}", targetFile);
// The media source is infinite so we need to handle stopping ourselves
using var durationToken = new CancellationTokenSource(duration);
using var cancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, durationToken.Token);
var linkedCancellationToken = cancellationTokenSource.Token;
var fileStream = new ProgressiveFileStream(directStreamProvider.GetStream());
await using (fileStream.ConfigureAwait(false))
{
await _streamHelper.CopyToAsync(
fileStream,
output,
IODefaults.CopyToBufferSize,
1000,
linkedCancellationToken).ConfigureAwait(false);
}
}
_logger.LogInformation("Recording completed: {FilePath}", targetFile);
}
private async Task RecordFromMediaSource(MediaSourceInfo mediaSource, string targetFile, TimeSpan duration, Action onStarted, CancellationToken cancellationToken)
{
using var response = await _httpClientFactory.CreateClient(NamedClient.Default)
.GetAsync(mediaSource.Path, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false);
_logger.LogInformation("Opened recording stream from tuner provider");
Directory.CreateDirectory(Path.GetDirectoryName(targetFile) ?? throw new ArgumentException("Path can't be a root directory.", nameof(targetFile)));
var output = new FileStream(targetFile, FileMode.CreateNew, FileAccess.Write, FileShare.Read, IODefaults.CopyToBufferSize, FileOptions.Asynchronous);
await using (output.ConfigureAwait(false))
{
onStarted();
_logger.LogInformation("Copying recording stream to file {0}", targetFile);
// The media source if infinite so we need to handle stopping ourselves
using var durationToken = new CancellationTokenSource(duration);
using var linkedCancellationToken = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, durationToken.Token);
cancellationToken = linkedCancellationToken.Token;
await _streamHelper.CopyUntilCancelled(
await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false),
output,
IODefaults.CopyToBufferSize,
cancellationToken).ConfigureAwait(false);
_logger.LogInformation("Recording completed to file {0}", targetFile);
}
}
/// <inheritdoc />
public void Dispose()
{
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,362 +0,0 @@
#nullable disable
#pragma warning disable CS1591
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Globalization;
using System.IO;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Extensions;
using Jellyfin.Extensions.Json;
using MediaBrowser.Common;
using MediaBrowser.Common.Configuration;
using MediaBrowser.Controller;
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.MediaEncoding;
using MediaBrowser.Model.Dto;
using MediaBrowser.Model.IO;
using Microsoft.Extensions.Logging;
namespace Emby.Server.Implementations.LiveTv.EmbyTV
{
public class EncodedRecorder : IRecorder
{
private readonly ILogger _logger;
private readonly IMediaEncoder _mediaEncoder;
private readonly IServerApplicationPaths _appPaths;
private readonly TaskCompletionSource<bool> _taskCompletionSource = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
private readonly IServerConfigurationManager _serverConfigurationManager;
private readonly JsonSerializerOptions _jsonOptions = JsonDefaults.Options;
private bool _hasExited;
private FileStream _logFileStream;
private string _targetPath;
private Process _process;
private bool _disposed;
public EncodedRecorder(
ILogger logger,
IMediaEncoder mediaEncoder,
IServerApplicationPaths appPaths,
IServerConfigurationManager serverConfigurationManager)
{
_logger = logger;
_mediaEncoder = mediaEncoder;
_appPaths = appPaths;
_serverConfigurationManager = serverConfigurationManager;
}
private static bool CopySubtitles => false;
public string GetOutputPath(MediaSourceInfo mediaSource, string targetFile)
{
return Path.ChangeExtension(targetFile, ".ts");
}
public async Task Record(IDirectStreamProvider directStreamProvider, MediaSourceInfo mediaSource, string targetFile, TimeSpan duration, Action onStarted, CancellationToken cancellationToken)
{
// The media source is infinite so we need to handle stopping ourselves
using var durationToken = new CancellationTokenSource(duration);
using var cancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, durationToken.Token);
await RecordFromFile(mediaSource, mediaSource.Path, targetFile, onStarted, cancellationTokenSource.Token).ConfigureAwait(false);
_logger.LogInformation("Recording completed to file {Path}", targetFile);
}
private async Task RecordFromFile(MediaSourceInfo mediaSource, string inputFile, string targetFile, Action onStarted, CancellationToken cancellationToken)
{
_targetPath = targetFile;
Directory.CreateDirectory(Path.GetDirectoryName(targetFile));
var processStartInfo = new ProcessStartInfo
{
CreateNoWindow = true,
UseShellExecute = false,
RedirectStandardError = true,
RedirectStandardInput = true,
FileName = _mediaEncoder.EncoderPath,
Arguments = GetCommandLineArgs(mediaSource, inputFile, targetFile),
WindowStyle = ProcessWindowStyle.Hidden,
ErrorDialog = false
};
_logger.LogInformation("{Filename} {Arguments}", processStartInfo.FileName, processStartInfo.Arguments);
var logFilePath = Path.Combine(_appPaths.LogDirectoryPath, "record-transcode-" + Guid.NewGuid() + ".txt");
Directory.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 = new FileStream(logFilePath, FileMode.CreateNew, FileAccess.Write, FileShare.Read, IODefaults.FileStreamBufferSize, FileOptions.Asynchronous);
await JsonSerializer.SerializeAsync(_logFileStream, mediaSource, _jsonOptions, cancellationToken).ConfigureAwait(false);
await _logFileStream.WriteAsync(Encoding.UTF8.GetBytes(Environment.NewLine + Environment.NewLine + processStartInfo.FileName + " " + processStartInfo.Arguments + Environment.NewLine + Environment.NewLine), cancellationToken).ConfigureAwait(false);
_process = new Process
{
StartInfo = processStartInfo,
EnableRaisingEvents = true
};
_process.Exited += (_, _) => OnFfMpegProcessExited(_process);
_process.Start();
cancellationToken.Register(Stop);
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.LogInformation("ffmpeg recording process started for {Path}", _targetPath);
// Block until ffmpeg exits
await _taskCompletionSource.Task.ConfigureAwait(false);
}
private string GetCommandLineArgs(MediaSourceInfo mediaSource, string inputTempFile, string targetFile)
{
string videoArgs;
if (EncodeVideo(mediaSource))
{
const int MaxBitrate = 25000000;
videoArgs = string.Format(
CultureInfo.InvariantCulture,
"-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);
}
else
{
videoArgs = "-codec:v:0 copy";
}
videoArgs += " -fflags +genpts";
var flags = new List<string>();
if (mediaSource.IgnoreDts)
{
flags.Add("+igndts");
}
if (mediaSource.IgnoreIndex)
{
flags.Add("+ignidx");
}
if (mediaSource.GenPtsInput)
{
flags.Add("+genpts");
}
var inputModifier = "-async 1 -vsync -1";
if (flags.Count > 0)
{
inputModifier += " -fflags " + string.Join(string.Empty, flags);
}
if (mediaSource.ReadAtNativeFramerate)
{
inputModifier += " -re";
}
if (mediaSource.RequiresLooping)
{
inputModifier += " -stream_loop -1 -reconnect_at_eof 1 -reconnect_streamed 1 -reconnect_delay_max 2";
}
var analyzeDurationSeconds = 5;
var analyzeDuration = " -analyzeduration " +
(analyzeDurationSeconds * 1000000).ToString(CultureInfo.InvariantCulture);
inputModifier += analyzeDuration;
var subtitleArgs = CopySubtitles ? " -codec:s copy" : " -sn";
// var outputParam = string.Equals(Path.GetExtension(targetFile), ".mp4", StringComparison.OrdinalIgnoreCase) ?
// " -f mp4 -movflags frag_keyframe+empty_moov" :
// string.Empty;
var outputParam = string.Empty;
var threads = EncodingHelper.GetNumberOfThreads(null, _serverConfigurationManager.GetEncodingOptions(), null);
var commandLineArgs = string.Format(
CultureInfo.InvariantCulture,
"-i \"{0}\" {2} -map_metadata -1 -threads {6} {3}{4}{5} -y \"{1}\"",
inputTempFile,
targetFile.Replace("\"", "\\\"", StringComparison.Ordinal), // Escape quotes in filename
videoArgs,
GetAudioArgs(mediaSource),
subtitleArgs,
outputParam,
threads);
return inputModifier + " " + commandLineArgs;
}
private static string GetAudioArgs(MediaSourceInfo mediaSource)
{
return "-codec:a:0 copy";
// var audioChannels = 2;
// var audioStream = mediaStreams.FirstOrDefault(i => i.Type == MediaStreamType.Audio);
// if (audioStream is not null)
// {
// audioChannels = audioStream.Channels ?? audioChannels;
// }
// return "-codec:a:0 aac -strict experimental -ab 320000";
}
private static bool EncodeVideo(MediaSourceInfo mediaSource)
{
return false;
}
protected string GetOutputSizeParam()
=> "-vf \"yadif=0:-1:0\"";
private void Stop()
{
if (!_hasExited)
{
try
{
_logger.LogInformation("Stopping ffmpeg recording process for {Path}", _targetPath);
_process.StandardInput.WriteLine("q");
}
catch (Exception ex)
{
_logger.LogError(ex, "Error stopping recording transcoding job for {Path}", _targetPath);
}
if (_hasExited)
{
return;
}
try
{
_logger.LogInformation("Calling recording process.WaitForExit for {Path}", _targetPath);
if (_process.WaitForExit(10000))
{
return;
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error waiting for recording process to exit for {Path}", _targetPath);
}
if (_hasExited)
{
return;
}
try
{
_logger.LogInformation("Killing ffmpeg recording process for {Path}", _targetPath);
_process.Kill();
}
catch (Exception ex)
{
_logger.LogError(ex, "Error killing recording transcoding job for {Path}", _targetPath);
}
}
}
/// <summary>
/// Processes the exited.
/// </summary>
private void OnFfMpegProcessExited(Process process)
{
using (process)
{
_hasExited = true;
_logFileStream?.Dispose();
_logFileStream = null;
var exitCode = process.ExitCode;
_logger.LogInformation("FFMpeg recording exited with code {ExitCode} for {Path}", exitCode, _targetPath);
if (exitCode == 0)
{
_taskCompletionSource.TrySetResult(true);
}
else
{
_taskCompletionSource.TrySetException(
new FfmpegException(
string.Format(
CultureInfo.InvariantCulture,
"Recording for {0} failed. Exit code {1}",
_targetPath,
exitCode)));
}
}
}
private async Task StartStreamingLog(Stream source, FileStream target)
{
try
{
using (var reader = new StreamReader(source))
{
await foreach (var line in reader.ReadAllLinesAsync().ConfigureAwait(false))
{
var bytes = Encoding.UTF8.GetBytes(Environment.NewLine + line);
await target.WriteAsync(bytes.AsMemory()).ConfigureAwait(false);
await target.FlushAsync().ConfigureAwait(false);
}
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error reading ffmpeg recording log");
}
}
/// <inheritdoc />
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}
/// <summary>
/// Releases unmanaged and optionally managed resources.
/// </summary>
/// <param name="disposing"><c>true</c> to release both managed and unmanaged resources; <c>false</c> to release only unmanaged resources.</param>
protected virtual void Dispose(bool disposing)
{
if (_disposed)
{
return;
}
if (disposing)
{
_logFileStream?.Dispose();
_process?.Dispose();
}
_logFileStream = null;
_process = null;
_disposed = true;
}
}
}

View File

@@ -1,21 +0,0 @@
#pragma warning disable CS1591
using System.Threading.Tasks;
using MediaBrowser.Controller.Plugins;
namespace Emby.Server.Implementations.LiveTv.EmbyTV
{
public sealed class EntryPoint : IServerEntryPoint
{
/// <inheritdoc />
public Task RunAsync()
{
return EmbyTV.Current.Start();
}
/// <inheritdoc />
public void Dispose()
{
}
}
}

View File

@@ -1,54 +0,0 @@
#pragma warning disable CS1591
using System;
using System.Collections.Generic;
using MediaBrowser.Controller.LiveTv;
namespace Emby.Server.Implementations.LiveTv.EmbyTV
{
internal class EpgChannelData
{
private readonly Dictionary<string, ChannelInfo> _channelsById;
private readonly Dictionary<string, ChannelInfo> _channelsByNumber;
private readonly Dictionary<string, ChannelInfo> _channelsByName;
public EpgChannelData(IEnumerable<ChannelInfo> channels)
{
_channelsById = new Dictionary<string, ChannelInfo>(StringComparer.OrdinalIgnoreCase);
_channelsByNumber = new Dictionary<string, ChannelInfo>(StringComparer.OrdinalIgnoreCase);
_channelsByName = new Dictionary<string, ChannelInfo>(StringComparer.OrdinalIgnoreCase);
foreach (var channel in channels)
{
_channelsById[channel.Id] = channel;
if (!string.IsNullOrEmpty(channel.Number))
{
_channelsByNumber[channel.Number] = channel;
}
var normalizedName = NormalizeName(channel.Name ?? string.Empty);
if (!string.IsNullOrWhiteSpace(normalizedName))
{
_channelsByName[normalizedName] = channel;
}
}
}
public ChannelInfo? GetChannelById(string id)
=> _channelsById.GetValueOrDefault(id);
public ChannelInfo? GetChannelByNumber(string number)
=> _channelsByNumber.GetValueOrDefault(number);
public ChannelInfo? GetChannelByName(string name)
=> _channelsByName.GetValueOrDefault(name);
public static string NormalizeName(string value)
{
return value.Replace(" ", string.Empty, StringComparison.Ordinal).Replace("-", string.Empty, StringComparison.Ordinal);
}
}
}

View File

@@ -1,27 +0,0 @@
#pragma warning disable CS1591
using System;
using System.Threading;
using System.Threading.Tasks;
using MediaBrowser.Controller.Library;
using MediaBrowser.Model.Dto;
namespace Emby.Server.Implementations.LiveTv.EmbyTV
{
public interface IRecorder : IDisposable
{
/// <summary>
/// Records the specified media source.
/// </summary>
/// <param name="directStreamProvider">The direct stream provider, or <c>null</c>.</param>
/// <param name="mediaSource">The media source.</param>
/// <param name="targetFile">The target file.</param>
/// <param name="duration">The duration to record.</param>
/// <param name="onStarted">An action to perform when recording starts.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>A <see cref="Task"/> that represents the recording operation.</returns>
Task Record(IDirectStreamProvider? directStreamProvider, MediaSourceInfo mediaSource, string targetFile, TimeSpan duration, Action onStarted, CancellationToken cancellationToken);
string GetOutputPath(MediaSourceInfo mediaSource, string targetFile);
}
}

View File

@@ -1,163 +0,0 @@
#pragma warning disable CS1591
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.IO;
using System.Linq;
using System.Text.Json;
using Jellyfin.Extensions.Json;
using Microsoft.Extensions.Logging;
namespace Emby.Server.Implementations.LiveTv.EmbyTV
{
public class ItemDataProvider<T>
where T : class
{
private readonly string _dataPath;
private readonly object _fileDataLock = new object();
private readonly JsonSerializerOptions _jsonOptions = JsonDefaults.Options;
private T[]? _items;
public ItemDataProvider(
ILogger logger,
string dataPath,
Func<T, T, bool> equalityComparer)
{
Logger = logger;
_dataPath = dataPath;
EqualityComparer = equalityComparer;
}
protected ILogger Logger { get; }
protected Func<T, T, bool> EqualityComparer { get; }
[MemberNotNull(nameof(_items))]
private void EnsureLoaded()
{
if (_items is not null)
{
return;
}
if (File.Exists(_dataPath))
{
Logger.LogInformation("Loading live tv data from {Path}", _dataPath);
try
{
var bytes = File.ReadAllBytes(_dataPath);
_items = JsonSerializer.Deserialize<T[]>(bytes, _jsonOptions);
if (_items is null)
{
Logger.LogError("Error deserializing {Path}, data was null", _dataPath);
_items = Array.Empty<T>();
}
return;
}
catch (JsonException ex)
{
Logger.LogError(ex, "Error deserializing {Path}", _dataPath);
}
}
_items = Array.Empty<T>();
}
private void SaveList()
{
Directory.CreateDirectory(Path.GetDirectoryName(_dataPath) ?? throw new ArgumentException("Path can't be a root directory.", nameof(_dataPath)));
var jsonString = JsonSerializer.Serialize(_items, _jsonOptions);
File.WriteAllText(_dataPath, jsonString);
}
public IReadOnlyList<T> GetAll()
{
lock (_fileDataLock)
{
EnsureLoaded();
return (T[])_items.Clone();
}
}
public virtual void Update(T item)
{
ArgumentNullException.ThrowIfNull(item);
lock (_fileDataLock)
{
EnsureLoaded();
var index = Array.FindIndex(_items, i => EqualityComparer(i, item));
if (index == -1)
{
throw new ArgumentException("item not found");
}
_items[index] = item;
SaveList();
}
}
public virtual void Add(T item)
{
ArgumentNullException.ThrowIfNull(item);
lock (_fileDataLock)
{
EnsureLoaded();
if (_items.Any(i => EqualityComparer(i, item)))
{
throw new ArgumentException("item already exists", nameof(item));
}
int oldLen = _items.Length;
var newList = new T[oldLen + 1];
_items.CopyTo(newList, 0);
newList[oldLen] = item;
_items = newList;
SaveList();
}
}
public virtual void AddOrUpdate(T item)
{
lock (_fileDataLock)
{
EnsureLoaded();
int index = Array.FindIndex(_items, i => EqualityComparer(i, item));
if (index == -1)
{
int oldLen = _items.Length;
var newList = new T[oldLen + 1];
_items.CopyTo(newList, 0);
newList[oldLen] = item;
_items = newList;
}
else
{
_items[index] = item;
}
SaveList();
}
}
public virtual void Delete(T item)
{
lock (_fileDataLock)
{
EnsureLoaded();
_items = _items.Where(i => !EqualityComparer(i, item)).ToArray();
SaveList();
}
}
}
}

View File

@@ -1,19 +0,0 @@
using MediaBrowser.Common.Configuration;
using MediaBrowser.Model.Configuration;
namespace Emby.Server.Implementations.LiveTv.EmbyTV
{
/// <summary>
/// Class containing extension methods for working with the nfo configuration.
/// </summary>
public static class NfoConfigurationExtensions
{
/// <summary>
/// Gets the nfo configuration.
/// </summary>
/// <param name="configurationManager">The configuration manager.</param>
/// <returns>The nfo configuration.</returns>
public static XbmcMetadataOptions GetNfoConfiguration(this IConfigurationManager configurationManager)
=> configurationManager.GetConfiguration<XbmcMetadataOptions>("xbmcmetadata");
}
}

View File

@@ -1,83 +0,0 @@
#pragma warning disable CS1591
using System;
using System.Globalization;
using System.Text;
using MediaBrowser.Controller.LiveTv;
namespace Emby.Server.Implementations.LiveTv.EmbyTV
{
internal static class RecordingHelper
{
public static DateTime GetStartTime(TimerInfo timer)
{
return timer.StartDate.AddSeconds(-timer.PrePaddingSeconds);
}
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(
CultureInfo.InvariantCulture,
" 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)
{
if (info.OriginalAirDate.Value.Date.Equals(info.StartDate.Date))
{
name += " " + GetDateString(info.StartDate);
}
else
{
name += " " + info.OriginalAirDate.Value.ToLocalTime().ToString("yyyy-MM-dd", CultureInfo.InvariantCulture);
}
}
else
{
name += " " + GetDateString(info.StartDate);
}
if (!string.IsNullOrWhiteSpace(info.EpisodeTitle))
{
var tmpName = name;
if (addHyphen)
{
tmpName += " -";
}
tmpName += " " + info.EpisodeTitle;
// Since the filename will be used with file ext. (.mp4, .ts, etc)
if (Encoding.UTF8.GetByteCount(tmpName) < 250)
{
name = tmpName;
}
}
}
else if (info.IsMovie && info.ProductionYear is not null)
{
name += " (" + info.ProductionYear + ")";
}
else
{
name += " " + GetDateString(info.StartDate);
}
return name;
}
private static string GetDateString(DateTime date)
{
return date.ToLocalTime().ToString("yyyy_MM_dd_HH_mm_ss", CultureInfo.InvariantCulture);
}
}
}

View File

@@ -1,24 +0,0 @@
#pragma warning disable CS1591
using System;
using MediaBrowser.Controller.LiveTv;
using Microsoft.Extensions.Logging;
namespace Emby.Server.Implementations.LiveTv.EmbyTV
{
public class SeriesTimerManager : ItemDataProvider<SeriesTimerInfo>
{
public SeriesTimerManager(ILogger logger, string dataPath)
: base(logger, dataPath, (r1, r2) => string.Equals(r1.Id, r2.Id, StringComparison.OrdinalIgnoreCase))
{
}
/// <inheritdoc />
public override void Add(SeriesTimerInfo item)
{
ArgumentException.ThrowIfNullOrEmpty(item.Id);
base.Add(item);
}
}
}

View File

@@ -1,181 +0,0 @@
#pragma warning disable CS1591
using System;
using System.Collections.Concurrent;
using System.Globalization;
using System.Linq;
using System.Threading;
using Jellyfin.Data.Events;
using MediaBrowser.Controller.LiveTv;
using MediaBrowser.Model.LiveTv;
using Microsoft.Extensions.Logging;
namespace Emby.Server.Implementations.LiveTv.EmbyTV
{
public class TimerManager : ItemDataProvider<TimerInfo>
{
private readonly ConcurrentDictionary<string, Timer> _timers = new ConcurrentDictionary<string, Timer>(StringComparer.OrdinalIgnoreCase);
public TimerManager(ILogger logger, string dataPath)
: base(logger, dataPath, (r1, r2) => string.Equals(r1.Id, r2.Id, StringComparison.OrdinalIgnoreCase))
{
}
public event EventHandler<GenericEventArgs<TimerInfo>>? TimerFired;
public void RestartTimers()
{
StopTimers();
foreach (var item in GetAll())
{
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;
}
base.AddOrUpdate(item);
}
public override void AddOrUpdate(TimerInfo item)
{
base.AddOrUpdate(item);
AddOrUpdateSystemTimer(item);
}
public override void Add(TimerInfo item)
{
ArgumentException.ThrowIfNullOrEmpty(item.Id);
base.Add(item);
AddOrUpdateSystemTimer(item);
}
private static 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)
{
TimerFired?.Invoke(this, new GenericEventArgs<TimerInfo>(item));
return;
}
var dueTime = startDate - now;
StartTimer(item, dueTime);
}
private void StartTimer(TimerInfo item, TimeSpan dueTime)
{
var timer = new Timer(TimerCallback, item.Id, dueTime, TimeSpan.Zero);
if (_timers.TryAdd(item.Id, timer))
{
if (item.IsSeries)
{
Logger.LogInformation(
"Creating recording timer for {Id}, {Name} {SeasonNumber}x{EpisodeNumber:D2} on channel {ChannelId}. Timer will fire in {Minutes} minutes at {StartDate}",
item.Id,
item.Name,
item.SeasonNumber,
item.EpisodeNumber,
item.ChannelId,
dueTime.TotalMinutes.ToString(CultureInfo.InvariantCulture),
item.StartDate);
}
else
{
Logger.LogInformation(
"Creating recording timer for {Id}, {Name} on channel {ChannelId}. Timer will fire in {Minutes} minutes at {StartDate}",
item.Id,
item.Name,
item.ChannelId,
dueTime.TotalMinutes.ToString(CultureInfo.InvariantCulture),
item.StartDate);
}
}
else
{
timer.Dispose();
Logger.LogWarning("Timer already exists for item {Id}", item.Id);
}
}
private void StopTimer(TimerInfo item)
{
if (_timers.TryRemove(item.Id, out var timer))
{
timer.Dispose();
}
}
private void TimerCallback(object? state)
{
var timerId = (string?)state ?? throw new ArgumentNullException(nameof(state));
var timer = GetAll().FirstOrDefault(i => string.Equals(i.Id, timerId, StringComparison.OrdinalIgnoreCase));
if (timer is not null)
{
TimerFired?.Invoke(this, new GenericEventArgs<TimerInfo>(timer));
}
}
public TimerInfo? GetTimer(string id)
{
return GetAll().FirstOrDefault(r => string.Equals(r.Id, id, StringComparison.OrdinalIgnoreCase));
}
public TimerInfo? GetTimerByProgramId(string programId)
{
return GetAll().FirstOrDefault(r => string.Equals(r.ProgramId, programId, StringComparison.OrdinalIgnoreCase));
}
}
}

View File

@@ -1,808 +0,0 @@
#nullable disable
#pragma warning disable CS1591
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Net.Http.Json;
using System.Net.Mime;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos;
using Jellyfin.Extensions;
using Jellyfin.Extensions.Json;
using MediaBrowser.Common.Net;
using MediaBrowser.Controller.Authentication;
using MediaBrowser.Controller.LiveTv;
using MediaBrowser.Model.Dto;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.LiveTv;
using Microsoft.Extensions.Logging;
namespace Emby.Server.Implementations.LiveTv.Listings
{
public class SchedulesDirect : IListingsProvider, IDisposable
{
private const string ApiUrl = "https://json.schedulesdirect.org/20141201";
private readonly ILogger<SchedulesDirect> _logger;
private readonly IHttpClientFactory _httpClientFactory;
private readonly SemaphoreSlim _tokenSemaphore = new SemaphoreSlim(1, 1);
private readonly ConcurrentDictionary<string, NameValuePair> _tokens = new ConcurrentDictionary<string, NameValuePair>();
private readonly JsonSerializerOptions _jsonOptions = JsonDefaults.Options;
private DateTime _lastErrorResponse;
private bool _disposed = false;
public SchedulesDirect(
ILogger<SchedulesDirect> logger,
IHttpClientFactory httpClientFactory)
{
_logger = logger;
_httpClientFactory = httpClientFactory;
}
/// <inheritdoc />
public string Name => "Schedules Direct";
/// <inheritdoc />
public string Type => nameof(SchedulesDirect);
private static List<string> GetScheduleRequestDates(DateTime startDateUtc, DateTime endDateUtc)
{
var dates = new List<string>();
var start = new[] { startDateUtc, startDateUtc.ToLocalTime() }.Min().Date;
var end = new[] { endDateUtc, endDateUtc.ToLocalTime() }.Max().Date;
while (start <= end)
{
dates.Add(start.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture));
start = start.AddDays(1);
}
return dates;
}
public async Task<IEnumerable<ProgramInfo>> GetProgramsAsync(ListingsProviderInfo info, string channelId, DateTime startDateUtc, DateTime endDateUtc, CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrEmpty(channelId);
// Normalize incoming input
channelId = channelId.Replace(".json.schedulesdirect.org", string.Empty, StringComparison.OrdinalIgnoreCase).TrimStart('I');
var token = await GetToken(info, cancellationToken).ConfigureAwait(false);
if (string.IsNullOrEmpty(token))
{
_logger.LogWarning("SchedulesDirect token is empty, returning empty program list");
return Enumerable.Empty<ProgramInfo>();
}
var dates = GetScheduleRequestDates(startDateUtc, endDateUtc);
_logger.LogInformation("Channel Station ID is: {ChannelID}", channelId);
var requestList = new List<RequestScheduleForChannelDto>()
{
new RequestScheduleForChannelDto()
{
StationId = channelId,
Date = dates
}
};
_logger.LogDebug("Request string for schedules is: {@RequestString}", requestList);
using var options = new HttpRequestMessage(HttpMethod.Post, ApiUrl + "/schedules");
options.Content = JsonContent.Create(requestList, options: _jsonOptions);
options.Headers.TryAddWithoutValidation("token", token);
using var response = await Send(options, true, info, cancellationToken).ConfigureAwait(false);
var dailySchedules = await response.Content.ReadFromJsonAsync<IReadOnlyList<DayDto>>(_jsonOptions, cancellationToken).ConfigureAwait(false);
if (dailySchedules is null)
{
return Array.Empty<ProgramInfo>();
}
_logger.LogDebug("Found {ScheduleCount} programs on {ChannelID} ScheduleDirect", dailySchedules.Count, channelId);
using var programRequestOptions = new HttpRequestMessage(HttpMethod.Post, ApiUrl + "/programs");
programRequestOptions.Headers.TryAddWithoutValidation("token", token);
var programIds = dailySchedules.SelectMany(d => d.Programs.Select(s => s.ProgramId)).Distinct();
programRequestOptions.Content = JsonContent.Create(programIds, options: _jsonOptions);
using var innerResponse = await Send(programRequestOptions, true, info, cancellationToken).ConfigureAwait(false);
var programDetails = await innerResponse.Content.ReadFromJsonAsync<IReadOnlyList<ProgramDetailsDto>>(_jsonOptions, cancellationToken).ConfigureAwait(false);
if (programDetails is null)
{
return Array.Empty<ProgramInfo>();
}
var programDict = programDetails.ToDictionary(p => p.ProgramId, y => y);
var programIdsWithImages = programDetails
.Where(p => p.HasImageArtwork).Select(p => p.ProgramId)
.ToList();
var images = await GetImageForPrograms(info, programIdsWithImages, cancellationToken).ConfigureAwait(false);
var programsInfo = new List<ProgramInfo>();
foreach (ProgramDto schedule in dailySchedules.SelectMany(d => d.Programs))
{
// _logger.LogDebug("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 (string.IsNullOrEmpty(schedule.ProgramId))
{
continue;
}
if (images is not null)
{
var imageIndex = images.FindIndex(i => i.ProgramId == schedule.ProgramId[..10]);
if (imageIndex > -1)
{
var programEntry = programDict[schedule.ProgramId];
var allImages = images[imageIndex].Data;
var imagesWithText = allImages.Where(i => string.Equals(i.Text, "yes", StringComparison.OrdinalIgnoreCase)).ToList();
var imagesWithoutText = allImages.Where(i => string.Equals(i.Text, "no", StringComparison.OrdinalIgnoreCase)).ToList();
const double DesiredAspect = 2.0 / 3;
programEntry.PrimaryImage = GetProgramImage(ApiUrl, imagesWithText, DesiredAspect, token) ??
GetProgramImage(ApiUrl, allImages, DesiredAspect, token);
const double WideAspect = 16.0 / 9;
programEntry.ThumbImage = GetProgramImage(ApiUrl, imagesWithText, WideAspect, token);
// Don't supply the same image twice
if (string.Equals(programEntry.PrimaryImage, programEntry.ThumbImage, StringComparison.Ordinal))
{
programEntry.ThumbImage = null;
}
programEntry.BackdropImage = GetProgramImage(ApiUrl, imagesWithoutText, WideAspect, token);
// 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(channelId, schedule, programDict[schedule.ProgramId]));
}
return programsInfo;
}
private static int GetSizeOrder(ImageDataDto image)
{
if (int.TryParse(image.Height, out int value))
{
return value;
}
return 0;
}
private static string GetChannelNumber(MapDto map)
{
var channelNumber = map.LogicalChannelNumber;
if (string.IsNullOrWhiteSpace(channelNumber))
{
channelNumber = map.Channel;
}
if (string.IsNullOrWhiteSpace(channelNumber))
{
channelNumber = map.AtscMajor + "." + map.AtscMinor;
}
return channelNumber.TrimStart('0');
}
private static bool IsMovie(ProgramDetailsDto programInfo)
{
return string.Equals(programInfo.EntityType, "movie", StringComparison.OrdinalIgnoreCase);
}
private ProgramInfo GetProgram(string channelId, ProgramDto programInfo, ProgramDetailsDto details)
{
if (programInfo.AirDateTime is null)
{
return null;
}
var startAt = programInfo.AirDateTime.Value;
var endAt = startAt.AddSeconds(programInfo.Duration);
var audioType = ProgramAudio.Stereo;
var programId = programInfo.ProgramId ?? string.Empty;
string newID = programId + "T" + startAt.Ticks + "C" + channelId;
if (programInfo.AudioProperties.Count != 0)
{
if (programInfo.AudioProperties.Contains("atmos", StringComparison.OrdinalIgnoreCase))
{
audioType = ProgramAudio.Atmos;
}
else if (programInfo.AudioProperties.Contains("dd 5.1", StringComparison.OrdinalIgnoreCase))
{
audioType = ProgramAudio.DolbyDigital;
}
else if (programInfo.AudioProperties.Contains("dd", StringComparison.OrdinalIgnoreCase))
{
audioType = ProgramAudio.DolbyDigital;
}
else if (programInfo.AudioProperties.Contains("stereo", StringComparison.OrdinalIgnoreCase))
{
audioType = ProgramAudio.Stereo;
}
else
{
audioType = ProgramAudio.Mono;
}
}
string episodeTitle = null;
if (details.EpisodeTitle150 is not null)
{
episodeTitle = details.EpisodeTitle150;
}
var info = new ProgramInfo
{
ChannelId = channelId,
Id = newID,
StartDate = startAt,
EndDate = endAt,
Name = details.Titles[0].Title120 ?? "Unknown",
OfficialRating = null,
CommunityRating = null,
EpisodeTitle = episodeTitle,
Audio = audioType,
// IsNew = programInfo.@new ?? false,
IsRepeat = programInfo.New is null,
IsSeries = string.Equals(details.EntityType, "episode", StringComparison.OrdinalIgnoreCase),
ImageUrl = details.PrimaryImage,
ThumbImageUrl = details.ThumbImage,
IsKids = string.Equals(details.Audience, "children", StringComparison.OrdinalIgnoreCase),
IsSports = string.Equals(details.EntityType, "sports", StringComparison.OrdinalIgnoreCase),
IsMovie = IsMovie(details),
Etag = programInfo.Md5,
IsLive = string.Equals(programInfo.LiveTapeDelay, "live", StringComparison.OrdinalIgnoreCase),
IsPremiere = programInfo.Premiere || (programInfo.IsPremiereOrFinale ?? string.Empty).Contains("premiere", StringComparison.OrdinalIgnoreCase)
};
var showId = programId;
if (!info.IsSeries)
{
// It's also a series if it starts with SH
info.IsSeries = showId.StartsWith("SH", StringComparison.OrdinalIgnoreCase) && showId.Length >= 14;
}
// According to SchedulesDirect, these are generic, unidentified episodes
// SH005316560000
var hasUniqueShowId = !showId.StartsWith("SH", StringComparison.OrdinalIgnoreCase) ||
!showId.EndsWith("0000", StringComparison.OrdinalIgnoreCase);
if (!hasUniqueShowId)
{
showId = newID;
}
info.ShowId = showId;
if (programInfo.VideoProperties is not null)
{
info.IsHD = programInfo.VideoProperties.Contains("hdtv", StringComparison.OrdinalIgnoreCase);
info.Is3D = programInfo.VideoProperties.Contains("3d", StringComparison.OrdinalIgnoreCase);
}
if (details.ContentRating is not null && details.ContentRating.Count > 0)
{
info.OfficialRating = details.ContentRating[0].Code.Replace("TV", "TV-", StringComparison.Ordinal)
.Replace("--", "-", StringComparison.Ordinal);
var invalid = new[] { "N/A", "Approved", "Not Rated", "Passed" };
if (invalid.Contains(info.OfficialRating, StringComparison.OrdinalIgnoreCase))
{
info.OfficialRating = null;
}
}
if (details.Descriptions is not null)
{
if (details.Descriptions.Description1000 is not null && details.Descriptions.Description1000.Count > 0)
{
info.Overview = details.Descriptions.Description1000[0].Description;
}
else if (details.Descriptions.Description100 is not null && details.Descriptions.Description100.Count > 0)
{
info.Overview = details.Descriptions.Description100[0].Description;
}
}
if (info.IsSeries)
{
info.SeriesId = programId.Substring(0, 10);
info.SeriesProviderIds[MetadataProvider.Zap2It.ToString()] = info.SeriesId;
if (details.Metadata is not null)
{
foreach (var metadataProgram in details.Metadata)
{
var gracenote = metadataProgram.Gracenote;
if (gracenote is not null)
{
info.SeasonNumber = gracenote.Season;
if (gracenote.Episode > 0)
{
info.EpisodeNumber = gracenote.Episode;
}
break;
}
}
}
}
if (details.OriginalAirDate is not null)
{
info.OriginalAirDate = details.OriginalAirDate;
info.ProductionYear = info.OriginalAirDate.Value.Year;
}
if (details.Movie is not null)
{
if (!string.IsNullOrEmpty(details.Movie.Year)
&& int.TryParse(details.Movie.Year, out int year))
{
info.ProductionYear = year;
}
}
if (details.Genres is not null)
{
info.Genres = details.Genres.Where(g => !string.IsNullOrWhiteSpace(g)).ToList();
info.IsNews = details.Genres.Contains("news", StringComparison.OrdinalIgnoreCase);
if (info.Genres.Contains("children", StringComparison.OrdinalIgnoreCase))
{
info.IsKids = true;
}
}
return info;
}
private static string GetProgramImage(string apiUrl, IEnumerable<ImageDataDto> images, double desiredAspect, string token)
{
var match = images
.OrderBy(i => Math.Abs(desiredAspect - GetAspectRatio(i)))
.ThenByDescending(i => GetSizeOrder(i))
.FirstOrDefault();
if (match is null)
{
return null;
}
var uri = match.Uri;
if (string.IsNullOrWhiteSpace(uri))
{
return null;
}
if (uri.Contains("http", StringComparison.OrdinalIgnoreCase))
{
return uri;
}
return apiUrl + "/image/" + uri + "?token=" + token;
}
private static double GetAspectRatio(ImageDataDto i)
{
int width = 0;
int height = 0;
if (!string.IsNullOrWhiteSpace(i.Width))
{
_ = int.TryParse(i.Width, out width);
}
if (!string.IsNullOrWhiteSpace(i.Height))
{
_ = int.TryParse(i.Height, out height);
}
if (height == 0 || width == 0)
{
return 0;
}
double result = width;
result /= height;
return result;
}
private async Task<IReadOnlyList<ShowImagesDto>> GetImageForPrograms(
ListingsProviderInfo info,
IReadOnlyList<string> programIds,
CancellationToken cancellationToken)
{
var token = await GetToken(info, cancellationToken).ConfigureAwait(false);
if (programIds.Count == 0)
{
return Array.Empty<ShowImagesDto>();
}
StringBuilder str = new StringBuilder("[", 1 + (programIds.Count * 13));
foreach (var i in programIds)
{
str.Append('"')
.Append(i[..10])
.Append("\",");
}
// Remove last ,
str.Length--;
str.Append(']');
using var message = new HttpRequestMessage(HttpMethod.Post, ApiUrl + "/metadata/programs")
{
Content = new StringContent(str.ToString(), Encoding.UTF8, MediaTypeNames.Application.Json)
};
message.Headers.TryAddWithoutValidation("token", token);
try
{
using var innerResponse2 = await Send(message, true, info, cancellationToken).ConfigureAwait(false);
return await innerResponse2.Content.ReadFromJsonAsync<IReadOnlyList<ShowImagesDto>>(_jsonOptions, cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error getting image info from schedules direct");
return Array.Empty<ShowImagesDto>();
}
}
public async Task<List<NameIdPair>> GetHeadends(ListingsProviderInfo info, string country, string location, CancellationToken cancellationToken)
{
var token = await GetToken(info, cancellationToken).ConfigureAwait(false);
var lineups = new List<NameIdPair>();
if (string.IsNullOrWhiteSpace(token))
{
return lineups;
}
using var options = new HttpRequestMessage(HttpMethod.Get, ApiUrl + "/headends?country=" + country + "&postalcode=" + location);
options.Headers.TryAddWithoutValidation("token", token);
try
{
using var httpResponse = await Send(options, false, info, cancellationToken).ConfigureAwait(false);
var root = await httpResponse.Content.ReadFromJsonAsync<IReadOnlyList<HeadendsDto>>(_jsonOptions, cancellationToken).ConfigureAwait(false);
if (root is not null)
{
foreach (HeadendsDto headend in root)
{
foreach (LineupDto lineup in headend.Lineups)
{
lineups.Add(new NameIdPair
{
Name = string.IsNullOrWhiteSpace(lineup.Name) ? lineup.Lineup : lineup.Name,
Id = lineup.Uri?[18..]
});
}
}
}
else
{
_logger.LogInformation("No lineups available");
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error getting headends");
}
return lineups;
}
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.IsNullOrEmpty(password))
{
return null;
}
// Avoid hammering SD
if ((DateTime.UtcNow - _lastErrorResponse).TotalMinutes < 1)
{
return null;
}
if (!_tokens.TryGetValue(username, out NameValuePair savedToken))
{
savedToken = new NameValuePair();
_tokens.TryAdd(username, savedToken);
}
if (!string.IsNullOrEmpty(savedToken.Name)
&& long.TryParse(savedToken.Value, CultureInfo.InvariantCulture, out long 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 (HttpRequestException ex)
{
if (ex.StatusCode.HasValue && ex.StatusCode.Value == HttpStatusCode.BadRequest)
{
_tokens.Clear();
_lastErrorResponse = DateTime.UtcNow;
}
throw;
}
finally
{
_tokenSemaphore.Release();
}
}
private async Task<HttpResponseMessage> Send(
HttpRequestMessage options,
bool enableRetry,
ListingsProviderInfo providerInfo,
CancellationToken cancellationToken,
HttpCompletionOption completionOption = HttpCompletionOption.ResponseContentRead)
{
var response = await _httpClientFactory.CreateClient(NamedClient.Default)
.SendAsync(options, completionOption, cancellationToken).ConfigureAwait(false);
if (response.IsSuccessStatusCode)
{
return response;
}
// Response is automatically disposed in the calling function,
// so dispose manually if not returning.
response.Dispose();
if (!enableRetry || (int)response.StatusCode >= 500)
{
throw new HttpRequestException(
string.Format(CultureInfo.InvariantCulture, "Request failed: {0}", response.ReasonPhrase),
null,
response.StatusCode);
}
_tokens.Clear();
options.Headers.TryAddWithoutValidation("token", await GetToken(providerInfo, cancellationToken).ConfigureAwait(false));
return await Send(options, false, providerInfo, cancellationToken).ConfigureAwait(false);
}
private async Task<string> GetTokenInternal(
string username,
string password,
CancellationToken cancellationToken)
{
using var options = new HttpRequestMessage(HttpMethod.Post, ApiUrl + "/token");
#pragma warning disable CA5350 // SchedulesDirect is always SHA1.
var hashedPasswordBytes = SHA1.HashData(Encoding.ASCII.GetBytes(password));
#pragma warning restore CA5350
// TODO: remove ToLower when Convert.ToHexString supports lowercase
// Schedules Direct requires the hex to be lowercase
string hashedPassword = Convert.ToHexString(hashedPasswordBytes).ToLowerInvariant();
options.Content = new StringContent("{\"username\":\"" + username + "\",\"password\":\"" + hashedPassword + "\"}", Encoding.UTF8, MediaTypeNames.Application.Json);
using var response = await Send(options, false, null, cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
var root = await response.Content.ReadFromJsonAsync<TokenDto>(_jsonOptions, cancellationToken).ConfigureAwait(false);
if (string.Equals(root?.Message, "OK", StringComparison.Ordinal))
{
_logger.LogInformation("Authenticated with Schedules Direct token: {Token}", root.Token);
return root.Token;
}
throw new AuthenticationException("Could not authenticate with Schedules Direct Error: " + root.Message);
}
private async Task AddLineupToAccount(ListingsProviderInfo info, CancellationToken cancellationToken)
{
var token = await GetToken(info, cancellationToken).ConfigureAwait(false);
ArgumentException.ThrowIfNullOrEmpty(token);
ArgumentException.ThrowIfNullOrEmpty(info.ListingsId);
_logger.LogInformation("Adding new LineUp ");
using var options = new HttpRequestMessage(HttpMethod.Put, ApiUrl + "/lineups/" + info.ListingsId);
options.Headers.TryAddWithoutValidation("token", token);
using var response = await _httpClientFactory.CreateClient(NamedClient.Default).SendAsync(options, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false);
}
private async Task<bool> HasLineup(ListingsProviderInfo info, CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrEmpty(info.ListingsId);
var token = await GetToken(info, cancellationToken).ConfigureAwait(false);
ArgumentException.ThrowIfNullOrEmpty(token);
_logger.LogInformation("Headends on account ");
using var options = new HttpRequestMessage(HttpMethod.Get, ApiUrl + "/lineups");
options.Headers.TryAddWithoutValidation("token", token);
try
{
using var httpResponse = await Send(options, false, null, cancellationToken).ConfigureAwait(false);
httpResponse.EnsureSuccessStatusCode();
var root = await httpResponse.Content.ReadFromJsonAsync<LineupsDto>(_jsonOptions, cancellationToken).ConfigureAwait(false);
return root?.Lineups.Any(i => string.Equals(info.ListingsId, i.Lineup, StringComparison.OrdinalIgnoreCase)) ?? false;
}
catch (HttpRequestException ex)
{
// SchedulesDirect returns 400 if no lineups are configured.
if (ex.StatusCode is HttpStatusCode.BadRequest)
{
return false;
}
throw;
}
}
public async Task Validate(ListingsProviderInfo info, bool validateLogin, bool validateListings)
{
if (validateLogin)
{
ArgumentException.ThrowIfNullOrEmpty(info.Username);
ArgumentException.ThrowIfNullOrEmpty(info.Password);
}
if (validateListings)
{
ArgumentException.ThrowIfNullOrEmpty(info.ListingsId);
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;
ArgumentException.ThrowIfNullOrEmpty(listingsId);
var token = await GetToken(info, cancellationToken).ConfigureAwait(false);
ArgumentException.ThrowIfNullOrEmpty(token);
using var options = new HttpRequestMessage(HttpMethod.Get, ApiUrl + "/lineups/" + listingsId);
options.Headers.TryAddWithoutValidation("token", token);
using var httpResponse = await Send(options, true, info, cancellationToken).ConfigureAwait(false);
var root = await httpResponse.Content.ReadFromJsonAsync<ChannelDto>(_jsonOptions, cancellationToken).ConfigureAwait(false);
if (root is null)
{
return new List<ChannelInfo>();
}
_logger.LogInformation("Found {ChannelCount} channels on the lineup on ScheduleDirect", root.Map.Count);
_logger.LogInformation("Mapping Stations to Channel");
var allStations = root.Stations;
var map = root.Map;
var list = new List<ChannelInfo>(map.Count);
foreach (var channel in map)
{
var channelNumber = GetChannelNumber(channel);
var stationIndex = allStations.FindIndex(item => string.Equals(item.StationId, channel.StationId, StringComparison.OrdinalIgnoreCase));
var station = stationIndex == -1
? new StationDto { StationId = channel.StationId }
: allStations[stationIndex];
var channelInfo = new ChannelInfo
{
Id = station.StationId,
CallSign = station.Callsign,
Number = channelNumber,
Name = string.IsNullOrWhiteSpace(station.Name) ? channelNumber : station.Name
};
if (station.Logo is not null)
{
channelInfo.ImageUrl = station.Logo.Url;
}
list.Add(channelInfo);
}
return list;
}
/// <inheritdoc />
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}
/// <summary>
/// Releases unmanaged and optionally managed resources.
/// </summary>
/// <param name="disposing"><c>true</c> to release both managed and unmanaged resources; <c>false</c> to release only unmanaged resources.</param>
protected virtual void Dispose(bool disposing)
{
if (_disposed)
{
return;
}
if (disposing)
{
_tokenSemaphore?.Dispose();
}
_disposed = true;
}
}
}

View File

@@ -1,34 +0,0 @@
using System.Text.Json.Serialization;
namespace Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos
{
/// <summary>
/// Broadcaster dto.
/// </summary>
public class BroadcasterDto
{
/// <summary>
/// Gets or sets the city.
/// </summary>
[JsonPropertyName("city")]
public string? City { get; set; }
/// <summary>
/// Gets or sets the state.
/// </summary>
[JsonPropertyName("state")]
public string? State { get; set; }
/// <summary>
/// Gets or sets the postal code.
/// </summary>
[JsonPropertyName("postalCode")]
public string? Postalcode { get; set; }
/// <summary>
/// Gets or sets the country.
/// </summary>
[JsonPropertyName("country")]
public string? Country { get; set; }
}
}

View File

@@ -1,22 +0,0 @@
using System.Text.Json.Serialization;
namespace Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos
{
/// <summary>
/// Caption dto.
/// </summary>
public class CaptionDto
{
/// <summary>
/// Gets or sets the content.
/// </summary>
[JsonPropertyName("content")]
public string? Content { get; set; }
/// <summary>
/// Gets or sets the lang.
/// </summary>
[JsonPropertyName("lang")]
public string? Lang { get; set; }
}
}

View File

@@ -1,46 +0,0 @@
using System.Text.Json.Serialization;
namespace Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos
{
/// <summary>
/// Cast dto.
/// </summary>
public class CastDto
{
/// <summary>
/// Gets or sets the billing order.
/// </summary>
[JsonPropertyName("billingOrder")]
public string? BillingOrder { get; set; }
/// <summary>
/// Gets or sets the role.
/// </summary>
[JsonPropertyName("role")]
public string? Role { get; set; }
/// <summary>
/// Gets or sets the name id.
/// </summary>
[JsonPropertyName("nameId")]
public string? NameId { get; set; }
/// <summary>
/// Gets or sets the person id.
/// </summary>
[JsonPropertyName("personId")]
public string? PersonId { get; set; }
/// <summary>
/// Gets or sets the name.
/// </summary>
[JsonPropertyName("name")]
public string? Name { get; set; }
/// <summary>
/// Gets or sets the character name.
/// </summary>
[JsonPropertyName("characterName")]
public string? CharacterName { get; set; }
}
}

View File

@@ -1,30 +0,0 @@
using System;
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos
{
/// <summary>
/// Channel dto.
/// </summary>
public class ChannelDto
{
/// <summary>
/// Gets or sets the list of maps.
/// </summary>
[JsonPropertyName("map")]
public IReadOnlyList<MapDto> Map { get; set; } = Array.Empty<MapDto>();
/// <summary>
/// Gets or sets the list of stations.
/// </summary>
[JsonPropertyName("stations")]
public IReadOnlyList<StationDto> Stations { get; set; } = Array.Empty<StationDto>();
/// <summary>
/// Gets or sets the metadata.
/// </summary>
[JsonPropertyName("metadata")]
public MetadataDto? Metadata { get; set; }
}
}

View File

@@ -1,22 +0,0 @@
using System.Text.Json.Serialization;
namespace Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos
{
/// <summary>
/// Content rating dto.
/// </summary>
public class ContentRatingDto
{
/// <summary>
/// Gets or sets the body.
/// </summary>
[JsonPropertyName("body")]
public string? Body { get; set; }
/// <summary>
/// Gets or sets the code.
/// </summary>
[JsonPropertyName("code")]
public string? Code { get; set; }
}
}

View File

@@ -1,40 +0,0 @@
using System.Text.Json.Serialization;
namespace Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos
{
/// <summary>
/// Crew dto.
/// </summary>
public class CrewDto
{
/// <summary>
/// Gets or sets the billing order.
/// </summary>
[JsonPropertyName("billingOrder")]
public string? BillingOrder { get; set; }
/// <summary>
/// Gets or sets the role.
/// </summary>
[JsonPropertyName("role")]
public string? Role { get; set; }
/// <summary>
/// Gets or sets the name id.
/// </summary>
[JsonPropertyName("nameId")]
public string? NameId { get; set; }
/// <summary>
/// Gets or sets the person id.
/// </summary>
[JsonPropertyName("personId")]
public string? PersonId { get; set; }
/// <summary>
/// Gets or sets the name.
/// </summary>
[JsonPropertyName("name")]
public string? Name { get; set; }
}
}

View File

@@ -1,30 +0,0 @@
using System;
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos
{
/// <summary>
/// Day dto.
/// </summary>
public class DayDto
{
/// <summary>
/// Gets or sets the station id.
/// </summary>
[JsonPropertyName("stationID")]
public string? StationId { get; set; }
/// <summary>
/// Gets or sets the list of programs.
/// </summary>
[JsonPropertyName("programs")]
public IReadOnlyList<ProgramDto> Programs { get; set; } = Array.Empty<ProgramDto>();
/// <summary>
/// Gets or sets the metadata schedule.
/// </summary>
[JsonPropertyName("metadata")]
public MetadataScheduleDto? Metadata { get; set; }
}
}

View File

@@ -1,22 +0,0 @@
using System.Text.Json.Serialization;
namespace Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos
{
/// <summary>
/// Description 1_000 dto.
/// </summary>
public class Description1000Dto
{
/// <summary>
/// Gets or sets the description language.
/// </summary>
[JsonPropertyName("descriptionLanguage")]
public string? DescriptionLanguage { get; set; }
/// <summary>
/// Gets or sets the description.
/// </summary>
[JsonPropertyName("description")]
public string? Description { get; set; }
}
}

View File

@@ -1,22 +0,0 @@
using System.Text.Json.Serialization;
namespace Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos
{
/// <summary>
/// Description 100 dto.
/// </summary>
public class Description100Dto
{
/// <summary>
/// Gets or sets the description language.
/// </summary>
[JsonPropertyName("descriptionLanguage")]
public string? DescriptionLanguage { get; set; }
/// <summary>
/// Gets or sets the description.
/// </summary>
[JsonPropertyName("description")]
public string? Description { get; set; }
}
}

View File

@@ -1,24 +0,0 @@
using System;
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos
{
/// <summary>
/// Descriptions program dto.
/// </summary>
public class DescriptionsProgramDto
{
/// <summary>
/// Gets or sets the list of description 100.
/// </summary>
[JsonPropertyName("description100")]
public IReadOnlyList<Description100Dto> Description100 { get; set; } = Array.Empty<Description100Dto>();
/// <summary>
/// Gets or sets the list of description1000.
/// </summary>
[JsonPropertyName("description1000")]
public IReadOnlyList<Description1000Dto> Description1000 { get; set; } = Array.Empty<Description1000Dto>();
}
}

View File

@@ -1,16 +0,0 @@
using System.Text.Json.Serialization;
namespace Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos
{
/// <summary>
/// Event details dto.
/// </summary>
public class EventDetailsDto
{
/// <summary>
/// Gets or sets the sub type.
/// </summary>
[JsonPropertyName("subType")]
public string? SubType { get; set; }
}
}

View File

@@ -1,22 +0,0 @@
using System.Text.Json.Serialization;
namespace Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos
{
/// <summary>
/// Gracenote dto.
/// </summary>
public class GracenoteDto
{
/// <summary>
/// Gets or sets the season.
/// </summary>
[JsonPropertyName("season")]
public int Season { get; set; }
/// <summary>
/// Gets or sets the episode.
/// </summary>
[JsonPropertyName("episode")]
public int Episode { get; set; }
}
}

View File

@@ -1,36 +0,0 @@
using System;
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos
{
/// <summary>
/// Headends dto.
/// </summary>
public class HeadendsDto
{
/// <summary>
/// Gets or sets the headend.
/// </summary>
[JsonPropertyName("headend")]
public string? Headend { get; set; }
/// <summary>
/// Gets or sets the transport.
/// </summary>
[JsonPropertyName("transport")]
public string? Transport { get; set; }
/// <summary>
/// Gets or sets the location.
/// </summary>
[JsonPropertyName("location")]
public string? Location { get; set; }
/// <summary>
/// Gets or sets the list of lineups.
/// </summary>
[JsonPropertyName("lineups")]
public IReadOnlyList<LineupDto> Lineups { get; set; } = Array.Empty<LineupDto>();
}
}

View File

@@ -1,70 +0,0 @@
using System.Text.Json.Serialization;
namespace Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos
{
/// <summary>
/// Image data dto.
/// </summary>
public class ImageDataDto
{
/// <summary>
/// Gets or sets the width.
/// </summary>
[JsonPropertyName("width")]
public string? Width { get; set; }
/// <summary>
/// Gets or sets the height.
/// </summary>
[JsonPropertyName("height")]
public string? Height { get; set; }
/// <summary>
/// Gets or sets the uri.
/// </summary>
[JsonPropertyName("uri")]
public string? Uri { get; set; }
/// <summary>
/// Gets or sets the size.
/// </summary>
[JsonPropertyName("size")]
public string? Size { get; set; }
/// <summary>
/// Gets or sets the aspect.
/// </summary>
[JsonPropertyName("aspect")]
public string? Aspect { get; set; }
/// <summary>
/// Gets or sets the category.
/// </summary>
[JsonPropertyName("category")]
public string? Category { get; set; }
/// <summary>
/// Gets or sets the text.
/// </summary>
[JsonPropertyName("text")]
public string? Text { get; set; }
/// <summary>
/// Gets or sets the primary.
/// </summary>
[JsonPropertyName("primary")]
public string? Primary { get; set; }
/// <summary>
/// Gets or sets the tier.
/// </summary>
[JsonPropertyName("tier")]
public string? Tier { get; set; }
/// <summary>
/// Gets or sets the caption.
/// </summary>
[JsonPropertyName("caption")]
public CaptionDto? Caption { get; set; }
}
}

View File

@@ -1,46 +0,0 @@
using System.Text.Json.Serialization;
namespace Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos
{
/// <summary>
/// The lineup dto.
/// </summary>
public class LineupDto
{
/// <summary>
/// Gets or sets the linup.
/// </summary>
[JsonPropertyName("lineup")]
public string? Lineup { get; set; }
/// <summary>
/// Gets or sets the lineup name.
/// </summary>
[JsonPropertyName("name")]
public string? Name { get; set; }
/// <summary>
/// Gets or sets the transport.
/// </summary>
[JsonPropertyName("transport")]
public string? Transport { get; set; }
/// <summary>
/// Gets or sets the location.
/// </summary>
[JsonPropertyName("location")]
public string? Location { get; set; }
/// <summary>
/// Gets or sets the uri.
/// </summary>
[JsonPropertyName("uri")]
public string? Uri { get; set; }
/// <summary>
/// Gets or sets a value indicating whether this lineup was deleted.
/// </summary>
[JsonPropertyName("isDeleted")]
public bool? IsDeleted { get; set; }
}
}

View File

@@ -1,36 +0,0 @@
using System;
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos
{
/// <summary>
/// Lineups dto.
/// </summary>
public class LineupsDto
{
/// <summary>
/// Gets or sets the response code.
/// </summary>
[JsonPropertyName("code")]
public int Code { get; set; }
/// <summary>
/// Gets or sets the server id.
/// </summary>
[JsonPropertyName("serverID")]
public string? ServerId { get; set; }
/// <summary>
/// Gets or sets the datetime.
/// </summary>
[JsonPropertyName("datetime")]
public DateTime? LineupTimestamp { get; set; }
/// <summary>
/// Gets or sets the list of lineups.
/// </summary>
[JsonPropertyName("lineups")]
public IReadOnlyList<LineupDto> Lineups { get; set; } = Array.Empty<LineupDto>();
}
}

View File

@@ -1,34 +0,0 @@
using System.Text.Json.Serialization;
namespace Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos
{
/// <summary>
/// Logo dto.
/// </summary>
public class LogoDto
{
/// <summary>
/// Gets or sets the url.
/// </summary>
[JsonPropertyName("URL")]
public string? Url { get; set; }
/// <summary>
/// Gets or sets the height.
/// </summary>
[JsonPropertyName("height")]
public int Height { get; set; }
/// <summary>
/// Gets or sets the width.
/// </summary>
[JsonPropertyName("width")]
public int Width { get; set; }
/// <summary>
/// Gets or sets the md5.
/// </summary>
[JsonPropertyName("md5")]
public string? Md5 { get; set; }
}
}

View File

@@ -1,58 +0,0 @@
using System.Text.Json.Serialization;
namespace Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos
{
/// <summary>
/// Map dto.
/// </summary>
public class MapDto
{
/// <summary>
/// Gets or sets the station id.
/// </summary>
[JsonPropertyName("stationID")]
public string? StationId { get; set; }
/// <summary>
/// Gets or sets the channel.
/// </summary>
[JsonPropertyName("channel")]
public string? Channel { get; set; }
/// <summary>
/// Gets or sets the provider callsign.
/// </summary>
[JsonPropertyName("providerCallsign")]
public string? ProvderCallsign { get; set; }
/// <summary>
/// Gets or sets the logical channel number.
/// </summary>
[JsonPropertyName("logicalChannelNumber")]
public string? LogicalChannelNumber { get; set; }
/// <summary>
/// Gets or sets the uhfvhf.
/// </summary>
[JsonPropertyName("uhfVhf")]
public int UhfVhf { get; set; }
/// <summary>
/// Gets or sets the atsc major.
/// </summary>
[JsonPropertyName("atscMajor")]
public int AtscMajor { get; set; }
/// <summary>
/// Gets or sets the atsc minor.
/// </summary>
[JsonPropertyName("atscMinor")]
public int AtscMinor { get; set; }
/// <summary>
/// Gets or sets the match type.
/// </summary>
[JsonPropertyName("matchType")]
public string? MatchType { get; set; }
}
}

View File

@@ -1,28 +0,0 @@
using System.Text.Json.Serialization;
namespace Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos
{
/// <summary>
/// Metadata dto.
/// </summary>
public class MetadataDto
{
/// <summary>
/// Gets or sets the linup.
/// </summary>
[JsonPropertyName("lineup")]
public string? Lineup { get; set; }
/// <summary>
/// Gets or sets the modified timestamp.
/// </summary>
[JsonPropertyName("modified")]
public string? Modified { get; set; }
/// <summary>
/// Gets or sets the transport.
/// </summary>
[JsonPropertyName("transport")]
public string? Transport { get; set; }
}
}

View File

@@ -1,16 +0,0 @@
using System.Text.Json.Serialization;
namespace Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos
{
/// <summary>
/// Metadata programs dto.
/// </summary>
public class MetadataProgramsDto
{
/// <summary>
/// Gets or sets the gracenote object.
/// </summary>
[JsonPropertyName("Gracenote")]
public GracenoteDto? Gracenote { get; set; }
}
}

View File

@@ -1,41 +0,0 @@
using System;
using System.Text.Json.Serialization;
namespace Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos
{
/// <summary>
/// Metadata schedule dto.
/// </summary>
public class MetadataScheduleDto
{
/// <summary>
/// Gets or sets the modified timestamp.
/// </summary>
[JsonPropertyName("modified")]
public string? Modified { get; set; }
/// <summary>
/// Gets or sets the md5.
/// </summary>
[JsonPropertyName("md5")]
public string? Md5 { get; set; }
/// <summary>
/// Gets or sets the start date.
/// </summary>
[JsonPropertyName("startDate")]
public DateTime? StartDate { get; set; }
/// <summary>
/// Gets or sets the end date.
/// </summary>
[JsonPropertyName("endDate")]
public DateTime? EndDate { get; set; }
/// <summary>
/// Gets or sets the days count.
/// </summary>
[JsonPropertyName("days")]
public int Days { get; set; }
}
}

View File

@@ -1,30 +0,0 @@
using System;
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos
{
/// <summary>
/// Movie dto.
/// </summary>
public class MovieDto
{
/// <summary>
/// Gets or sets the year.
/// </summary>
[JsonPropertyName("year")]
public string? Year { get; set; }
/// <summary>
/// Gets or sets the duration.
/// </summary>
[JsonPropertyName("duration")]
public int Duration { get; set; }
/// <summary>
/// Gets or sets the list of quality rating.
/// </summary>
[JsonPropertyName("qualityRating")]
public IReadOnlyList<QualityRatingDto> QualityRating { get; set; } = Array.Empty<QualityRatingDto>();
}
}

View File

@@ -1,22 +0,0 @@
using System.Text.Json.Serialization;
namespace Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos
{
/// <summary>
/// Multipart dto.
/// </summary>
public class MultipartDto
{
/// <summary>
/// Gets or sets the part number.
/// </summary>
[JsonPropertyName("partNumber")]
public int PartNumber { get; set; }
/// <summary>
/// Gets or sets the total parts.
/// </summary>
[JsonPropertyName("totalParts")]
public int TotalParts { get; set; }
}
}

View File

@@ -1,156 +0,0 @@
using System;
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos
{
/// <summary>
/// Program details dto.
/// </summary>
public class ProgramDetailsDto
{
/// <summary>
/// Gets or sets the audience.
/// </summary>
[JsonPropertyName("audience")]
public string? Audience { get; set; }
/// <summary>
/// Gets or sets the program id.
/// </summary>
[JsonPropertyName("programID")]
public string? ProgramId { get; set; }
/// <summary>
/// Gets or sets the list of titles.
/// </summary>
[JsonPropertyName("titles")]
public IReadOnlyList<TitleDto> Titles { get; set; } = Array.Empty<TitleDto>();
/// <summary>
/// Gets or sets the event details object.
/// </summary>
[JsonPropertyName("eventDetails")]
public EventDetailsDto? EventDetails { get; set; }
/// <summary>
/// Gets or sets the descriptions.
/// </summary>
[JsonPropertyName("descriptions")]
public DescriptionsProgramDto? Descriptions { get; set; }
/// <summary>
/// Gets or sets the original air date.
/// </summary>
[JsonPropertyName("originalAirDate")]
public DateTime? OriginalAirDate { get; set; }
/// <summary>
/// Gets or sets the list of genres.
/// </summary>
[JsonPropertyName("genres")]
public IReadOnlyList<string> Genres { get; set; } = Array.Empty<string>();
/// <summary>
/// Gets or sets the episode title.
/// </summary>
[JsonPropertyName("episodeTitle150")]
public string? EpisodeTitle150 { get; set; }
/// <summary>
/// Gets or sets the list of metadata.
/// </summary>
[JsonPropertyName("metadata")]
public IReadOnlyList<MetadataProgramsDto> Metadata { get; set; } = Array.Empty<MetadataProgramsDto>();
/// <summary>
/// Gets or sets the list of content raitings.
/// </summary>
[JsonPropertyName("contentRating")]
public IReadOnlyList<ContentRatingDto> ContentRating { get; set; } = Array.Empty<ContentRatingDto>();
/// <summary>
/// Gets or sets the list of cast.
/// </summary>
[JsonPropertyName("cast")]
public IReadOnlyList<CastDto> Cast { get; set; } = Array.Empty<CastDto>();
/// <summary>
/// Gets or sets the list of crew.
/// </summary>
[JsonPropertyName("crew")]
public IReadOnlyList<CrewDto> Crew { get; set; } = Array.Empty<CrewDto>();
/// <summary>
/// Gets or sets the entity type.
/// </summary>
[JsonPropertyName("entityType")]
public string? EntityType { get; set; }
/// <summary>
/// Gets or sets the show type.
/// </summary>
[JsonPropertyName("showType")]
public string? ShowType { get; set; }
/// <summary>
/// Gets or sets a value indicating whether there is image artwork.
/// </summary>
[JsonPropertyName("hasImageArtwork")]
public bool HasImageArtwork { get; set; }
/// <summary>
/// Gets or sets the primary image.
/// </summary>
[JsonPropertyName("primaryImage")]
public string? PrimaryImage { get; set; }
/// <summary>
/// Gets or sets the thumb image.
/// </summary>
[JsonPropertyName("thumbImage")]
public string? ThumbImage { get; set; }
/// <summary>
/// Gets or sets the backdrop image.
/// </summary>
[JsonPropertyName("backdropImage")]
public string? BackdropImage { get; set; }
/// <summary>
/// Gets or sets the banner image.
/// </summary>
[JsonPropertyName("bannerImage")]
public string? BannerImage { get; set; }
/// <summary>
/// Gets or sets the image id.
/// </summary>
[JsonPropertyName("imageID")]
public string? ImageId { get; set; }
/// <summary>
/// Gets or sets the md5.
/// </summary>
[JsonPropertyName("md5")]
public string? Md5 { get; set; }
/// <summary>
/// Gets or sets the list of content advisory.
/// </summary>
[JsonPropertyName("contentAdvisory")]
public IReadOnlyList<string> ContentAdvisory { get; set; } = Array.Empty<string>();
/// <summary>
/// Gets or sets the movie object.
/// </summary>
[JsonPropertyName("movie")]
public MovieDto? Movie { get; set; }
/// <summary>
/// Gets or sets the list of recommendations.
/// </summary>
[JsonPropertyName("recommendations")]
public IReadOnlyList<RecommendationDto> Recommendations { get; set; } = Array.Empty<RecommendationDto>();
}
}

View File

@@ -1,90 +0,0 @@
using System;
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos
{
/// <summary>
/// Program dto.
/// </summary>
public class ProgramDto
{
/// <summary>
/// Gets or sets the program id.
/// </summary>
[JsonPropertyName("programID")]
public string? ProgramId { get; set; }
/// <summary>
/// Gets or sets the air date time.
/// </summary>
[JsonPropertyName("airDateTime")]
public DateTime? AirDateTime { get; set; }
/// <summary>
/// Gets or sets the duration.
/// </summary>
[JsonPropertyName("duration")]
public int Duration { get; set; }
/// <summary>
/// Gets or sets the md5.
/// </summary>
[JsonPropertyName("md5")]
public string? Md5 { get; set; }
/// <summary>
/// Gets or sets the list of audio properties.
/// </summary>
[JsonPropertyName("audioProperties")]
public IReadOnlyList<string> AudioProperties { get; set; } = Array.Empty<string>();
/// <summary>
/// Gets or sets the list of video properties.
/// </summary>
[JsonPropertyName("videoProperties")]
public IReadOnlyList<string> VideoProperties { get; set; } = Array.Empty<string>();
/// <summary>
/// Gets or sets the list of ratings.
/// </summary>
[JsonPropertyName("ratings")]
public IReadOnlyList<RatingDto> Ratings { get; set; } = Array.Empty<RatingDto>();
/// <summary>
/// Gets or sets a value indicating whether this program is new.
/// </summary>
[JsonPropertyName("new")]
public bool? New { get; set; }
/// <summary>
/// Gets or sets the multipart object.
/// </summary>
[JsonPropertyName("multipart")]
public MultipartDto? Multipart { get; set; }
/// <summary>
/// Gets or sets the live tape delay.
/// </summary>
[JsonPropertyName("liveTapeDelay")]
public string? LiveTapeDelay { get; set; }
/// <summary>
/// Gets or sets a value indicating whether this is the premiere.
/// </summary>
[JsonPropertyName("premiere")]
public bool Premiere { get; set; }
/// <summary>
/// Gets or sets a value indicating whether this is a repeat.
/// </summary>
[JsonPropertyName("repeat")]
public bool Repeat { get; set; }
/// <summary>
/// Gets or sets the premiere or finale.
/// </summary>
[JsonPropertyName("isPremiereOrFinale")]
public string? IsPremiereOrFinale { get; set; }
}
}

View File

@@ -1,40 +0,0 @@
using System.Text.Json.Serialization;
namespace Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos
{
/// <summary>
/// Quality rating dto.
/// </summary>
public class QualityRatingDto
{
/// <summary>
/// Gets or sets the ratings body.
/// </summary>
[JsonPropertyName("ratingsBody")]
public string? RatingsBody { get; set; }
/// <summary>
/// Gets or sets the rating.
/// </summary>
[JsonPropertyName("rating")]
public string? Rating { get; set; }
/// <summary>
/// Gets or sets the min rating.
/// </summary>
[JsonPropertyName("minRating")]
public string? MinRating { get; set; }
/// <summary>
/// Gets or sets the max rating.
/// </summary>
[JsonPropertyName("maxRating")]
public string? MaxRating { get; set; }
/// <summary>
/// Gets or sets the increment.
/// </summary>
[JsonPropertyName("increment")]
public string? Increment { get; set; }
}
}

View File

@@ -1,22 +0,0 @@
using System.Text.Json.Serialization;
namespace Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos
{
/// <summary>
/// Rating dto.
/// </summary>
public class RatingDto
{
/// <summary>
/// Gets or sets the body.
/// </summary>
[JsonPropertyName("body")]
public string? Body { get; set; }
/// <summary>
/// Gets or sets the code.
/// </summary>
[JsonPropertyName("code")]
public string? Code { get; set; }
}
}

View File

@@ -1,22 +0,0 @@
using System.Text.Json.Serialization;
namespace Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos
{
/// <summary>
/// Recommendation dto.
/// </summary>
public class RecommendationDto
{
/// <summary>
/// Gets or sets the program id.
/// </summary>
[JsonPropertyName("programID")]
public string? ProgramId { get; set; }
/// <summary>
/// Gets or sets the title.
/// </summary>
[JsonPropertyName("title120")]
public string? Title120 { get; set; }
}
}

View File

@@ -1,24 +0,0 @@
using System;
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos
{
/// <summary>
/// Request schedule for channel dto.
/// </summary>
public class RequestScheduleForChannelDto
{
/// <summary>
/// Gets or sets the station id.
/// </summary>
[JsonPropertyName("stationID")]
public string? StationId { get; set; }
/// <summary>
/// Gets or sets the list of dates.
/// </summary>
[JsonPropertyName("date")]
public IReadOnlyList<string> Date { get; set; } = Array.Empty<string>();
}
}

View File

@@ -1,24 +0,0 @@
using System;
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos
{
/// <summary>
/// Show image dto.
/// </summary>
public class ShowImagesDto
{
/// <summary>
/// Gets or sets the program id.
/// </summary>
[JsonPropertyName("programID")]
public string? ProgramId { get; set; }
/// <summary>
/// Gets or sets the list of data.
/// </summary>
[JsonPropertyName("data")]
public IReadOnlyList<ImageDataDto> Data { get; set; } = Array.Empty<ImageDataDto>();
}
}

View File

@@ -1,66 +0,0 @@
using System;
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos
{
/// <summary>
/// Station dto.
/// </summary>
public class StationDto
{
/// <summary>
/// Gets or sets the station id.
/// </summary>
[JsonPropertyName("stationID")]
public string? StationId { get; set; }
/// <summary>
/// Gets or sets the name.
/// </summary>
[JsonPropertyName("name")]
public string? Name { get; set; }
/// <summary>
/// Gets or sets the callsign.
/// </summary>
[JsonPropertyName("callsign")]
public string? Callsign { get; set; }
/// <summary>
/// Gets or sets the broadcast language.
/// </summary>
[JsonPropertyName("broadcastLanguage")]
public IReadOnlyList<string> BroadcastLanguage { get; set; } = Array.Empty<string>();
/// <summary>
/// Gets or sets the description language.
/// </summary>
[JsonPropertyName("descriptionLanguage")]
public IReadOnlyList<string> DescriptionLanguage { get; set; } = Array.Empty<string>();
/// <summary>
/// Gets or sets the broadcaster.
/// </summary>
[JsonPropertyName("broadcaster")]
public BroadcasterDto? Broadcaster { get; set; }
/// <summary>
/// Gets or sets the affiliate.
/// </summary>
[JsonPropertyName("affiliate")]
public string? Affiliate { get; set; }
/// <summary>
/// Gets or sets the logo.
/// </summary>
[JsonPropertyName("logo")]
public LogoDto? Logo { get; set; }
/// <summary>
/// Gets or sets a value indicating whether it is commercial free.
/// </summary>
[JsonPropertyName("isCommercialFree")]
public bool? IsCommercialFree { get; set; }
}
}

View File

@@ -1,16 +0,0 @@
using System.Text.Json.Serialization;
namespace Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos
{
/// <summary>
/// Title dto.
/// </summary>
public class TitleDto
{
/// <summary>
/// Gets or sets the title.
/// </summary>
[JsonPropertyName("title120")]
public string? Title120 { get; set; }
}
}

View File

@@ -1,47 +0,0 @@
using System;
using System.Text.Json.Serialization;
namespace Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos
{
/// <summary>
/// The token dto.
/// </summary>
public class TokenDto
{
/// <summary>
/// Gets or sets the response code.
/// </summary>
[JsonPropertyName("code")]
public int Code { get; set; }
/// <summary>
/// Gets or sets the response message.
/// </summary>
[JsonPropertyName("message")]
public string? Message { get; set; }
/// <summary>
/// Gets or sets the server id.
/// </summary>
[JsonPropertyName("serverID")]
public string? ServerId { get; set; }
/// <summary>
/// Gets or sets the token.
/// </summary>
[JsonPropertyName("token")]
public string? Token { get; set; }
/// <summary>
/// Gets or sets the current datetime.
/// </summary>
[JsonPropertyName("datetime")]
public DateTime? TokenTimestamp { get; set; }
/// <summary>
/// Gets or sets the response message.
/// </summary>
[JsonPropertyName("response")]
public string? Response { get; set; }
}
}

View File

@@ -1,267 +0,0 @@
#nullable disable
#pragma warning disable CS1591
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.IO.Compression;
using System.Linq;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Extensions;
using Jellyfin.XmlTv;
using Jellyfin.XmlTv.Entities;
using MediaBrowser.Common.Extensions;
using MediaBrowser.Common.Net;
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.LiveTv;
using MediaBrowser.Model.Dto;
using MediaBrowser.Model.IO;
using MediaBrowser.Model.LiveTv;
using Microsoft.Extensions.Logging;
namespace Emby.Server.Implementations.LiveTv.Listings
{
public class XmlTvListingsProvider : IListingsProvider
{
private static readonly TimeSpan _maxCacheAge = TimeSpan.FromHours(1);
private readonly IServerConfigurationManager _config;
private readonly IHttpClientFactory _httpClientFactory;
private readonly ILogger<XmlTvListingsProvider> _logger;
public XmlTvListingsProvider(
IServerConfigurationManager config,
IHttpClientFactory httpClientFactory,
ILogger<XmlTvListingsProvider> logger)
{
_config = config;
_httpClientFactory = httpClientFactory;
_logger = logger;
}
public string Name => "XmlTV";
public string Type => "xmltv";
private string GetLanguage(ListingsProviderInfo info)
{
if (!string.IsNullOrWhiteSpace(info.PreferredLanguage))
{
return info.PreferredLanguage;
}
return _config.Configuration.PreferredMetadataLanguage;
}
private async Task<string> GetXml(ListingsProviderInfo info, CancellationToken cancellationToken)
{
_logger.LogInformation("xmltv path: {Path}", info.Path);
string cacheFilename = info.Id + ".xml";
string cacheFile = Path.Combine(_config.ApplicationPaths.CachePath, "xmltv", cacheFilename);
if (File.Exists(cacheFile) && File.GetLastWriteTimeUtc(cacheFile) >= DateTime.UtcNow.Subtract(_maxCacheAge))
{
return cacheFile;
}
// Must check if file exists as parent directory may not exist.
if (File.Exists(cacheFile))
{
File.Delete(cacheFile);
}
else
{
Directory.CreateDirectory(Path.GetDirectoryName(cacheFile));
}
if (info.Path.StartsWith("http", StringComparison.OrdinalIgnoreCase))
{
_logger.LogInformation("Downloading xmltv listings from {Path}", info.Path);
using var response = await _httpClientFactory.CreateClient(NamedClient.Default).GetAsync(info.Path, cancellationToken).ConfigureAwait(false);
var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
await using (stream.ConfigureAwait(false))
{
return await UnzipIfNeededAndCopy(info.Path, stream, cacheFile, cancellationToken).ConfigureAwait(false);
}
}
else
{
var stream = AsyncFile.OpenRead(info.Path);
await using (stream.ConfigureAwait(false))
{
return await UnzipIfNeededAndCopy(info.Path, stream, cacheFile, cancellationToken).ConfigureAwait(false);
}
}
}
private async Task<string> UnzipIfNeededAndCopy(string originalUrl, Stream stream, string file, CancellationToken cancellationToken)
{
var fileStream = new FileStream(
file,
FileMode.CreateNew,
FileAccess.Write,
FileShare.None,
IODefaults.FileStreamBufferSize,
FileOptions.Asynchronous);
await using (fileStream.ConfigureAwait(false))
{
if (Path.GetExtension(originalUrl.AsSpan().LeftPart('?')).Equals(".gz", StringComparison.OrdinalIgnoreCase))
{
try
{
using var reader = new GZipStream(stream, CompressionMode.Decompress);
await reader.CopyToAsync(fileStream, cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error extracting from gz file {File}", originalUrl);
}
}
else
{
await stream.CopyToAsync(fileStream, cancellationToken).ConfigureAwait(false);
}
return file;
}
}
public async Task<IEnumerable<ProgramInfo>> GetProgramsAsync(ListingsProviderInfo info, string channelId, DateTime startDateUtc, DateTime endDateUtc, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(channelId))
{
throw new ArgumentNullException(nameof(channelId));
}
_logger.LogDebug("Getting xmltv programs for channel {Id}", channelId);
string path = await GetXml(info, cancellationToken).ConfigureAwait(false);
_logger.LogDebug("Opening XmlTvReader for {Path}", path);
var reader = new XmlTvReader(path, GetLanguage(info));
return reader.GetProgrammes(channelId, startDateUtc, endDateUtc, cancellationToken)
.Select(p => GetProgramInfo(p, info));
}
private static ProgramInfo GetProgramInfo(XmlTvProgram program, ListingsProviderInfo info)
{
string episodeTitle = program.Episode.Title;
var programCategories = program.Categories.Where(c => !string.IsNullOrWhiteSpace(c)).ToList();
var programInfo = new ProgramInfo
{
ChannelId = program.ChannelId,
EndDate = program.EndDate.UtcDateTime,
EpisodeNumber = program.Episode.Episode,
EpisodeTitle = episodeTitle,
Genres = programCategories,
StartDate = program.StartDate.UtcDateTime,
Name = program.Title,
Overview = program.Description,
ProductionYear = program.CopyrightDate?.Year,
SeasonNumber = program.Episode.Series,
IsSeries = program.Episode.Series is not null,
IsRepeat = program.IsPreviouslyShown && !program.IsNew,
IsPremiere = program.Premiere is not null,
IsKids = programCategories.Any(c => info.KidsCategories.Contains(c, StringComparison.OrdinalIgnoreCase)),
IsMovie = programCategories.Any(c => info.MovieCategories.Contains(c, StringComparison.OrdinalIgnoreCase)),
IsNews = programCategories.Any(c => info.NewsCategories.Contains(c, StringComparison.OrdinalIgnoreCase)),
IsSports = programCategories.Any(c => info.SportsCategories.Contains(c, StringComparison.OrdinalIgnoreCase)),
ImageUrl = string.IsNullOrEmpty(program.Icon?.Source) ? null : program.Icon.Source,
HasImage = !string.IsNullOrEmpty(program.Icon?.Source),
OfficialRating = string.IsNullOrEmpty(program.Rating?.Value) ? null : program.Rating.Value,
CommunityRating = program.StarRating,
SeriesId = program.Episode.Episode is null ? null : program.Title?.GetMD5().ToString("N", CultureInfo.InvariantCulture)
};
if (string.IsNullOrWhiteSpace(program.ProgramId))
{
string uniqueString = (program.Title ?? string.Empty) + (episodeTitle ?? string.Empty);
if (programInfo.SeasonNumber.HasValue)
{
uniqueString = "-" + programInfo.SeasonNumber.Value.ToString(CultureInfo.InvariantCulture);
}
if (programInfo.EpisodeNumber.HasValue)
{
uniqueString = "-" + programInfo.EpisodeNumber.Value.ToString(CultureInfo.InvariantCulture);
}
programInfo.ShowId = uniqueString.GetMD5().ToString("N", CultureInfo.InvariantCulture);
// If we don't have valid episode info, assume it's a unique program, otherwise recordings might be skipped
if (programInfo.IsSeries
&& !programInfo.IsRepeat
&& (programInfo.EpisodeNumber ?? 0) == 0)
{
programInfo.ShowId += programInfo.StartDate.Ticks.ToString(CultureInfo.InvariantCulture);
}
}
else
{
programInfo.ShowId = program.ProgramId;
}
// Construct an id from the channel and start date
programInfo.Id = string.Format(CultureInfo.InvariantCulture, "{0}_{1:O}", program.ChannelId, program.StartDate);
if (programInfo.IsMovie)
{
programInfo.IsSeries = false;
programInfo.EpisodeNumber = null;
programInfo.EpisodeTitle = null;
}
return programInfo;
}
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) && !File.Exists(info.Path))
{
throw new FileNotFoundException("Could not find the XmlTv file specified:", info.Path);
}
return Task.CompletedTask;
}
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
string path = await GetXml(info, CancellationToken.None).ConfigureAwait(false);
_logger.LogDebug("Opening XmlTvReader for {Path}", path);
var reader = new XmlTvReader(path, GetLanguage(info));
IEnumerable<XmlTvChannel> 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
string path = await GetXml(info, cancellationToken).ConfigureAwait(false);
_logger.LogDebug("Opening XmlTvReader for {Path}", path);
var reader = new XmlTvReader(path, GetLanguage(info));
var results = reader.GetChannels();
// Should this method be async?
return results.Select(c => new ChannelInfo
{
Id = c.Id,
Name = c.DisplayName,
ImageUrl = string.IsNullOrEmpty(c.Icon?.Source) ? null : c.Icon.Source,
Number = string.IsNullOrWhiteSpace(c.Number) ? c.Id : c.Number
}).ToList();
}
}
}

View File

@@ -1,25 +0,0 @@
using System.Collections.Generic;
using MediaBrowser.Common.Configuration;
using MediaBrowser.Model.LiveTv;
namespace Emby.Server.Implementations.LiveTv
{
/// <summary>
/// <see cref="IConfigurationFactory" /> implementation for <see cref="LiveTvOptions" />.
/// </summary>
public class LiveTvConfigurationFactory : IConfigurationFactory
{
/// <inheritdoc />
public IEnumerable<ConfigurationStore> GetConfigurations()
{
return new ConfigurationStore[]
{
new ConfigurationStore
{
ConfigurationType = typeof(LiveTvOptions),
Key = "livetv"
}
};
}
}
}

View File

@@ -1,548 +0,0 @@
#nullable disable
#pragma warning disable CS1591
using System;
using System.Globalization;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Data.Enums;
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.Dto;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.LiveTv;
using Microsoft.Extensions.Logging;
namespace Emby.Server.Implementations.LiveTv
{
public class LiveTvDtoService
{
private const string InternalVersionNumber = "4";
private const string ServiceName = "Emby";
private readonly ILogger<LiveTvDtoService> _logger;
private readonly IImageProcessor _imageProcessor;
private readonly IDtoService _dtoService;
private readonly IApplicationHost _appHost;
private readonly ILibraryManager _libraryManager;
public LiveTvDtoService(
IDtoService dtoService,
IImageProcessor imageProcessor,
ILogger<LiveTvDtoService> logger,
IApplicationHost appHost,
ILibraryManager libraryManager)
{
_dtoService = dtoService;
_imageProcessor = imageProcessor;
_logger = logger;
_appHost = appHost;
_libraryManager = libraryManager;
}
public TimerInfoDto GetTimerInfoDto(TimerInfo info, ILiveTvService service, LiveTvProgram program, BaseItem channel)
{
var dto = new TimerInfoDto
{
Id = GetInternalTimerId(info.Id),
Overview = info.Overview,
EndDate = info.EndDate,
Name = info.Name,
StartDate = info.StartDate,
ExternalId = info.Id,
ChannelId = GetInternalChannelId(service.Name, info.ChannelId),
Status = info.Status,
SeriesTimerId = string.IsNullOrEmpty(info.SeriesTimerId) ? null : GetInternalSeriesTimerId(info.SeriesTimerId).ToString("N", CultureInfo.InvariantCulture),
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(info.ProgramId).ToString("N", CultureInfo.InvariantCulture);
}
if (program is not 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 (!string.IsNullOrEmpty(info.SeriesTimerId))
{
FillImages(dto.ProgramInfo, info.Name, info.SeriesId);
}
}
if (channel is not null)
{
dto.ChannelName = channel.Name;
if (channel.HasImage(ImageType.Primary))
{
dto.ChannelPrimaryImageTag = GetImageTag(channel);
}
}
return dto;
}
public SeriesTimerInfoDto GetSeriesTimerInfoDto(SeriesTimerInfo info, ILiveTvService service, string channelName)
{
var dto = new SeriesTimerInfoDto
{
Id = GetInternalSeriesTimerId(info.Id).ToString("N", CultureInfo.InvariantCulture),
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.ToArray(),
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);
}
if (!string.IsNullOrEmpty(info.ProgramId))
{
dto.ProgramId = GetInternalProgramId(info.ProgramId).ToString("N", CultureInfo.InvariantCulture);
}
dto.DayPattern = info.Days is null ? null : GetDayPattern(info.Days.ToArray());
FillImages(dto, info.Name, info.SeriesId);
return dto;
}
private void FillImages(BaseItemDto dto, string seriesName, string programSeriesId)
{
var librarySeries = _libraryManager.GetItemList(new InternalItemsQuery
{
IncludeItemTypes = new[] { BaseItemKind.Series },
Name = seriesName,
Limit = 1,
ImageTypes = new ImageType[] { ImageType.Thumb },
DtoOptions = new DtoOptions(false)
}).FirstOrDefault();
if (librarySeries is not null)
{
var image = librarySeries.GetImageInfo(ImageType.Thumb, 0);
if (image is not null)
{
try
{
dto.ParentThumbImageTag = _imageProcessor.GetImageCacheTag(librarySeries, image);
dto.ParentThumbItemId = librarySeries.Id;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error");
}
}
image = librarySeries.GetImageInfo(ImageType.Backdrop, 0);
if (image is not null)
{
try
{
dto.ParentBackdropImageTags = new string[]
{
_imageProcessor.GetImageCacheTag(librarySeries, image)
};
dto.ParentBackdropItemId = librarySeries.Id;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error");
}
}
}
var program = _libraryManager.GetItemList(new InternalItemsQuery
{
IncludeItemTypes = new[] { BaseItemKind.LiveTvProgram },
ExternalSeriesId = programSeriesId,
Limit = 1,
ImageTypes = new ImageType[] { ImageType.Primary },
DtoOptions = new DtoOptions(false),
Name = string.IsNullOrEmpty(programSeriesId) ? seriesName : null
}).FirstOrDefault();
if (program is not null)
{
var image = program.GetImageInfo(ImageType.Primary, 0);
if (image is not null)
{
try
{
dto.ParentPrimaryImageTag = _imageProcessor.GetImageCacheTag(program, image);
dto.ParentPrimaryImageItemId = program.Id.ToString("N", CultureInfo.InvariantCulture);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error");
}
}
if (dto.ParentBackdropImageTags is null || dto.ParentBackdropImageTags.Length == 0)
{
image = program.GetImageInfo(ImageType.Backdrop, 0);
if (image is not null)
{
try
{
dto.ParentBackdropImageTags = new string[]
{
_imageProcessor.GetImageCacheTag(program, image)
};
dto.ParentBackdropItemId = program.Id;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error");
}
}
}
}
}
private void FillImages(SeriesTimerInfoDto dto, string seriesName, string programSeriesId)
{
var librarySeries = _libraryManager.GetItemList(new InternalItemsQuery
{
IncludeItemTypes = new[] { BaseItemKind.Series },
Name = seriesName,
Limit = 1,
ImageTypes = new ImageType[] { ImageType.Thumb },
DtoOptions = new DtoOptions(false)
}).FirstOrDefault();
if (librarySeries is not null)
{
var image = librarySeries.GetImageInfo(ImageType.Thumb, 0);
if (image is not null)
{
try
{
dto.ParentThumbImageTag = _imageProcessor.GetImageCacheTag(librarySeries, image);
dto.ParentThumbItemId = librarySeries.Id.ToString("N", CultureInfo.InvariantCulture);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error");
}
}
image = librarySeries.GetImageInfo(ImageType.Backdrop, 0);
if (image is not null)
{
try
{
dto.ParentBackdropImageTags = new string[]
{
_imageProcessor.GetImageCacheTag(librarySeries, image)
};
dto.ParentBackdropItemId = librarySeries.Id.ToString("N", CultureInfo.InvariantCulture);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error");
}
}
}
var program = _libraryManager.GetItemList(new InternalItemsQuery
{
IncludeItemTypes = new[] { BaseItemKind.Series },
Name = seriesName,
Limit = 1,
ImageTypes = new ImageType[] { ImageType.Primary },
DtoOptions = new DtoOptions(false)
}).FirstOrDefault();
if (program is null)
{
program = _libraryManager.GetItemList(new InternalItemsQuery
{
IncludeItemTypes = new[] { BaseItemKind.LiveTvProgram },
ExternalSeriesId = programSeriesId,
Limit = 1,
ImageTypes = new ImageType[] { ImageType.Primary },
DtoOptions = new DtoOptions(false),
Name = string.IsNullOrEmpty(programSeriesId) ? seriesName : null
}).FirstOrDefault();
}
if (program is not null)
{
var image = program.GetImageInfo(ImageType.Primary, 0);
if (image is not null)
{
try
{
dto.ParentPrimaryImageTag = _imageProcessor.GetImageCacheTag(program, image);
dto.ParentPrimaryImageItemId = program.Id.ToString("N", CultureInfo.InvariantCulture);
}
catch (Exception ex)
{
_logger.LogDebug(ex, "GetImageCacheTag raised an exception in LiveTvDtoService.FillImages.");
}
}
if (dto.ParentBackdropImageTags is null || dto.ParentBackdropImageTags.Length == 0)
{
image = program.GetImageInfo(ImageType.Backdrop, 0);
if (image is not null)
{
try
{
dto.ParentBackdropImageTags = new[]
{
_imageProcessor.GetImageCacheTag(program, image)
};
dto.ParentBackdropItemId = program.Id.ToString("N", CultureInfo.InvariantCulture);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error");
}
}
}
}
}
public DayPattern? GetDayPattern(DayOfWeek[] days)
{
DayPattern? pattern = null;
if (days.Length > 0)
{
if (days.Length == 7)
{
pattern = DayPattern.Daily;
}
else if (days.Length == 2)
{
if (days.Contains(DayOfWeek.Saturday) && days.Contains(DayOfWeek.Sunday))
{
pattern = DayPattern.Weekends;
}
}
else if (days.Length == 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;
}
internal string GetImageTag(BaseItem info)
{
try
{
return _imageProcessor.GetImageCacheTag(info, ImageType.Primary);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error getting image info for {Name}", info.Name);
}
return null;
}
public Guid GetInternalChannelId(string serviceName, string externalId)
{
var name = serviceName + externalId + InternalVersionNumber;
return _libraryManager.GetNewItemId(name.ToLowerInvariant(), typeof(LiveTvChannel));
}
public string GetInternalTimerId(string externalId)
{
var name = ServiceName + externalId + InternalVersionNumber;
return name.ToLowerInvariant().GetMD5().ToString("N", CultureInfo.InvariantCulture);
}
public Guid GetInternalSeriesTimerId(string externalId)
{
var name = ServiceName + externalId + InternalVersionNumber;
return name.ToLowerInvariant().GetMD5();
}
public Guid GetInternalProgramId(string externalId)
{
var name = ServiceName + externalId + InternalVersionNumber;
return _libraryManager.GetNewItemId(name.ToLowerInvariant(), typeof(LiveTvProgram));
}
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 (!dto.ChannelId.Equals(default) && string.IsNullOrEmpty(info.ChannelId))
{
var channel = _libraryManager.GetItemById(dto.ChannelId);
if (channel is not null)
{
info.ChannelId = channel.ExternalId;
}
}
if (!string.IsNullOrEmpty(dto.ProgramId) && string.IsNullOrEmpty(info.ProgramId))
{
var program = _libraryManager.GetItemById(dto.ProgramId);
if (program is not 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 is not 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.ToList(),
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 (!dto.ChannelId.Equals(default) && string.IsNullOrEmpty(info.ChannelId))
{
var channel = _libraryManager.GetItemById(dto.ChannelId);
if (channel is not null)
{
info.ChannelId = channel.ExternalId;
}
}
if (!string.IsNullOrEmpty(dto.ProgramId) && string.IsNullOrEmpty(info.ProgramId))
{
var program = _libraryManager.GetItemById(dto.ProgramId);
if (program is not null)
{
info.ProgramId = program.ExternalId;
}
}
return info;
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,128 +0,0 @@
#nullable disable
#pragma warning disable CS1591
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using MediaBrowser.Controller;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.LiveTv;
using MediaBrowser.Model.Dto;
using MediaBrowser.Model.MediaInfo;
using Microsoft.Extensions.Logging;
namespace Emby.Server.Implementations.LiveTv
{
public class LiveTvMediaSourceProvider : IMediaSourceProvider
{
// Do not use a pipe here because Roku http requests to the server will fail, without any explicit error message.
private const char StreamIdDelimiter = '_';
private readonly ILiveTvManager _liveTvManager;
private readonly ILogger<LiveTvMediaSourceProvider> _logger;
private readonly IMediaSourceManager _mediaSourceManager;
private readonly IServerApplicationHost _appHost;
public LiveTvMediaSourceProvider(ILiveTvManager liveTvManager, ILogger<LiveTvMediaSourceProvider> logger, IMediaSourceManager mediaSourceManager, IServerApplicationHost appHost)
{
_liveTvManager = liveTvManager;
_logger = logger;
_mediaSourceManager = mediaSourceManager;
_appHost = appHost;
}
public Task<IEnumerable<MediaSourceInfo>> GetMediaSources(BaseItem item, CancellationToken cancellationToken)
{
if (item.SourceType == SourceType.LiveTV)
{
var activeRecordingInfo = _liveTvManager.GetActiveRecordingInfo(item.Path);
if (string.IsNullOrEmpty(item.Path) || activeRecordingInfo is not null)
{
return GetMediaSourcesInternal(item, activeRecordingInfo, cancellationToken);
}
}
return Task.FromResult(Enumerable.Empty<MediaSourceInfo>());
}
private async Task<IEnumerable<MediaSourceInfo>> GetMediaSourcesInternal(BaseItem item, ActiveRecordingInfo activeRecordingInfo, CancellationToken cancellationToken)
{
IEnumerable<MediaSourceInfo> sources;
var forceRequireOpening = false;
try
{
if (activeRecordingInfo is not null)
{
sources = await EmbyTV.EmbyTV.Current.GetRecordingStreamMediaSources(activeRecordingInfo, cancellationToken)
.ConfigureAwait(false);
}
else
{
sources = await _liveTvManager.GetChannelMediaSources(item, cancellationToken)
.ConfigureAwait(false);
}
}
catch (NotImplementedException)
{
sources = _mediaSourceManager.GetStaticMediaSources(item, false);
forceRequireOpening = true;
}
var list = sources.ToList();
foreach (var source in list)
{
source.Type = MediaSourceType.Default;
source.BufferMs ??= 1500;
if (source.RequiresOpening || forceRequireOpening)
{
source.RequiresOpening = true;
}
if (source.RequiresOpening)
{
var openKeys = new List<string>
{
item.GetType().Name,
item.Id.ToString("N", CultureInfo.InvariantCulture),
source.Id ?? string.Empty
};
source.OpenToken = string.Join(StreamIdDelimiter, openKeys);
}
// Dummy this up so that direct play checks can still run
if (string.IsNullOrEmpty(source.Path) && source.Protocol == MediaProtocol.Http)
{
source.Path = _appHost.GetApiUrlForLocalAccess();
}
}
_logger.LogDebug("MediaSources: {@MediaSources}", list);
return list;
}
/// <inheritdoc />
public async Task<ILiveStream> OpenMediaSource(string openToken, List<ILiveStream> currentLiveStreams, CancellationToken cancellationToken)
{
var keys = openToken.Split(StreamIdDelimiter, 3);
var mediaSourceId = keys.Length >= 3 ? keys[2] : null;
var info = await _liveTvManager.GetChannelStream(keys[1], mediaSourceId, currentLiveStreams, cancellationToken).ConfigureAwait(false);
var liveStream = info.Item2;
return liveStream;
}
}
}

View File

@@ -1,75 +0,0 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using MediaBrowser.Common.Configuration;
using MediaBrowser.Controller.LiveTv;
using MediaBrowser.Model.LiveTv;
using MediaBrowser.Model.Tasks;
namespace Emby.Server.Implementations.LiveTv
{
/// <summary>
/// The "Refresh Guide" scheduled task.
/// </summary>
public class RefreshGuideScheduledTask : IScheduledTask, IConfigurableScheduledTask
{
private readonly ILiveTvManager _liveTvManager;
private readonly IConfigurationManager _config;
/// <summary>
/// Initializes a new instance of the <see cref="RefreshGuideScheduledTask"/> class.
/// </summary>
/// <param name="liveTvManager">The live tv manager.</param>
/// <param name="config">The configuration manager.</param>
public RefreshGuideScheduledTask(ILiveTvManager liveTvManager, IConfigurationManager config)
{
_liveTvManager = liveTvManager;
_config = config;
}
/// <inheritdoc />
public string Name => "Refresh Guide";
/// <inheritdoc />
public string Description => "Downloads channel information from live tv services.";
/// <inheritdoc />
public string Category => "Live TV";
/// <inheritdoc />
public bool IsHidden => _liveTvManager.Services.Count == 1 && GetConfiguration().TunerHosts.Length == 0;
/// <inheritdoc />
public bool IsEnabled => true;
/// <inheritdoc />
public bool IsLogged => true;
/// <inheritdoc />
public string Key => "RefreshGuide";
/// <inheritdoc />
public Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken)
{
var manager = (LiveTvManager)_liveTvManager;
return manager.RefreshChannels(progress, cancellationToken);
}
/// <inheritdoc />
public IEnumerable<TaskTriggerInfo> GetDefaultTriggers()
{
return new[]
{
// Every so often
new TaskTriggerInfo { Type = TaskTriggerInfo.TriggerInterval, IntervalTicks = TimeSpan.FromHours(24).Ticks }
};
}
private LiveTvOptions GetConfiguration()
{
return _config.GetConfiguration<LiveTvOptions>("livetv");
}
}
}

View File

@@ -1,237 +0,0 @@
#nullable disable
#pragma warning disable CS1591
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using MediaBrowser.Common.Configuration;
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.LiveTv;
using MediaBrowser.Model.Dto;
using MediaBrowser.Model.IO;
using MediaBrowser.Model.LiveTv;
using Microsoft.Extensions.Logging;
namespace Emby.Server.Implementations.LiveTv.TunerHosts
{
public abstract class BaseTunerHost
{
private readonly ConcurrentDictionary<string, List<ChannelInfo>> _cache;
protected BaseTunerHost(IServerConfigurationManager config, ILogger<BaseTunerHost> logger, IFileSystem fileSystem)
{
Config = config;
Logger = logger;
FileSystem = fileSystem;
_cache = new ConcurrentDictionary<string, List<ChannelInfo>>();
}
protected IServerConfigurationManager Config { get; }
protected ILogger<BaseTunerHost> Logger { get; }
protected IFileSystem FileSystem { get; }
public virtual bool IsSupported => true;
public abstract string Type { get; }
protected virtual string ChannelIdPrefix => Type + "_";
protected abstract Task<List<ChannelInfo>> GetChannelsInternal(TunerHostInfo tuner, CancellationToken cancellationToken);
public async Task<List<ChannelInfo>> GetChannels(TunerHostInfo tuner, bool enableCache, CancellationToken cancellationToken)
{
var key = tuner.Id;
if (enableCache && !string.IsNullOrEmpty(key) && _cache.TryGetValue(key, out List<ChannelInfo> cache))
{
return cache;
}
var list = await GetChannelsInternal(tuner, cancellationToken).ConfigureAwait(false);
// logger.LogInformation("Channels from {0}: {1}", tuner.Url, JsonSerializer.SerializeToString(list));
if (!string.IsNullOrEmpty(key) && list.Count > 0)
{
_cache[key] = list;
}
return list;
}
protected virtual IList<TunerHostInfo> GetTunerHosts()
{
return GetConfiguration().TunerHosts
.Where(i => string.Equals(i.Type, Type, StringComparison.OrdinalIgnoreCase))
.ToList();
}
public async Task<List<ChannelInfo>> GetChannels(bool enableCache, CancellationToken cancellationToken)
{
var list = new List<ChannelInfo>();
var hosts = GetTunerHosts();
foreach (var host in hosts)
{
var channelCacheFile = Path.Combine(Config.ApplicationPaths.CachePath, host.Id + "_channels");
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);
if (!enableCache)
{
try
{
Directory.CreateDirectory(Path.GetDirectoryName(channelCacheFile));
var writeStream = AsyncFile.OpenWrite(channelCacheFile);
await using (writeStream.ConfigureAwait(false))
{
await JsonSerializer.SerializeAsync(writeStream, channels, cancellationToken: cancellationToken).ConfigureAwait(false);
}
}
catch (IOException)
{
}
}
}
catch (Exception ex)
{
Logger.LogError(ex, "Error getting channel list");
if (enableCache)
{
try
{
var readStream = AsyncFile.OpenRead(channelCacheFile);
await using (readStream.ConfigureAwait(false))
{
var channels = await JsonSerializer
.DeserializeAsync<List<ChannelInfo>>(readStream, cancellationToken: cancellationToken)
.ConfigureAwait(false);
list.AddRange(channels);
}
}
catch (IOException)
{
}
}
}
}
return list;
}
protected abstract Task<List<MediaSourceInfo>> GetChannelStreamMediaSources(TunerHostInfo tuner, ChannelInfo channel, CancellationToken cancellationToken);
public async Task<List<MediaSourceInfo>> GetChannelStreamMediaSources(string channelId, CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrEmpty(channelId);
if (IsValidChannelId(channelId))
{
var hosts = GetTunerHosts();
foreach (var host in hosts)
{
try
{
var channels = await GetChannels(host, true, cancellationToken).ConfigureAwait(false);
var channelInfo = channels.FirstOrDefault(i => string.Equals(i.Id, channelId, StringComparison.OrdinalIgnoreCase));
if (channelInfo is not null)
{
return await GetChannelStreamMediaSources(host, channelInfo, cancellationToken).ConfigureAwait(false);
}
}
catch (Exception ex)
{
Logger.LogError(ex, "Error getting channels");
}
}
}
return new List<MediaSourceInfo>();
}
protected abstract Task<ILiveStream> GetChannelStream(TunerHostInfo tunerHost, ChannelInfo channel, string streamId, IList<ILiveStream> currentLiveStreams, CancellationToken cancellationToken);
public async Task<ILiveStream> GetChannelStream(string channelId, string streamId, IList<ILiveStream> currentLiveStreams, CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrEmpty(channelId);
if (!IsValidChannelId(channelId))
{
throw new FileNotFoundException();
}
var hosts = GetTunerHosts();
var hostsWithChannel = new List<Tuple<TunerHostInfo, ChannelInfo>>();
foreach (var host in hosts)
{
try
{
var channels = await GetChannels(host, true, cancellationToken).ConfigureAwait(false);
var channelInfo = channels.FirstOrDefault(i => string.Equals(i.Id, channelId, StringComparison.OrdinalIgnoreCase));
if (channelInfo is not null)
{
hostsWithChannel.Add(new Tuple<TunerHostInfo, ChannelInfo>(host, channelInfo));
}
}
catch (Exception ex)
{
Logger.LogError(ex, "Error getting channels");
}
}
foreach (var hostTuple in hostsWithChannel)
{
var host = hostTuple.Item1;
var channelInfo = hostTuple.Item2;
try
{
var liveStream = await GetChannelStream(host, channelInfo, streamId, currentLiveStreams, cancellationToken).ConfigureAwait(false);
var startTime = DateTime.UtcNow;
await liveStream.Open(cancellationToken).ConfigureAwait(false);
var endTime = DateTime.UtcNow;
Logger.LogInformation("Live stream opened after {0}ms", (endTime - startTime).TotalMilliseconds);
return liveStream;
}
catch (Exception ex)
{
Logger.LogError(ex, "Error opening tuner");
}
}
throw new LiveTvConflictException();
}
protected virtual bool IsValidChannelId(string channelId)
{
ArgumentException.ThrowIfNullOrEmpty(channelId);
return channelId.StartsWith(ChannelIdPrefix, StringComparison.OrdinalIgnoreCase);
}
protected LiveTvOptions GetConfiguration()
{
return Config.GetConfiguration<LiveTvOptions>("livetv");
}
}
}

View File

@@ -1,23 +0,0 @@
#nullable disable
namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun
{
internal 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 bool HD { get; set; }
}
}

View File

@@ -1,42 +0,0 @@
#nullable disable
using System;
namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun
{
internal 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; }
public int TunerCount { get; set; }
public bool SupportsTranscoding
{
get
{
var model = ModelNumber ?? string.Empty;
if (model.Contains("hdtc", StringComparison.OrdinalIgnoreCase))
{
return true;
}
return false;
}
}
}
}

View File

@@ -1,35 +0,0 @@
#pragma warning disable CS1591
using System;
using System.Collections.Generic;
namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun
{
public class HdHomerunChannelCommands : IHdHomerunChannelCommands
{
private string? _channel;
private string? _profile;
public HdHomerunChannelCommands(string? channel, string? profile)
{
_channel = channel;
_profile = profile;
}
public IEnumerable<(string CommandName, string CommandValue)> GetCommands()
{
if (!string.IsNullOrEmpty(_channel))
{
if (!string.IsNullOrEmpty(_profile)
&& !string.Equals(_profile, "native", StringComparison.OrdinalIgnoreCase))
{
yield return ("vchannel", $"{_channel} transcode={_profile}");
}
else
{
yield return ("vchannel", _channel);
}
}
}
}
}

View File

@@ -1,718 +0,0 @@
#nullable disable
#pragma warning disable CS1591
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Net.Http.Json;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Extensions;
using Jellyfin.Extensions.Json;
using Jellyfin.Extensions.Json.Converters;
using MediaBrowser.Common.Extensions;
using MediaBrowser.Common.Net;
using MediaBrowser.Controller;
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.LiveTv;
using MediaBrowser.Model.Dto;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.IO;
using MediaBrowser.Model.LiveTv;
using MediaBrowser.Model.MediaInfo;
using MediaBrowser.Model.Net;
using Microsoft.Extensions.Logging;
namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun
{
public class HdHomerunHost : BaseTunerHost, ITunerHost, IConfigurableTunerHost
{
private readonly IHttpClientFactory _httpClientFactory;
private readonly IServerApplicationHost _appHost;
private readonly ISocketFactory _socketFactory;
private readonly IStreamHelper _streamHelper;
private readonly JsonSerializerOptions _jsonOptions;
private readonly Dictionary<string, DiscoverResponse> _modelCache = new Dictionary<string, DiscoverResponse>();
public HdHomerunHost(
IServerConfigurationManager config,
ILogger<HdHomerunHost> logger,
IFileSystem fileSystem,
IHttpClientFactory httpClientFactory,
IServerApplicationHost appHost,
ISocketFactory socketFactory,
IStreamHelper streamHelper)
: base(config, logger, fileSystem)
{
_httpClientFactory = httpClientFactory;
_appHost = appHost;
_socketFactory = socketFactory;
_streamHelper = streamHelper;
_jsonOptions = new JsonSerializerOptions(JsonDefaults.Options);
_jsonOptions.Converters.Add(new JsonBoolNumberConverter());
}
public string Name => "HD Homerun";
public override string Type => "hdhomerun";
protected override string ChannelIdPrefix => "hdhr_";
private string GetChannelId(Channels i)
=> ChannelIdPrefix + i.GuideNumber;
internal async Task<List<Channels>> GetLineup(TunerHostInfo info, CancellationToken cancellationToken)
{
var model = await GetModelInfo(info, false, cancellationToken).ConfigureAwait(false);
using var response = await _httpClientFactory.CreateClient(NamedClient.Default).GetAsync(model.LineupURL ?? model.BaseURL + "/lineup.json", HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false);
var lineup = await response.Content.ReadFromJsonAsync<IEnumerable<Channels>>(_jsonOptions, cancellationToken).ConfigureAwait(false) ?? Enumerable.Empty<Channels>();
if (info.ImportFavoritesOnly)
{
lineup = lineup.Where(i => i.Favorite);
}
return lineup.Where(i => !i.DRM).ToList();
}
protected override async Task<List<ChannelInfo>> GetChannelsInternal(TunerHostInfo tuner, CancellationToken cancellationToken)
{
var lineup = await GetLineup(tuner, cancellationToken).ConfigureAwait(false);
return lineup.Select(i => new HdHomerunChannelInfo
{
Name = i.GuideName,
Number = i.GuideNumber,
Id = GetChannelId(i),
IsFavorite = i.Favorite,
TunerHostId = tuner.Id,
IsHD = i.HD,
AudioCodec = i.AudioCodec,
VideoCodec = i.VideoCodec,
ChannelType = ChannelType.TV,
IsLegacyTuner = (i.URL ?? string.Empty).StartsWith("hdhomerun", StringComparison.OrdinalIgnoreCase),
Path = i.URL
}).Cast<ChannelInfo>().ToList();
}
internal async Task<DiscoverResponse> GetModelInfo(TunerHostInfo info, bool throwAllExceptions, CancellationToken cancellationToken)
{
var cacheKey = info.Id;
lock (_modelCache)
{
if (!string.IsNullOrEmpty(cacheKey))
{
if (_modelCache.TryGetValue(cacheKey, out DiscoverResponse response))
{
return response;
}
}
}
try
{
using var response = await _httpClientFactory.CreateClient(NamedClient.Default)
.GetAsync(GetApiUrl(info) + "/discover.json", HttpCompletionOption.ResponseHeadersRead, cancellationToken)
.ConfigureAwait(false);
response.EnsureSuccessStatusCode();
var discoverResponse = await response.Content.ReadFromJsonAsync<DiscoverResponse>(_jsonOptions, cancellationToken).ConfigureAwait(false);
if (!string.IsNullOrEmpty(cacheKey))
{
lock (_modelCache)
{
_modelCache[cacheKey] = discoverResponse;
}
}
return discoverResponse;
}
catch (HttpRequestException ex)
{
if (!throwAllExceptions && ex.StatusCode.HasValue && ex.StatusCode.Value == HttpStatusCode.NotFound)
{
const string DefaultValue = "HDHR";
var response = new DiscoverResponse
{
ModelNumber = DefaultValue
};
if (!string.IsNullOrEmpty(cacheKey))
{
// HDHR4 doesn't have this api
lock (_modelCache)
{
_modelCache[cacheKey] = response;
}
}
return response;
}
throw;
}
}
private async Task<List<LiveTvTunerInfo>> GetTunerInfosHttp(TunerHostInfo info, CancellationToken cancellationToken)
{
var model = await GetModelInfo(info, false, cancellationToken).ConfigureAwait(false);
using var response = await _httpClientFactory.CreateClient(NamedClient.Default)
.GetAsync(string.Format(CultureInfo.InvariantCulture, "{0}/tuners.html", GetApiUrl(info)), HttpCompletionOption.ResponseHeadersRead, cancellationToken)
.ConfigureAwait(false);
var tuners = new List<LiveTvTunerInfo>();
var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
await using (stream.ConfigureAwait(false))
{
using var sr = new StreamReader(stream, System.Text.Encoding.UTF8);
await foreach (var line in sr.ReadAllLinesAsync().ConfigureAwait(false))
{
string stripedLine = StripXML(line);
if (stripedLine.Contains("Channel", StringComparison.Ordinal))
{
LiveTvTunerStatus status;
var index = stripedLine.IndexOf("Channel", StringComparison.OrdinalIgnoreCase);
var name = stripedLine.Substring(0, index - 1);
var currentChannel = stripedLine.Substring(index + 7);
if (string.Equals(currentChannel, "none", StringComparison.Ordinal))
{
status = LiveTvTunerStatus.LiveTv;
}
else
{
status = LiveTvTunerStatus.Available;
}
tuners.Add(new LiveTvTunerInfo
{
Name = name,
SourceType = string.IsNullOrWhiteSpace(model.ModelNumber) ? Name : model.ModelNumber,
ProgramName = currentChannel,
Status = status
});
}
}
}
return tuners;
}
private static string StripXML(string source)
{
if (string.IsNullOrEmpty(source))
{
return string.Empty;
}
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;
}
}
return new string(buffer, 0, bufferIndex);
}
private async Task<List<LiveTvTunerInfo>> GetTunerInfosUdp(TunerHostInfo info, CancellationToken cancellationToken)
{
var model = await GetModelInfo(info, false, cancellationToken).ConfigureAwait(false);
var tuners = new List<LiveTvTunerInfo>(model.TunerCount);
var uri = new Uri(GetApiUrl(info));
using (var manager = new HdHomerunManager())
{
// Legacy HdHomeruns are IPv4 only
var ipInfo = IPAddress.Parse(uri.Host);
for (int i = 0; i < model.TunerCount; i++)
{
var name = string.Format(CultureInfo.InvariantCulture, "Tuner {0}", i + 1);
var currentChannel = "none"; // TODO: Get current channel and map back to Station Id
var isAvailable = await manager.CheckTunerAvailability(ipInfo, i, cancellationToken).ConfigureAwait(false);
var status = isAvailable ? LiveTvTunerStatus.Available : LiveTvTunerStatus.LiveTv;
tuners.Add(new LiveTvTunerInfo
{
Name = name,
SourceType = string.IsNullOrWhiteSpace(model.ModelNumber) ? Name : model.ModelNumber,
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 => string.Equals(i.Type, Type, StringComparison.OrdinalIgnoreCase)))
{
try
{
list.AddRange(await GetTunerInfos(host, cancellationToken).ConfigureAwait(false));
}
catch (Exception ex)
{
Logger.LogError(ex, "Error getting tuner info");
}
}
return list;
}
public async Task<List<LiveTvTunerInfo>> GetTunerInfos(TunerHostInfo info, CancellationToken cancellationToken)
{
// TODO Need faster way to determine UDP vs HTTP
var channels = await GetChannels(info, true, cancellationToken).ConfigureAwait(false);
var hdHomerunChannelInfo = channels.FirstOrDefault() as HdHomerunChannelInfo;
if (hdHomerunChannelInfo is null || hdHomerunChannelInfo.IsLegacyTuner)
{
return await GetTunerInfosUdp(info, cancellationToken).ConfigureAwait(false);
}
return await GetTunerInfosHttp(info, cancellationToken).ConfigureAwait(false);
}
private static string GetApiUrl(TunerHostInfo info)
{
var url = info.Url;
if (string.IsNullOrWhiteSpace(url))
{
throw new ArgumentException("Invalid tuner info");
}
if (!url.StartsWith("http", StringComparison.OrdinalIgnoreCase))
{
url = "http://" + url;
}
return new Uri(url).AbsoluteUri.TrimEnd('/');
}
private static string GetHdHrIdFromChannelId(string channelId)
{
return channelId.Split('_')[1];
}
private MediaSourceInfo GetMediaSource(TunerHostInfo info, string channelId, ChannelInfo channelInfo, string profile)
{
int? width = null;
int? height = null;
bool isInterlaced = true;
string videoCodec = null;
int? videoBitrate = null;
var isHd = channelInfo.IsHD ?? true;
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, "internet720", StringComparison.OrdinalIgnoreCase))
{
width = 1280;
height = 720;
isInterlaced = false;
videoCodec = "h264";
videoBitrate = 8000000;
}
else if (string.Equals(profile, "internet540", StringComparison.OrdinalIgnoreCase))
{
width = 960;
height = 540;
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;
}
else
{
// This is for android tv's 1200 condition. Remove once not needed anymore so that we can avoid possible side effects of dummying up this data
if (isHd)
{
width = 1920;
height = 1080;
}
}
if (string.IsNullOrWhiteSpace(videoCodec))
{
videoCodec = channelInfo.VideoCodec;
}
string audioCodec = channelInfo.AudioCodec;
videoBitrate ??= isHd ? 15000000 : 2000000;
int? audioBitrate = isHd ? 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);
var id = profile;
if (string.IsNullOrWhiteSpace(id))
{
id = "native";
}
id += "_" + channelId.GetMD5().ToString("N", CultureInfo.InvariantCulture) + "_" + url.GetMD5().ToString("N", CultureInfo.InvariantCulture);
var mediaSource = new MediaSourceInfo
{
Path = url,
Protocol = MediaProtocol.Udp,
MediaStreams = new 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 = true,
BufferMs = 0,
Container = "ts",
Id = id,
SupportsDirectPlay = false,
SupportsDirectStream = true,
SupportsTranscoding = true,
IsInfiniteStream = true,
IgnoreDts = true,
// IgnoreIndex = true,
// ReadAtNativeFramerate = true
};
mediaSource.InferTotalBitrate();
return mediaSource;
}
protected override async Task<List<MediaSourceInfo>> GetChannelStreamMediaSources(TunerHostInfo tuner, ChannelInfo channel, CancellationToken cancellationToken)
{
var list = new List<MediaSourceInfo>();
var channelId = channel.Id;
var hdhrId = GetHdHrIdFromChannelId(channelId);
if (channel is HdHomerunChannelInfo hdHomerunChannelInfo && hdHomerunChannelInfo.IsLegacyTuner)
{
list.Add(GetMediaSource(tuner, hdhrId, channel, "native"));
}
else
{
var modelInfo = await GetModelInfo(tuner, false, cancellationToken).ConfigureAwait(false);
if (modelInfo is not null && modelInfo.SupportsTranscoding)
{
if (tuner.AllowHWTranscoding)
{
list.Add(GetMediaSource(tuner, hdhrId, channel, "heavy"));
list.Add(GetMediaSource(tuner, hdhrId, channel, "internet540"));
list.Add(GetMediaSource(tuner, hdhrId, channel, "internet480"));
list.Add(GetMediaSource(tuner, hdhrId, channel, "internet360"));
list.Add(GetMediaSource(tuner, hdhrId, channel, "internet240"));
list.Add(GetMediaSource(tuner, hdhrId, channel, "mobile"));
}
list.Add(GetMediaSource(tuner, hdhrId, channel, "native"));
}
if (list.Count == 0)
{
list.Add(GetMediaSource(tuner, hdhrId, channel, "native"));
}
}
return list;
}
protected override async Task<ILiveStream> GetChannelStream(TunerHostInfo tunerHost, ChannelInfo channel, string streamId, IList<ILiveStream> currentLiveStreams, CancellationToken cancellationToken)
{
var tunerCount = tunerHost.TunerCount;
if (tunerCount > 0)
{
var tunerHostId = tunerHost.Id;
var liveStreams = currentLiveStreams.Where(i => string.Equals(i.TunerHostId, tunerHostId, StringComparison.OrdinalIgnoreCase));
if (liveStreams.Count() >= tunerCount)
{
throw new LiveTvConflictException("HDHomeRun simultaneous stream limit has been reached.");
}
}
var profile = streamId.AsSpan().LeftPart('_').ToString();
Logger.LogInformation("GetChannelStream: channel id: {0}. stream id: {1} profile: {2}", channel.Id, streamId, profile);
var hdhrId = GetHdHrIdFromChannelId(channel.Id);
var hdhomerunChannel = channel as HdHomerunChannelInfo;
var modelInfo = await GetModelInfo(tunerHost, false, cancellationToken).ConfigureAwait(false);
if (!modelInfo.SupportsTranscoding)
{
profile = "native";
}
var mediaSource = GetMediaSource(tunerHost, hdhrId, channel, profile);
if (hdhomerunChannel is not null && hdhomerunChannel.IsLegacyTuner)
{
return new HdHomerunUdpStream(
mediaSource,
tunerHost,
streamId,
new LegacyHdHomerunChannelCommands(hdhomerunChannel.Path),
modelInfo.TunerCount,
FileSystem,
Logger,
Config,
_appHost,
_streamHelper);
}
var enableHttpStream = true;
if (enableHttpStream)
{
mediaSource.Protocol = MediaProtocol.Http;
var httpUrl = channel.Path;
// If raw was used, the tuner doesn't support params
if (!string.IsNullOrWhiteSpace(profile) && !string.Equals(profile, "native", StringComparison.OrdinalIgnoreCase))
{
httpUrl += "?transcode=" + profile;
}
mediaSource.Path = httpUrl;
return new SharedHttpStream(
mediaSource,
tunerHost,
streamId,
FileSystem,
_httpClientFactory,
Logger,
Config,
_appHost,
_streamHelper);
}
return new HdHomerunUdpStream(
mediaSource,
tunerHost,
streamId,
new HdHomerunChannelCommands(hdhomerunChannel.Number, profile),
modelInfo.TunerCount,
FileSystem,
Logger,
Config,
_appHost,
_streamHelper);
}
public async Task Validate(TunerHostInfo info)
{
lock (_modelCache)
{
_modelCache.Clear();
}
try
{
// Test it by pulling down the lineup
var modelInfo = await GetModelInfo(info, true, CancellationToken.None).ConfigureAwait(false);
info.DeviceId = modelInfo.DeviceID;
}
catch (HttpRequestException ex)
{
if (ex.StatusCode.HasValue && ex.StatusCode.Value == HttpStatusCode.NotFound)
{
// HDHR4 doesn't have this api
return;
}
throw;
}
}
public async Task<List<TunerHostInfo>> DiscoverDevices(int discoveryDurationMs, CancellationToken cancellationToken)
{
lock (_modelCache)
{
_modelCache.Clear();
}
using var timedCancellationToken = new CancellationTokenSource(discoveryDurationMs);
using var linkedCancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(timedCancellationToken.Token, cancellationToken);
cancellationToken = linkedCancellationTokenSource.Token;
var list = new List<TunerHostInfo>();
// Create udp broadcast discovery message
byte[] discBytes = { 0, 2, 0, 12, 1, 4, 255, 255, 255, 255, 2, 4, 255, 255, 255, 255, 115, 204, 125, 143 };
using (var udpClient = _socketFactory.CreateUdpBroadcastSocket(0))
{
// Need a way to set the Receive timeout on the socket otherwise this might never timeout?
try
{
await udpClient.SendToAsync(discBytes, new IPEndPoint(IPAddress.Broadcast, 65001), cancellationToken).ConfigureAwait(false);
var receiveBuffer = new byte[8192];
while (!cancellationToken.IsCancellationRequested)
{
var response = await udpClient.ReceiveMessageFromAsync(receiveBuffer, new IPEndPoint(IPAddress.Any, 0), cancellationToken).ConfigureAwait(false);
var deviceIP = ((IPEndPoint)response.RemoteEndPoint).Address.ToString();
// Check to make sure we have enough bytes received to be a valid message and make sure the 2nd byte is the discover reply byte
if (response.ReceivedBytes > 13 && receiveBuffer[1] == 3)
{
var deviceAddress = "http://" + deviceIP;
var info = await TryGetTunerHostInfo(deviceAddress, cancellationToken).ConfigureAwait(false);
if (info is not null)
{
list.Add(info);
}
}
}
}
catch (OperationCanceledException)
{
}
catch (Exception ex)
{
// Socket timeout indicates all messages have been received.
Logger.LogError(ex, "Error while sending discovery message");
}
}
return list;
}
internal async Task<TunerHostInfo> TryGetTunerHostInfo(string url, CancellationToken cancellationToken)
{
var hostInfo = new TunerHostInfo
{
Type = Type,
Url = url
};
var modelInfo = await GetModelInfo(hostInfo, false, cancellationToken).ConfigureAwait(false);
hostInfo.DeviceId = modelInfo.DeviceID;
hostInfo.FriendlyName = modelInfo.FriendlyName;
hostInfo.TunerCount = modelInfo.TunerCount;
return hostInfo;
}
private class HdHomerunChannelInfo : ChannelInfo
{
public bool IsLegacyTuner { get; set; }
}
}
}

View File

@@ -1,351 +0,0 @@
#nullable disable
#pragma warning disable CS1591
using System;
using System.Buffers;
using System.Buffers.Binary;
using System.Globalization;
using System.Net;
using System.Net.Sockets;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using MediaBrowser.Common;
using MediaBrowser.Controller.LiveTv;
namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun
{
public sealed class HdHomerunManager : IDisposable
{
public const int HdHomeRunPort = 65001;
// Message constants
private const byte GetSetName = 3;
private const byte GetSetValue = 4;
private const byte GetSetLockkey = 21;
private const ushort GetSetRequest = 4;
private const ushort GetSetReply = 5;
private uint? _lockkey = null;
private int _activeTuner = -1;
private IPEndPoint _remoteEndPoint;
private TcpClient _tcpClient;
public void Dispose()
{
using (var socket = _tcpClient)
{
if (socket is not null)
{
_tcpClient = null;
StopStreaming(socket).GetAwaiter().GetResult();
}
}
}
public async Task<bool> CheckTunerAvailability(IPAddress remoteIP, int tuner, CancellationToken cancellationToken)
{
using var client = new TcpClient();
await client.ConnectAsync(remoteIP, HdHomeRunPort, cancellationToken).ConfigureAwait(false);
using var stream = client.GetStream();
return await CheckTunerAvailability(stream, tuner, cancellationToken).ConfigureAwait(false);
}
private static async Task<bool> CheckTunerAvailability(NetworkStream stream, int tuner, CancellationToken cancellationToken)
{
byte[] buffer = ArrayPool<byte>.Shared.Rent(8192);
try
{
var msgLen = WriteGetMessage(buffer, tuner, "lockkey");
await stream.WriteAsync(buffer.AsMemory(0, msgLen), cancellationToken).ConfigureAwait(false);
int receivedBytes = await stream.ReadAsync(buffer, cancellationToken).ConfigureAwait(false);
return VerifyReturnValueOfGetSet(buffer.AsSpan(0, receivedBytes), "none");
}
finally
{
ArrayPool<byte>.Shared.Return(buffer);
}
}
public async Task StartStreaming(IPAddress remoteIP, IPAddress localIP, int localPort, IHdHomerunChannelCommands commands, int numTuners, CancellationToken cancellationToken)
{
_remoteEndPoint = new IPEndPoint(remoteIP, HdHomeRunPort);
_tcpClient = new TcpClient();
await _tcpClient.ConnectAsync(_remoteEndPoint, cancellationToken).ConfigureAwait(false);
if (!_lockkey.HasValue)
{
_lockkey = (uint)Random.Shared.Next();
}
var lockKeyValue = _lockkey.Value;
var stream = _tcpClient.GetStream();
byte[] buffer = ArrayPool<byte>.Shared.Rent(8192);
try
{
for (int i = 0; i < numTuners; ++i)
{
if (!await CheckTunerAvailability(stream, i, cancellationToken).ConfigureAwait(false))
{
continue;
}
_activeTuner = i;
var lockKeyString = string.Format(CultureInfo.InvariantCulture, "{0:d}", lockKeyValue);
var lockkeyMsgLen = WriteSetMessage(buffer, i, "lockkey", lockKeyString, null);
await stream.WriteAsync(buffer.AsMemory(0, lockkeyMsgLen), cancellationToken).ConfigureAwait(false);
int receivedBytes = await stream.ReadAsync(buffer, cancellationToken).ConfigureAwait(false);
// parse response to make sure it worked
if (!TryGetReturnValueOfGetSet(buffer.AsSpan(0, receivedBytes), out _))
{
continue;
}
foreach (var command in commands.GetCommands())
{
var channelMsgLen = WriteSetMessage(buffer, i, command.CommandName, command.CommandValue, lockKeyValue);
await stream.WriteAsync(buffer.AsMemory(0, channelMsgLen), cancellationToken).ConfigureAwait(false);
receivedBytes = await stream.ReadAsync(buffer, cancellationToken).ConfigureAwait(false);
// parse response to make sure it worked
if (!TryGetReturnValueOfGetSet(buffer.AsSpan(0, receivedBytes), out _))
{
await ReleaseLockkey(_tcpClient, lockKeyValue).ConfigureAwait(false);
}
}
var targetValue = string.Format(CultureInfo.InvariantCulture, "rtp://{0}:{1}", localIP, localPort);
var targetMsgLen = WriteSetMessage(buffer, i, "target", targetValue, lockKeyValue);
await stream.WriteAsync(buffer.AsMemory(0, targetMsgLen), cancellationToken).ConfigureAwait(false);
receivedBytes = await stream.ReadAsync(buffer, cancellationToken).ConfigureAwait(false);
// parse response to make sure it worked
if (!TryGetReturnValueOfGetSet(buffer.AsSpan(0, receivedBytes), out _))
{
await ReleaseLockkey(_tcpClient, lockKeyValue).ConfigureAwait(false);
continue;
}
return;
}
}
finally
{
ArrayPool<byte>.Shared.Return(buffer);
}
_activeTuner = -1;
throw new LiveTvConflictException();
}
public async Task ChangeChannel(IHdHomerunChannelCommands commands, CancellationToken cancellationToken)
{
if (!_lockkey.HasValue)
{
return;
}
using var tcpClient = new TcpClient();
await tcpClient.ConnectAsync(_remoteEndPoint, cancellationToken).ConfigureAwait(false);
using var stream = tcpClient.GetStream();
var commandList = commands.GetCommands();
byte[] buffer = ArrayPool<byte>.Shared.Rent(8192);
try
{
foreach (var command in commandList)
{
var channelMsgLen = WriteSetMessage(buffer, _activeTuner, command.CommandName, command.CommandValue, _lockkey);
await stream.WriteAsync(buffer.AsMemory(0, channelMsgLen), cancellationToken).ConfigureAwait(false);
int receivedBytes = await stream.ReadAsync(buffer, cancellationToken).ConfigureAwait(false);
// parse response to make sure it worked
if (!TryGetReturnValueOfGetSet(buffer.AsSpan(0, receivedBytes), out _))
{
return;
}
}
}
finally
{
ArrayPool<byte>.Shared.Return(buffer);
}
}
public Task StopStreaming(TcpClient client)
{
var lockKey = _lockkey;
if (!lockKey.HasValue)
{
return Task.CompletedTask;
}
return ReleaseLockkey(client, lockKey.Value);
}
private async Task ReleaseLockkey(TcpClient client, uint lockKeyValue)
{
var stream = client.GetStream();
var buffer = ArrayPool<byte>.Shared.Rent(8192);
try
{
var releaseTargetLen = WriteSetMessage(buffer, _activeTuner, "target", "none", lockKeyValue);
await stream.WriteAsync(buffer.AsMemory(0, releaseTargetLen)).ConfigureAwait(false);
await stream.ReadAsync(buffer).ConfigureAwait(false);
var releaseKeyMsgLen = WriteSetMessage(buffer, _activeTuner, "lockkey", "none", lockKeyValue);
_lockkey = null;
await stream.WriteAsync(buffer.AsMemory(0, releaseKeyMsgLen)).ConfigureAwait(false);
await stream.ReadAsync(buffer).ConfigureAwait(false);
}
finally
{
ArrayPool<byte>.Shared.Return(buffer);
}
}
internal static int WriteGetMessage(Span<byte> buffer, int tuner, string name)
{
var byteName = string.Format(CultureInfo.InvariantCulture, "/tuner{0}/{1}", tuner, name);
int offset = WriteHeaderAndPayload(buffer, byteName);
return FinishPacket(buffer, offset);
}
internal static int WriteSetMessage(Span<byte> buffer, int tuner, string name, string value, uint? lockkey)
{
var byteName = string.Format(CultureInfo.InvariantCulture, "/tuner{0}/{1}", tuner, name);
int offset = WriteHeaderAndPayload(buffer, byteName);
buffer[offset++] = GetSetValue;
offset += WriteNullTerminatedString(buffer.Slice(offset), value);
if (lockkey.HasValue)
{
buffer[offset++] = GetSetLockkey;
buffer[offset++] = 4;
BinaryPrimitives.WriteUInt32BigEndian(buffer.Slice(offset), lockkey.Value);
offset += 4;
}
return FinishPacket(buffer, offset);
}
internal static int WriteNullTerminatedString(Span<byte> buffer, ReadOnlySpan<char> payload)
{
int len = Encoding.UTF8.GetBytes(payload, buffer.Slice(1)) + 1;
// TODO: variable length: this can be 2 bytes if len > 127
// Write length in front of value
buffer[0] = Convert.ToByte(len);
// null-terminate
buffer[len++] = 0;
return len;
}
private static int WriteHeaderAndPayload(Span<byte> buffer, ReadOnlySpan<char> payload)
{
// Packet type
BinaryPrimitives.WriteUInt16BigEndian(buffer, GetSetRequest);
// We write the payload length at the end
int offset = 4;
// Tag
buffer[offset++] = GetSetName;
// Payload length + data
int strLen = WriteNullTerminatedString(buffer.Slice(offset), payload);
offset += strLen;
return offset;
}
private static int FinishPacket(Span<byte> buffer, int offset)
{
// Payload length
BinaryPrimitives.WriteUInt16BigEndian(buffer.Slice(2), (ushort)(offset - 4));
// calculate crc and insert at the end of the message
var crc = Crc32.Compute(buffer.Slice(0, offset));
BinaryPrimitives.WriteUInt32LittleEndian(buffer.Slice(offset), crc);
return offset + 4;
}
internal static bool VerifyReturnValueOfGetSet(ReadOnlySpan<byte> buffer, string expected)
{
return TryGetReturnValueOfGetSet(buffer, out var value)
&& string.Equals(Encoding.UTF8.GetString(value), expected, StringComparison.OrdinalIgnoreCase);
}
internal static bool TryGetReturnValueOfGetSet(ReadOnlySpan<byte> buffer, out ReadOnlySpan<byte> value)
{
value = ReadOnlySpan<byte>.Empty;
if (buffer.Length < 8)
{
return false;
}
uint crc = BinaryPrimitives.ReadUInt32LittleEndian(buffer[^4..]);
if (crc != Crc32.Compute(buffer[..^4]))
{
return false;
}
if (BinaryPrimitives.ReadUInt16BigEndian(buffer) != GetSetReply)
{
return false;
}
var msgLength = BinaryPrimitives.ReadUInt16BigEndian(buffer.Slice(2));
if (buffer.Length != 2 + 2 + 4 + msgLength)
{
return false;
}
var offset = 4;
if (buffer[offset++] != GetSetName)
{
return false;
}
var nameLength = buffer[offset++];
if (buffer.Length < 4 + 1 + offset + nameLength)
{
return false;
}
offset += nameLength;
if (buffer[offset++] != GetSetValue)
{
return false;
}
var valueLength = buffer[offset++];
if (buffer.Length < 4 + offset + valueLength)
{
return false;
}
// remove null terminator
value = buffer.Slice(offset, valueLength - 1);
return true;
}
}
}

View File

@@ -1,218 +0,0 @@
#nullable disable
#pragma warning disable CS1591
using System;
using System.IO;
using System.Linq;
using System.Net;
using System.Net.NetworkInformation;
using System.Net.Sockets;
using System.Threading;
using System.Threading.Tasks;
using MediaBrowser.Common.Configuration;
using MediaBrowser.Controller;
using MediaBrowser.Controller.Library;
using MediaBrowser.Model.Dto;
using MediaBrowser.Model.IO;
using MediaBrowser.Model.LiveTv;
using MediaBrowser.Model.MediaInfo;
using Microsoft.Extensions.Logging;
namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun
{
public class HdHomerunUdpStream : LiveStream, IDirectStreamProvider
{
private const int RtpHeaderBytes = 12;
private readonly IServerApplicationHost _appHost;
private readonly IHdHomerunChannelCommands _channelCommands;
private readonly int _numTuners;
public HdHomerunUdpStream(
MediaSourceInfo mediaSource,
TunerHostInfo tunerHostInfo,
string originalStreamId,
IHdHomerunChannelCommands channelCommands,
int numTuners,
IFileSystem fileSystem,
ILogger logger,
IConfigurationManager configurationManager,
IServerApplicationHost appHost,
IStreamHelper streamHelper)
: base(mediaSource, tunerHostInfo, fileSystem, logger, configurationManager, streamHelper)
{
_appHost = appHost;
OriginalStreamId = originalStreamId;
_channelCommands = channelCommands;
_numTuners = numTuners;
EnableStreamSharing = true;
}
/// <summary>
/// Returns an unused UDP port number in the range specified.
/// Temporarily placed here until future network PR merged.
/// </summary>
/// <param name="range">Upper and Lower boundary of ports to select.</param>
/// <returns>System.Int32.</returns>
private static int GetUdpPortFromRange((int Min, int Max) range)
{
var properties = IPGlobalProperties.GetIPGlobalProperties();
// Get active udp listeners.
var udpListenerPorts = properties.GetActiveUdpListeners()
.Where(n => n.Port >= range.Min && n.Port <= range.Max)
.Select(n => n.Port);
return Enumerable
.Range(range.Min, range.Max)
.FirstOrDefault(i => !udpListenerPorts.Contains(i));
}
public override async Task Open(CancellationToken openCancellationToken)
{
LiveStreamCancellationTokenSource.Token.ThrowIfCancellationRequested();
var mediaSource = OriginalMediaSource;
var uri = new Uri(mediaSource.Path);
// Temporary code to reduce PR size. This will be updated by a future network pr.
var localPort = GetUdpPortFromRange((49152, 65535));
Directory.CreateDirectory(Path.GetDirectoryName(TempFilePath));
Logger.LogInformation("Opening HDHR UDP Live stream from {Host}", uri.Host);
var remoteAddress = IPAddress.Parse(uri.Host);
IPAddress localAddress;
using (var tcpClient = new TcpClient())
{
try
{
await tcpClient.ConnectAsync(remoteAddress, HdHomerunManager.HdHomeRunPort, openCancellationToken).ConfigureAwait(false);
localAddress = ((IPEndPoint)tcpClient.Client.LocalEndPoint).Address;
tcpClient.Close();
}
catch (Exception ex)
{
Logger.LogError(ex, "Unable to determine local ip address for Legacy HDHomerun stream.");
return;
}
}
if (localAddress.IsIPv4MappedToIPv6)
{
localAddress = localAddress.MapToIPv4();
}
var udpClient = new UdpClient(localPort, AddressFamily.InterNetwork);
var hdHomerunManager = new HdHomerunManager();
try
{
// send url to start streaming
await hdHomerunManager.StartStreaming(
remoteAddress,
localAddress,
localPort,
_channelCommands,
_numTuners,
openCancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
using (udpClient)
using (hdHomerunManager)
{
if (ex is not OperationCanceledException)
{
Logger.LogError(ex, "Error opening live stream:");
}
throw;
}
}
var taskCompletionSource = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
_ = StartStreaming(
udpClient,
hdHomerunManager,
remoteAddress,
taskCompletionSource,
LiveStreamCancellationTokenSource.Token);
// OpenedMediaSource.Protocol = MediaProtocol.File;
// OpenedMediaSource.Path = tempFile;
// OpenedMediaSource.ReadAtNativeFramerate = true;
MediaSource.Path = _appHost.GetApiUrlForLocalAccess() + "/LiveTv/LiveStreamFiles/" + UniqueId + "/stream.ts";
MediaSource.Protocol = MediaProtocol.Http;
// OpenedMediaSource.SupportsDirectPlay = false;
// OpenedMediaSource.SupportsDirectStream = true;
// OpenedMediaSource.SupportsTranscoding = true;
// await Task.Delay(5000).ConfigureAwait(false);
await taskCompletionSource.Task.ConfigureAwait(false);
}
private async Task StartStreaming(UdpClient udpClient, HdHomerunManager hdHomerunManager, IPAddress remoteAddress, TaskCompletionSource<bool> openTaskCompletionSource, CancellationToken cancellationToken)
{
using (udpClient)
using (hdHomerunManager)
{
try
{
await CopyTo(udpClient, TempFilePath, openTaskCompletionSource, cancellationToken).ConfigureAwait(false);
}
catch (Exception ex) when (ex is OperationCanceledException || ex is TimeoutException)
{
Logger.LogInformation("HDHR UDP stream cancelled or timed out from {0}", remoteAddress);
openTaskCompletionSource.TrySetException(ex);
}
catch (Exception ex)
{
Logger.LogError(ex, "Error opening live stream:");
openTaskCompletionSource.TrySetException(ex);
}
EnableStreamSharing = false;
}
await DeleteTempFiles(TempFilePath).ConfigureAwait(false);
}
private async Task CopyTo(UdpClient udpClient, string file, TaskCompletionSource<bool> openTaskCompletionSource, CancellationToken cancellationToken)
{
var resolved = false;
var fileStream = new FileStream(file, FileMode.Create, FileAccess.Write, FileShare.Read);
await using (fileStream.ConfigureAwait(false))
{
while (true)
{
cancellationToken.ThrowIfCancellationRequested();
var res = await udpClient.ReceiveAsync(cancellationToken)
.AsTask()
.WaitAsync(TimeSpan.FromMilliseconds(30000), CancellationToken.None)
.ConfigureAwait(false);
var buffer = res.Buffer;
var read = buffer.Length - RtpHeaderBytes;
if (read > 0)
{
await fileStream.WriteAsync(buffer.AsMemory(RtpHeaderBytes, read), cancellationToken).ConfigureAwait(false);
}
if (!resolved)
{
resolved = true;
DateOpened = DateTime.UtcNow;
openTaskCompletionSource.TrySetResult(true);
}
}
}
}
}
}

View File

@@ -1,11 +0,0 @@
#pragma warning disable CS1591
using System.Collections.Generic;
namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun
{
public interface IHdHomerunChannelCommands
{
IEnumerable<(string CommandName, string CommandValue)> GetCommands();
}
}

View File

@@ -1,40 +0,0 @@
#pragma warning disable CS1591
using System.Collections.Generic;
using System.Text.RegularExpressions;
namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun
{
public partial class LegacyHdHomerunChannelCommands : IHdHomerunChannelCommands
{
private string? _channel;
private string? _program;
public LegacyHdHomerunChannelCommands(string url)
{
// parse url for channel and program
var match = ChannelAndProgramRegex().Match(url);
if (match.Success)
{
_channel = match.Groups[1].Value;
_program = match.Groups[2].Value;
}
}
[GeneratedRegex(@"\/ch([0-9]+)-?([0-9]*)")]
private static partial Regex ChannelAndProgramRegex();
public IEnumerable<(string CommandName, string CommandValue)> GetCommands()
{
if (!string.IsNullOrEmpty(_channel))
{
yield return ("channel", _channel);
}
if (!string.IsNullOrEmpty(_program))
{
yield return ("program", _program);
}
}
}
}

View File

@@ -1,175 +0,0 @@
#nullable disable
#pragma warning disable CS1591
using System;
using System.Globalization;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using MediaBrowser.Common.Configuration;
using MediaBrowser.Controller.Library;
using MediaBrowser.Model.Dto;
using MediaBrowser.Model.IO;
using MediaBrowser.Model.LiveTv;
using Microsoft.Extensions.Logging;
namespace Emby.Server.Implementations.LiveTv.TunerHosts
{
public class LiveStream : ILiveStream
{
private readonly IConfigurationManager _configurationManager;
public LiveStream(
MediaSourceInfo mediaSource,
TunerHostInfo tuner,
IFileSystem fileSystem,
ILogger logger,
IConfigurationManager configurationManager,
IStreamHelper streamHelper)
{
OriginalMediaSource = mediaSource;
FileSystem = fileSystem;
MediaSource = mediaSource;
Logger = logger;
EnableStreamSharing = true;
UniqueId = Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture);
if (tuner is not null)
{
TunerHostId = tuner.Id;
}
_configurationManager = configurationManager;
StreamHelper = streamHelper;
ConsumerCount = 1;
SetTempFilePath("ts");
}
protected IFileSystem FileSystem { get; }
protected IStreamHelper StreamHelper { get; }
protected ILogger Logger { get; }
protected CancellationTokenSource LiveStreamCancellationTokenSource { get; } = new CancellationTokenSource();
protected string TempFilePath { get; set; }
public MediaSourceInfo OriginalMediaSource { get; set; }
public MediaSourceInfo MediaSource { get; set; }
public int ConsumerCount { get; set; }
public string OriginalStreamId { get; set; }
public bool EnableStreamSharing { get; set; }
public string UniqueId { get; }
public string TunerHostId { get; }
public DateTime DateOpened { get; protected set; }
protected void SetTempFilePath(string extension)
{
TempFilePath = Path.Combine(_configurationManager.GetTranscodePath(), UniqueId + "." + extension);
}
public virtual Task Open(CancellationToken openCancellationToken)
{
DateOpened = DateTime.UtcNow;
return Task.CompletedTask;
}
public async Task Close()
{
EnableStreamSharing = false;
Logger.LogInformation("Closing {Type}", GetType().Name);
await LiveStreamCancellationTokenSource.CancelAsync().ConfigureAwait(false);
}
public Stream GetStream()
{
var stream = new FileStream(
TempFilePath,
FileMode.Open,
FileAccess.Read,
FileShare.ReadWrite,
IODefaults.FileStreamBufferSize,
FileOptions.SequentialScan | FileOptions.Asynchronous);
bool seekFile = (DateTime.UtcNow - DateOpened).TotalSeconds > 10;
if (seekFile)
{
TrySeek(stream, -20000);
}
return stream;
}
/// <inheritdoc />
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}
protected virtual void Dispose(bool dispose)
{
if (dispose)
{
LiveStreamCancellationTokenSource?.Dispose();
}
}
protected async Task DeleteTempFiles(string path, int retryCount = 0)
{
if (retryCount == 0)
{
Logger.LogInformation("Deleting temp file {FilePath}", path);
}
try
{
FileSystem.DeleteFile(path);
}
catch (Exception ex)
{
Logger.LogError(ex, "Error deleting file {FilePath}", path);
if (retryCount <= 40)
{
await Task.Delay(500).ConfigureAwait(false);
await DeleteTempFiles(path, retryCount + 1).ConfigureAwait(false);
}
}
}
private void TrySeek(FileStream stream, long offset)
{
if (!stream.CanSeek)
{
return;
}
try
{
stream.Seek(offset, SeekOrigin.End);
}
catch (IOException)
{
}
catch (ArgumentException)
{
}
catch (Exception ex)
{
Logger.LogError(ex, "Error seeking stream");
}
}
}
}

View File

@@ -1,220 +0,0 @@
#nullable disable
#pragma warning disable CS1591
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Extensions;
using MediaBrowser.Common.Extensions;
using MediaBrowser.Common.Net;
using MediaBrowser.Controller;
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.LiveTv;
using MediaBrowser.Model.Dto;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.IO;
using MediaBrowser.Model.LiveTv;
using MediaBrowser.Model.MediaInfo;
using Microsoft.Extensions.Logging;
using Microsoft.Net.Http.Headers;
namespace Emby.Server.Implementations.LiveTv.TunerHosts
{
public class M3UTunerHost : BaseTunerHost, ITunerHost, IConfigurableTunerHost
{
private static readonly string[] _disallowedMimeTypes =
{
"video/x-matroska",
"video/mp4",
"application/vnd.apple.mpegurl",
"application/mpegurl",
"application/x-mpegurl",
"video/vnd.mpeg.dash.mpd"
};
private readonly IHttpClientFactory _httpClientFactory;
private readonly IServerApplicationHost _appHost;
private readonly INetworkManager _networkManager;
private readonly IMediaSourceManager _mediaSourceManager;
private readonly IStreamHelper _streamHelper;
public M3UTunerHost(
IServerConfigurationManager config,
IMediaSourceManager mediaSourceManager,
ILogger<M3UTunerHost> logger,
IFileSystem fileSystem,
IHttpClientFactory httpClientFactory,
IServerApplicationHost appHost,
INetworkManager networkManager,
IStreamHelper streamHelper)
: base(config, logger, fileSystem)
{
_httpClientFactory = httpClientFactory;
_appHost = appHost;
_networkManager = networkManager;
_mediaSourceManager = mediaSourceManager;
_streamHelper = streamHelper;
}
public override string Type => "m3u";
public virtual string Name => "M3U Tuner";
private string GetFullChannelIdPrefix(TunerHostInfo info)
{
return ChannelIdPrefix + info.Url.GetMD5().ToString("N", CultureInfo.InvariantCulture);
}
protected override async Task<List<ChannelInfo>> GetChannelsInternal(TunerHostInfo tuner, CancellationToken cancellationToken)
{
var channelIdPrefix = GetFullChannelIdPrefix(tuner);
return await new M3uParser(Logger, _httpClientFactory)
.Parse(tuner, channelIdPrefix, 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", CultureInfo.InvariantCulture),
Url = i.Url
})
.ToList();
return Task.FromResult(list);
}
protected override async Task<ILiveStream> GetChannelStream(TunerHostInfo tunerHost, ChannelInfo channel, string streamId, IList<ILiveStream> currentLiveStreams, CancellationToken cancellationToken)
{
var tunerCount = tunerHost.TunerCount;
if (tunerCount > 0)
{
var tunerHostId = tunerHost.Id;
var liveStreams = currentLiveStreams.Where(i => string.Equals(i.TunerHostId, tunerHostId, StringComparison.OrdinalIgnoreCase));
if (liveStreams.Count() >= tunerCount)
{
throw new LiveTvConflictException("M3U simultaneous stream limit has been reached.");
}
}
var sources = await GetChannelStreamMediaSources(tunerHost, channel, cancellationToken).ConfigureAwait(false);
var mediaSource = sources[0];
if (mediaSource.Protocol == MediaProtocol.Http && !mediaSource.RequiresLooping)
{
using var message = new HttpRequestMessage(HttpMethod.Head, mediaSource.Path);
using var response = await _httpClientFactory.CreateClient(NamedClient.Default)
.SendAsync(message, cancellationToken)
.ConfigureAwait(false);
response.EnsureSuccessStatusCode();
if (!_disallowedMimeTypes.Contains(response.Content.Headers.ContentType?.ToString(), StringComparison.OrdinalIgnoreCase))
{
return new SharedHttpStream(mediaSource, tunerHost, streamId, FileSystem, _httpClientFactory, Logger, Config, _appHost, _streamHelper);
}
}
return new LiveStream(mediaSource, tunerHost, FileSystem, Logger, Config, _streamHelper);
}
public async Task Validate(TunerHostInfo info)
{
using (await new M3uParser(Logger, _httpClientFactory).GetListingsStream(info, CancellationToken.None).ConfigureAwait(false))
{
}
}
protected override Task<List<MediaSourceInfo>> GetChannelStreamMediaSources(TunerHostInfo tuner, ChannelInfo channel, CancellationToken cancellationToken)
{
return Task.FromResult(new List<MediaSourceInfo> { CreateMediaSourceInfo(tuner, channel) });
}
protected virtual MediaSourceInfo CreateMediaSourceInfo(TunerHostInfo info, ChannelInfo channel)
{
var path = channel.Path;
var supportsDirectPlay = !info.EnableStreamLooping && info.TunerCount == 0;
var supportsDirectStream = !info.EnableStreamLooping;
var protocol = _mediaSourceManager.GetPathProtocol(path);
var isRemote = true;
if (Uri.TryCreate(path, UriKind.Absolute, out var uri))
{
isRemote = !_networkManager.IsInLocalNetwork(uri.Host);
}
var httpHeaders = new Dictionary<string, string>();
if (protocol == MediaProtocol.Http)
{
// Use user-defined user-agent. If there isn't one, make it look like a browser.
httpHeaders[HeaderNames.UserAgent] = string.IsNullOrWhiteSpace(info.UserAgent) ?
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/64.0.3282.85 Safari/537.36" :
info.UserAgent;
}
var mediaSource = new MediaSourceInfo
{
Path = path,
Protocol = protocol,
MediaStreams = new 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 = true,
RequiresClosing = true,
RequiresLooping = info.EnableStreamLooping,
ReadAtNativeFramerate = false,
Id = channel.Path.GetMD5().ToString("N", CultureInfo.InvariantCulture),
IsInfiniteStream = true,
IsRemote = isRemote,
IgnoreDts = info.IgnoreDts,
SupportsDirectPlay = supportsDirectPlay,
SupportsDirectStream = supportsDirectStream,
RequiredHttpHeaders = httpHeaders
};
mediaSource.InferTotalBitrate();
return mediaSource;
}
public Task<List<TunerHostInfo>> DiscoverDevices(int discoveryDurationMs, CancellationToken cancellationToken)
{
return Task.FromResult(new List<TunerHostInfo>());
}
}
}

View File

@@ -1,326 +0,0 @@
#nullable disable
#pragma warning disable CS1591
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Net.Http;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Extensions;
using MediaBrowser.Common.Extensions;
using MediaBrowser.Common.Net;
using MediaBrowser.Controller.LiveTv;
using MediaBrowser.Model.IO;
using MediaBrowser.Model.LiveTv;
using Microsoft.Extensions.Logging;
namespace Emby.Server.Implementations.LiveTv.TunerHosts
{
public partial class M3uParser
{
private const string ExtInfPrefix = "#EXTINF:";
private readonly ILogger _logger;
private readonly IHttpClientFactory _httpClientFactory;
public M3uParser(ILogger logger, IHttpClientFactory httpClientFactory)
{
_logger = logger;
_httpClientFactory = httpClientFactory;
}
[GeneratedRegex(@"([a-z0-9\-_]+)=\""([^""]+)\""", RegexOptions.IgnoreCase, "en-US")]
private static partial Regex KeyValueRegex();
public async Task<List<ChannelInfo>> Parse(TunerHostInfo info, string channelIdPrefix, CancellationToken cancellationToken)
{
// Read the file and display it line by line.
using (var reader = new StreamReader(await GetListingsStream(info, cancellationToken).ConfigureAwait(false)))
{
return await GetChannelsAsync(reader, channelIdPrefix, info.Id).ConfigureAwait(false);
}
}
public async Task<Stream> GetListingsStream(TunerHostInfo info, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(info);
if (!info.Url.StartsWith("http", StringComparison.OrdinalIgnoreCase))
{
return AsyncFile.OpenRead(info.Url);
}
using var requestMessage = new HttpRequestMessage(HttpMethod.Get, info.Url);
if (!string.IsNullOrEmpty(info.UserAgent))
{
requestMessage.Headers.UserAgent.TryParseAdd(info.UserAgent);
}
// Set HttpCompletionOption.ResponseHeadersRead to prevent timeouts on larger files
var response = await _httpClientFactory.CreateClient(NamedClient.Default)
.SendAsync(requestMessage, HttpCompletionOption.ResponseHeadersRead, cancellationToken)
.ConfigureAwait(false);
response.EnsureSuccessStatusCode();
return await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
}
private async Task<List<ChannelInfo>> GetChannelsAsync(TextReader reader, string channelIdPrefix, string tunerHostId)
{
var channels = new List<ChannelInfo>();
string extInf = string.Empty;
await foreach (var line in reader.ReadAllLinesAsync().ConfigureAwait(false))
{
var trimmedLine = line.Trim();
if (string.IsNullOrWhiteSpace(trimmedLine))
{
continue;
}
if (trimmedLine.StartsWith("#EXTM3U", StringComparison.OrdinalIgnoreCase))
{
continue;
}
if (trimmedLine.StartsWith(ExtInfPrefix, StringComparison.OrdinalIgnoreCase))
{
extInf = trimmedLine.Substring(ExtInfPrefix.Length).Trim();
}
else if (!string.IsNullOrWhiteSpace(extInf) && !trimmedLine.StartsWith('#'))
{
var channel = GetChannelnfo(extInf, tunerHostId, trimmedLine);
channel.Id = channelIdPrefix + trimmedLine.GetMD5().ToString("N", CultureInfo.InvariantCulture);
channel.Path = trimmedLine;
channels.Add(channel);
_logger.LogInformation("Parsed channel: {ChannelName}", channel.Name);
extInf = string.Empty;
}
}
return channels;
}
private ChannelInfo GetChannelnfo(string extInf, string tunerHostId, string mediaUrl)
{
var channel = new ChannelInfo()
{
TunerHostId = tunerHostId
};
extInf = extInf.Trim();
var attributes = ParseExtInf(extInf, out string remaining);
extInf = remaining;
if (attributes.TryGetValue("tvg-logo", out string tvgLogo))
{
channel.ImageUrl = tvgLogo;
}
else if (attributes.TryGetValue("logo", out string logo))
{
channel.ImageUrl = logo;
}
if (attributes.TryGetValue("group-title", out string groupTitle))
{
channel.ChannelGroup = groupTitle;
}
channel.Name = GetChannelName(extInf, attributes);
channel.Number = GetChannelNumber(extInf, attributes, mediaUrl);
attributes.TryGetValue("tvg-id", out string tvgId);
attributes.TryGetValue("channel-id", out string channelId);
channel.TunerChannelId = string.IsNullOrWhiteSpace(tvgId) ? channelId : tvgId;
var channelIdValues = new List<string>();
if (!string.IsNullOrWhiteSpace(channelId))
{
channelIdValues.Add(channelId);
}
if (!string.IsNullOrWhiteSpace(tvgId))
{
channelIdValues.Add(tvgId);
}
if (channelIdValues.Count > 0)
{
channel.Id = string.Join('_', channelIdValues);
}
return channel;
}
private string GetChannelNumber(string extInf, Dictionary<string, string> attributes, string mediaUrl)
{
var nameParts = extInf.Split(',', StringSplitOptions.RemoveEmptyEntries);
var nameInExtInf = nameParts.Length > 1 ? nameParts[^1].AsSpan().Trim() : ReadOnlySpan<char>.Empty;
string numberString = null;
if (attributes.TryGetValue("tvg-chno", out var attributeValue)
&& double.TryParse(attributeValue, CultureInfo.InvariantCulture, out _))
{
numberString = attributeValue;
}
if (!IsValidChannelNumber(numberString))
{
if (attributes.TryGetValue("tvg-id", out attributeValue))
{
if (double.TryParse(attributeValue, CultureInfo.InvariantCulture, out _))
{
numberString = attributeValue;
}
else if (attributes.TryGetValue("channel-id", out attributeValue)
&& double.TryParse(attributeValue, CultureInfo.InvariantCulture, out _))
{
numberString = attributeValue;
}
}
if (string.IsNullOrWhiteSpace(numberString))
{
// Using this as a fallback now as this leads to Problems with channels like "5 USA"
// where 5 isn't meant to be the channel number
// Check for channel number with the format from SatIp
// #EXTINF:0,84. VOX Schweiz
// #EXTINF:0,84.0 - VOX Schweiz
if (!nameInExtInf.IsEmpty && !nameInExtInf.IsWhiteSpace())
{
var numberIndex = nameInExtInf.IndexOf(' ');
if (numberIndex > 0)
{
var numberPart = nameInExtInf.Slice(0, numberIndex).Trim(new[] { ' ', '.' });
if (double.TryParse(numberPart, CultureInfo.InvariantCulture, out _))
{
numberString = numberPart.ToString();
}
}
}
}
}
if (!IsValidChannelNumber(numberString))
{
numberString = null;
}
if (!string.IsNullOrWhiteSpace(numberString))
{
numberString = numberString.Trim();
}
else
{
if (string.IsNullOrWhiteSpace(mediaUrl))
{
numberString = null;
}
else
{
try
{
numberString = Path.GetFileNameWithoutExtension(mediaUrl.AsSpan().RightPart('/')).ToString();
if (!IsValidChannelNumber(numberString))
{
numberString = null;
}
}
catch
{
// Seeing occasional argument exception here
numberString = null;
}
}
}
return numberString;
}
private static bool IsValidChannelNumber(string numberString)
{
if (string.IsNullOrWhiteSpace(numberString)
|| string.Equals(numberString, "-1", StringComparison.Ordinal)
|| string.Equals(numberString, "0", StringComparison.Ordinal))
{
return false;
}
return double.TryParse(numberString, CultureInfo.InvariantCulture, out _);
}
private static string GetChannelName(string extInf, Dictionary<string, string> attributes)
{
var nameParts = extInf.Split(',', StringSplitOptions.RemoveEmptyEntries);
var nameInExtInf = nameParts.Length > 1 ? nameParts[^1].Trim() : null;
// Check for channel number with the format from SatIp
// #EXTINF:0,84. VOX Schweiz
// #EXTINF:0,84.0 - VOX Schweiz
if (!string.IsNullOrWhiteSpace(nameInExtInf))
{
var numberIndex = nameInExtInf.IndexOf(' ', StringComparison.Ordinal);
if (numberIndex > 0)
{
var numberPart = nameInExtInf.Substring(0, numberIndex).Trim(new[] { ' ', '.' });
if (double.TryParse(numberPart, CultureInfo.InvariantCulture, out _))
{
// channel.Number = number.ToString();
nameInExtInf = nameInExtInf.Substring(numberIndex + 1).Trim(new[] { ' ', '-' });
}
}
}
string name = nameInExtInf;
if (string.IsNullOrWhiteSpace(name))
{
attributes.TryGetValue("tvg-name", out name);
}
if (string.IsNullOrWhiteSpace(name))
{
attributes.TryGetValue("tvg-id", out name);
}
if (string.IsNullOrWhiteSpace(name))
{
name = null;
}
return name;
}
private static Dictionary<string, string> ParseExtInf(string line, out string remaining)
{
var dict = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
var matches = KeyValueRegex().Matches(line);
remaining = line;
foreach (Match match in matches)
{
var key = match.Groups[1].Value;
var value = match.Groups[2].Value;
dict[key] = value;
remaining = remaining.Replace(key + "=\"" + value + "\"", string.Empty, StringComparison.OrdinalIgnoreCase);
}
return dict;
}
}
}

View File

@@ -1,134 +0,0 @@
#pragma warning disable CS1591
using System;
using System.Globalization;
using System.IO;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using MediaBrowser.Common.Configuration;
using MediaBrowser.Common.Net;
using MediaBrowser.Controller;
using MediaBrowser.Controller.Library;
using MediaBrowser.Model.Dto;
using MediaBrowser.Model.IO;
using MediaBrowser.Model.LiveTv;
using MediaBrowser.Model.MediaInfo;
using Microsoft.Extensions.Logging;
namespace Emby.Server.Implementations.LiveTv.TunerHosts
{
public class SharedHttpStream : LiveStream, IDirectStreamProvider
{
private readonly IHttpClientFactory _httpClientFactory;
private readonly IServerApplicationHost _appHost;
public SharedHttpStream(
MediaSourceInfo mediaSource,
TunerHostInfo tunerHostInfo,
string originalStreamId,
IFileSystem fileSystem,
IHttpClientFactory httpClientFactory,
ILogger logger,
IConfigurationManager configurationManager,
IServerApplicationHost appHost,
IStreamHelper streamHelper)
: base(mediaSource, tunerHostInfo, fileSystem, logger, configurationManager, streamHelper)
{
_httpClientFactory = httpClientFactory;
_appHost = appHost;
OriginalStreamId = originalStreamId;
}
public override async Task Open(CancellationToken openCancellationToken)
{
LiveStreamCancellationTokenSource.Token.ThrowIfCancellationRequested();
var mediaSource = OriginalMediaSource;
var url = mediaSource.Path;
Directory.CreateDirectory(Path.GetDirectoryName(TempFilePath) ?? throw new InvalidOperationException("Path can't be a root directory."));
var typeName = GetType().Name;
Logger.LogInformation("Opening {StreamType} Live stream from {Url}", typeName, url);
// Response stream is disposed manually.
var response = await _httpClientFactory.CreateClient(NamedClient.Default)
.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, CancellationToken.None)
.ConfigureAwait(false);
var taskCompletionSource = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
_ = StartStreaming(response, taskCompletionSource, LiveStreamCancellationTokenSource.Token);
MediaSource.Path = _appHost.GetApiUrlForLocalAccess() + "/LiveTv/LiveStreamFiles/" + UniqueId + "/stream.ts";
MediaSource.Protocol = MediaProtocol.Http;
var res = await taskCompletionSource.Task.ConfigureAwait(false);
if (!res)
{
Logger.LogWarning("Zero bytes copied from stream {StreamType} to {FilePath} but no exception raised", GetType().Name, TempFilePath);
throw new EndOfStreamException(string.Format(CultureInfo.InvariantCulture, "Zero bytes copied from stream {0}", GetType().Name));
}
}
private Task StartStreaming(HttpResponseMessage response, TaskCompletionSource<bool> openTaskCompletionSource, CancellationToken cancellationToken)
{
return Task.Run(
async () =>
{
try
{
Logger.LogInformation("Beginning {StreamType} stream to {FilePath}", GetType().Name, TempFilePath);
using (response)
{
var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
await using (stream.ConfigureAwait(false))
{
var fileStream = new FileStream(
TempFilePath,
FileMode.Create,
FileAccess.Write,
FileShare.Read,
IODefaults.FileStreamBufferSize,
FileOptions.Asynchronous);
await using (fileStream.ConfigureAwait(false))
{
await StreamHelper.CopyToAsync(
stream,
fileStream,
IODefaults.CopyToBufferSize,
() => Resolve(openTaskCompletionSource),
cancellationToken).ConfigureAwait(false);
}
}
}
}
catch (OperationCanceledException ex)
{
Logger.LogInformation("Copying of {StreamType} to {FilePath} was canceled", GetType().Name, TempFilePath);
openTaskCompletionSource.TrySetException(ex);
}
catch (Exception ex)
{
Logger.LogError(ex, "Error copying live stream {StreamType} to {FilePath}", GetType().Name, TempFilePath);
openTaskCompletionSource.TrySetException(ex);
}
openTaskCompletionSource.TrySetResult(false);
EnableStreamSharing = false;
await DeleteTempFiles(TempFilePath).ConfigureAwait(false);
},
CancellationToken.None);
}
private void Resolve(TaskCompletionSource<bool> openTaskCompletionSource)
{
DateOpened = DateTime.UtcNow;
openTaskCompletionSource.TrySetResult(true);
}
}
}