mirror of
https://github.com/jellyfin/jellyfin.git
synced 2026-04-21 01:24:44 +01:00
Merge pull request #10838 from barronpm/livetv-project
Move Live TV code to Jellyfin.LiveTv
This commit is contained in:
@@ -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>();
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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" />
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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>();
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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>();
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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>();
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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>();
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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>();
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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>();
|
||||
}
|
||||
}
|
||||
@@ -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>();
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user