diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..149f4a2 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,56 @@ +name: Release + +on: + workflow_dispatch: # run manually only + +permissions: + contents: write + +jobs: + + publish: + name: Build and publish release + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + - name: Get release version + run: | + grep -Eo "(\ ver.tmp + sed -E "s/.*[^0-9.]([0-9.]+)$/X_VERSION=\1/" ver.tmp >> $GITHUB_ENV + - name: Setup .NET + uses: actions/setup-dotnet@v3 + with: + dotnet-version: 8.0.x + - name: Build win-x86 + run: dotnet publish -c Release -r win-x86 --sc false -p DebugType=embedded -p PublishSingleFile=true + - name: Build win-x64 + run: dotnet publish -c Release -r win-x64 --sc false -p DebugType=embedded -p PublishSingleFile=true + - name: Build win-arm64 + run: dotnet publish -c Release -r win-arm64 --sc false -p DebugType=embedded -p PublishSingleFile=true + - name: Build linux-x64 + run: dotnet publish -c Release -r linux-x64 --sc false -p DebugType=embedded -p PublishSingleFile=true + - name: Build linux-arm + run: dotnet publish -c Release -r linux-arm --sc false -p DebugType=embedded -p PublishSingleFile=true + - name: Build linux-arm64 + run: dotnet publish -c Release -r linux-arm64 --sc false -p DebugType=embedded -p PublishSingleFile=true + - name: Build osx-x64 + run: dotnet publish -c Release -r osx-x64 --sc false -p DebugType=embedded -p PublishSingleFile=true + - name: Build osx-arm64 + run: dotnet publish -c Release -r osx-arm64 --sc false -p DebugType=embedded -p PublishSingleFile=true + - name: Create release assets + run: | + for folder in FixHdhrAspect/bin/Release/*/*/; do + platform=$(basename "$folder") + publish=${folder%/}/publish + zip -vr "FixHdhrAspect-$X_VERSION-${platform}.zip" -j "$publish"/* -x "*.Development.*" + done + - name: Update release + uses: "marvinpinto/action-automatic-releases@6273874b61ebc8c71f1a61b2d98e234cf389b303" + with: + repo_token: "${{ secrets.GITHUB_TOKEN }}" + prerelease: false + files: FixHdhrAspect-*.zip + automatic_release_tag: latest + title: ${{ env.X_VERSION }} diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 0000000..e39e488 --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,31 @@ + + + + + HDHomeRun Proxy Service + This service functions as a proxy for an HDHomeRun device and fixes the aspect ratio on certain MPEG streams. + Jonathan Duke Software, LLC + Copyright © $([System.String]::Format("2023-{0:yyyy}",$([System.DateTime]::Now)).Replace("2023-2023", "2023")) $(Company) + Jonathan Duke + + + + 1.0.0 + + + + + $(AssemblyName) + + + + + $(Configuration.ToLower()) + + + diff --git a/Directory.Build.targets b/Directory.Build.targets new file mode 100644 index 0000000..9ff83a6 --- /dev/null +++ b/Directory.Build.targets @@ -0,0 +1,28 @@ + + + + + + $(VersionPrefix.Substring(0,$(VersionPrefix.IndexOf('.')))).0.0.0 + + + + + + + + + + + + + $(SolutionDir)=$(SolutionName)\$(SourceRevisionId) + + + + diff --git a/FixHdhrAspect.sln b/FixHdhrAspect.sln new file mode 100644 index 0000000..6a9320f --- /dev/null +++ b/FixHdhrAspect.sln @@ -0,0 +1,33 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.8.34322.80 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "HDHomeRun Proxy Service", "HDHomeRun Proxy Service", "{55B7CD7B-087E-4566-AE54-1E4F559FFF44}" + ProjectSection(SolutionItems) = preProject + Directory.Build.props = Directory.Build.props + Directory.Build.targets = Directory.Build.targets + LICENSE = LICENSE + README.md = README.md + EndProjectSection +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "FixHdhrAspect", "FixHdhrAspect\FixHdhrAspect.csproj", "{346F01F1-E2C1-43CE-B3EA-9E28A4F70E6F}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {346F01F1-E2C1-43CE-B3EA-9E28A4F70E6F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {346F01F1-E2C1-43CE-B3EA-9E28A4F70E6F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {346F01F1-E2C1-43CE-B3EA-9E28A4F70E6F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {346F01F1-E2C1-43CE-B3EA-9E28A4F70E6F}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {562B520D-F997-494C-9E6B-60FE7EC5CD4F} + EndGlobalSection +EndGlobal diff --git a/FixHdhrAspect/DeviceIdentifier.cs b/FixHdhrAspect/DeviceIdentifier.cs new file mode 100644 index 0000000..5f8e817 --- /dev/null +++ b/FixHdhrAspect/DeviceIdentifier.cs @@ -0,0 +1,80 @@ +using System.Text.RegularExpressions; + +namespace JonathanDuke.FixHdhrAspect; + +public partial class DeviceIdentifier +{ + [GeneratedRegex("^[0-9A-F]{8}$", RegexOptions.IgnoreCase | RegexOptions.Compiled, "en-US")] + private static partial Regex DeviceIdRegex(); + private static readonly Regex _rxDeviceId = DeviceIdRegex(); + private static readonly byte[] _checksumLookupTable = [0xA, 0x5, 0xF, 0x6, 0x7, 0xC, 0x1, 0xB, 0x9, 0x2, 0x8, 0xD, 0x4, 0x3, 0xE, 0x0]; + + private uint _value; + + public DeviceIdentifier(uint deviceId) + { + _value = deviceId; + } + + public bool Validate() + { + return CalculateChecksum(_value) == 0; + } + + internal static uint CalculateChecksum(uint value) + { + // checksum algorithm from: https://github.com/Silicondust/libhdhomerun/blob/master/hdhomerun_discover.c#L1773 + byte checksum = 0; + checksum ^= _checksumLookupTable[(value >> 28) & 0x0F]; + checksum ^= (byte)((value >> 24) & 0x0F); + checksum ^= _checksumLookupTable[(value >> 20) & 0x0F]; + checksum ^= (byte)((value >> 16) & 0x0F); + checksum ^= _checksumLookupTable[(value >> 12) & 0x0F]; + checksum ^= (byte)((value >> 8) & 0x0F); + checksum ^= _checksumLookupTable[(value >> 4) & 0x0F]; + checksum ^= (byte)((value >> 0) & 0x0F); + return checksum; + } + + public static explicit operator uint(DeviceIdentifier id) => id._value; + + public static DeviceIdentifier Parse(string value) + { + if (value.Length != 8 || !_rxDeviceId.IsMatch(value)) throw new ArgumentException("The device ID should be 8 hex digits.", nameof(value)); + return ParseInternal(value); + } + + private static DeviceIdentifier ParseInternal(string value) + { + uint parsedId = 0; + + for (int i = 0; i < value.Length; i += 2) + { + parsedId <<= 8; + parsedId |= (byte)int.Parse(value.Substring(i, 2), System.Globalization.NumberStyles.HexNumber); + } + + return new DeviceIdentifier(parsedId); + } + + public static bool TryParse(string value, out DeviceIdentifier? instance) + { + if (value.Length == 8 && _rxDeviceId.IsMatch(value)) + { + try + { + instance = ParseInternal(value); + return true; + } + catch { } + } + + instance = null; + return false; + } + + public override string ToString() + { + return BitConverter.ToString(BitConverter.GetBytes(_value).Reverse().ToArray()).Replace("-", ""); + } +} diff --git a/FixHdhrAspect/FixHdhrAspect.csproj b/FixHdhrAspect/FixHdhrAspect.csproj new file mode 100644 index 0000000..4e99d67 --- /dev/null +++ b/FixHdhrAspect/FixHdhrAspect.csproj @@ -0,0 +1,14 @@ + + + + net8.0 + enable + enable + JonathanDuke.FixHdhrAspect + + + + + + + diff --git a/FixHdhrAspect/MpegAspectRatio.Extensions.cs b/FixHdhrAspect/MpegAspectRatio.Extensions.cs new file mode 100644 index 0000000..ce737ea --- /dev/null +++ b/FixHdhrAspect/MpegAspectRatio.Extensions.cs @@ -0,0 +1,24 @@ +namespace JonathanDuke.FixHdhrAspect; + +public static class AspectRatioExtensions +{ + public static string ToRatioString(this MpegAspectRatio value) + { + if (value == MpegAspectRatio.Default) + { + return value.ToString(); + } + + return value.ToString().Replace("x", ":").Replace("_", ".").TrimStart('.'); + } + + public static string FromAspectRatio(this string aspectRatioString) + { + if (string.IsNullOrEmpty(aspectRatioString) || string.Equals(aspectRatioString, MpegAspectRatio.Default.ToString(), StringComparison.OrdinalIgnoreCase)) + { + return MpegAspectRatio.Default.ToString(); + } + + return "_" + aspectRatioString.Replace(".", "_").Replace(":", "x"); + } +} diff --git a/FixHdhrAspect/MpegAspectRatio.cs b/FixHdhrAspect/MpegAspectRatio.cs new file mode 100644 index 0000000..8b09a8f --- /dev/null +++ b/FixHdhrAspect/MpegAspectRatio.cs @@ -0,0 +1,29 @@ +namespace JonathanDuke.FixHdhrAspect; + +/// +/// Supported aspect ratio values in the MPEG specification. +/// +/// +public enum MpegAspectRatio : byte +{ + /// + /// Do not override the aspect ratio. + /// + Default = 0, + /// + /// Override the stream with a 1:1 aspect ratio. + /// + _1x1 = 1, + /// + /// Override the stream with a 4:3 aspect ratio. + /// + _4x3 = 2, + /// + /// Override the stream with a 16:9 aspect ratio. + /// + _16x9 = 3, + /// + /// Override the stream with a 2.21:1 aspect ratio. + /// + _2_21x1 = 4, +} diff --git a/FixHdhrAspect/Program.cs b/FixHdhrAspect/Program.cs new file mode 100644 index 0000000..b1b49bd --- /dev/null +++ b/FixHdhrAspect/Program.cs @@ -0,0 +1,28 @@ +// Template: https://learn.microsoft.com/en-us/dotnet/core/extensions/windows-service?pivots=dotnet-7-0#rewrite-the-program-class + +using JonathanDuke.FixHdhrAspect; +using Microsoft.Extensions.Logging.Configuration; +using Microsoft.Extensions.Logging.EventLog; + +HostApplicationBuilder builder = Host.CreateApplicationBuilder(args); +builder.Services.AddWindowsService(options => +{ + options.ServiceName = "HDHomeRun Proxy Service"; +}); + +// EventLog is only supported on Windows: https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/quality-rules/ca1416#how-to-fix-violations +if (OperatingSystem.IsWindows()) +{ + LoggerProviderOptions.RegisterProviderOptions< + EventLogSettings, EventLogLoggerProvider>(builder.Services); +} + +builder.Services.AddSingleton(); +builder.Services.AddHostedService(); + +// See: https://github.com/dotnet/runtime/issues/47303 +builder.Logging.AddConfiguration( + builder.Configuration.GetSection("Logging")); + +IHost host = builder.Build(); +host.Run(); diff --git a/FixHdhrAspect/Properties/launchSettings.json b/FixHdhrAspect/Properties/launchSettings.json new file mode 100644 index 0000000..82444ba --- /dev/null +++ b/FixHdhrAspect/Properties/launchSettings.json @@ -0,0 +1,11 @@ +{ + "profiles": { + "JonathanDuke.FixHdhrAspect": { + "commandName": "Project", + "dotnetRunMessages": true, + "environmentVariables": { + "DOTNET_ENVIRONMENT": "Development" + } + } + } +} diff --git a/FixHdhrAspect/ProxyHttpStream.cs b/FixHdhrAspect/ProxyHttpStream.cs new file mode 100644 index 0000000..8821591 --- /dev/null +++ b/FixHdhrAspect/ProxyHttpStream.cs @@ -0,0 +1,144 @@ +using System.Net.Sockets; +using System.Text; +using System.Text.RegularExpressions; + +namespace JonathanDuke.FixHdhrAspect; + +public partial class ProxyHttpStream : ProxyStream +{ + [GeneratedRegex(@"(?<=^\s*Host:\s+)(?(?[^:\r\n]+)(:(?\d+))?)", RegexOptions.IgnoreCase | RegexOptions.Multiline | RegexOptions.Compiled)] + private static partial Regex HostHeaderRegex(); + private static readonly Regex _rxHostHeader = HostHeaderRegex(); + + [GeneratedRegex(@"(?<=^\s*Referer:\s+http(s)?://+)[^/\r\n]+", RegexOptions.IgnoreCase | RegexOptions.Multiline | RegexOptions.Compiled)] + private static partial Regex ReferrerHeaderRegex(); + private static readonly Regex _rxRefererHeader = ReferrerHeaderRegex(); + + [GeneratedRegex(@"(?<=^\s*Content-Length:\s+)(?\d+)", RegexOptions.IgnoreCase | RegexOptions.Multiline | RegexOptions.Compiled)] + private static partial Regex ContentLengthHeaderRegex(); + private static readonly Regex _rxContentLengthHeader = ContentLengthHeaderRegex(); + + public ProxyHttpStream(ProxyService service, ILogger logger) + : base(service, logger) + { + } + + protected override bool HttpBuffering => true; + + protected override async Task ConnectAsync(TcpClient client, CancellationToken cancellationToken) + { + await client.ConnectAsync(_service!.DeviceHostName, _service!.DeviceWebPort, cancellationToken); + } + + protected override void InspectRequest(ref byte[] buffer, ref int count) + { + var altered = false; + var request = Encoding.UTF8.GetString(buffer, 0, count); +#if DEBUG + _logger.LogDebug("Original HTTP Request:\n{Request}", request); +#endif + if (_service.ServiceHostName == null) + { + var match = _rxHostHeader.Match(request); + + if (match.Success && _service.LastHostName != match.Groups["Host"].Value) + { + _service.LastHostName = match.Groups["Host"].Value; +#if DEBUG + _logger.LogDebug("Service Host Name: {Host}", _service.LastHostName); +#endif + } + } + + var hostReplace = ProxyService.RegexPort80Suffix.Replace(_service!.DeviceHostName + ":" + _service!.DeviceWebPort, ""); + request = _rxRefererHeader.Replace(_rxHostHeader.Replace(request, (match) => + { + altered = true; + return hostReplace; + }), (match) => + { + altered = true; + return hostReplace; + }); + + if (altered) + { +#if DEBUG + _logger.LogDebug("Modified HTTP Request:\n{Request}", request); +#endif + buffer = Encoding.UTF8.GetBytes(request, 0, request.Length); + count = buffer.Length; + } + } + + protected override void InspectResponse(ref byte[] buffer, ref int count) + { + var altered = false; + var response = Encoding.UTF8.GetString(buffer, 0, count); + + if (_service.OriginalDeviceId == null) + { + _service.DetectOriginalDeviceId(response); + } + +#if DEBUG + _logger.LogDebug("Original HTTP Response:\n{Response}", response); +#endif + var hostName = _service!.ServiceHostName ?? _service.LastHostName ?? _service.ServiceEndpoint.ToString(); + response = _service!.HostReplaceRegex.Replace(response, (match) => + { + if (match.Groups["Port"].Value == _service.DeviceCapturePort.ToString()) + { + if (_service.ProxyAllChannels || (match.Groups["Channel"].Success && _service.Channels.TryGetValue(match.Groups["Channel"].Value, out MpegAspectRatio ratio) && ratio != MpegAspectRatio.Default)) + { + altered = true; + return string.Concat(hostName, ':', _service.ServiceCapturePort, match.Groups["Path"].Value); + } + } + else // this is a web URL, not a capture URL + { + altered = true; + return ProxyService.RegexPort80Suffix.Replace(string.Concat(hostName, ':', _service.ServiceWebPort), ""); + } + + return match.Value; + }); + + if (_service.OriginalDeviceRegex != null && _service.OriginalDeviceId != null) + { + response = _service.OriginalDeviceRegex.Replace(response, (match) => + { + altered = true; + return _service.ProxyDeviceId!.ToString(); + }); + } + + if (altered) + { + int oldCount = count; + buffer = Encoding.UTF8.GetBytes(response, 0, response.Length); + int newCount = count = buffer.Length; + + response = _rxContentLengthHeader.Replace(response, (match) => + { + int oldValue = int.Parse(match.Groups["Length"].Value); + int newValue = oldValue + newCount - oldCount; + string replacement = newValue.ToString(); + + if (replacement.Length != match.Groups["Length"].Value.Length) + { + newValue += replacement.Length - match.Groups["Length"].Value.Length; + replacement = newValue.ToString(); + } + + return replacement; + }); + + buffer = Encoding.UTF8.GetBytes(response, 0, response.Length); + count = buffer.Length; +#if DEBUG + _logger.LogDebug("Modified HTTP Response:\n{Response}", response); +#endif + } + } +} diff --git a/FixHdhrAspect/ProxyService.cs b/FixHdhrAspect/ProxyService.cs new file mode 100644 index 0000000..3e692ae --- /dev/null +++ b/FixHdhrAspect/ProxyService.cs @@ -0,0 +1,303 @@ +using System.Collections.Concurrent; +using System.Net; +using System.Net.Sockets; +using System.Text; +using System.Text.RegularExpressions; + +namespace JonathanDuke.FixHdhrAspect; + +public partial class ProxyService : IDisposable +{ + [GeneratedRegex(":80$", RegexOptions.Compiled)] + private static partial Regex Port80SuffixRegex(); + internal static readonly Regex RegexPort80Suffix = Port80SuffixRegex(); + + [GeneratedRegex(@"(?<=\bDevice\s*ID\""*\s*:\s*\""*)[0-9A-F]+", RegexOptions.Multiline | RegexOptions.Compiled)] + private static partial Regex DeviceIdRegex(); + internal static readonly Regex RegexDeviceId = DeviceIdRegex(); + + [GeneratedRegex(@"^\s*0*([1-9]\d*)\s*[-.]\s*([1-9]\d*)\s*$", RegexOptions.Compiled)] + private static partial Regex ChannelRegex(); + private static readonly Regex _rxChannel = ChannelRegex(); + + private const string DefaultDeviceHostName = "hdhomerun.local"; + private const int DefaultDeviceCapturePort = 5004; + private const int DefaultDeviceWebPort = 80; + private const string DefaultServiceEndpoint = "0.0.0.0"; + + private bool _isDisposed; + private readonly ConcurrentDictionary _requests = new(); + protected readonly ILogger _logger; + + public string DeviceHostName { get; private set; } + public int DeviceCapturePort { get; private set; } + public int DeviceWebPort { get; private set; } + public string? ServiceHostName { get; private set; } + public IPAddress ServiceEndpoint { get; private set; } + public IPAddress? ServiceEndpointIPv6 { get; private set; } + public int ServiceCapturePort { get; private set; } + public int ServiceWebPort { get; private set; } + public bool ProxyAllChannels { get; private set; } + + internal readonly Regex HostReplaceRegex; + + internal IReadOnlyDictionary Channels { get; private set; } + internal string? LastHostName { get; set; } + internal string? OriginalDeviceId { get; private set; } + internal Regex? OriginalDeviceRegex { get; private set; } + + internal DeviceIdentifier? ProxyDeviceId + { + get; + private set; + } + + public ProxyService(IConfiguration configuration, ILogger logger) + { + _logger = logger; + var settings = configuration.GetSection("Settings"); + DeviceHostName = settings.GetValue(nameof(DeviceHostName)) is string deviceHostName && !string.IsNullOrWhiteSpace(deviceHostName) ? deviceHostName : DefaultDeviceHostName; + DeviceCapturePort = settings.GetValue(nameof(DeviceCapturePort), DefaultDeviceCapturePort); + DeviceWebPort = settings.GetValue(nameof(DeviceWebPort), DefaultDeviceWebPort); + ServiceHostName = settings.GetValue(nameof(ServiceHostName)) is string serviceHostName && !string.IsNullOrWhiteSpace(serviceHostName) ? serviceHostName : null; + ServiceEndpoint = IPAddress.Parse(settings.GetValue(nameof(ServiceEndpoint)) is string serviceEndpoint && !string.IsNullOrWhiteSpace(serviceEndpoint) ? serviceEndpoint : DefaultServiceEndpoint); + + if (ServiceEndpoint.AddressFamily != AddressFamily.InterNetworkV6) + { + // if the service endpoint is IPv4, we can also add a secondary IPv6 address + if (settings.GetValue(nameof(ServiceEndpointIPv6)) is string value && !string.IsNullOrWhiteSpace(value)) + { + if (IPAddress.TryParse(value, out IPAddress? ipv6) && ipv6!.AddressFamily == AddressFamily.InterNetworkV6) + { + ServiceEndpointIPv6 = ipv6; + } + } + } + + // if the capture/web ports are not set for the service, it is assumed they should match the device ports + ServiceCapturePort = settings.GetValue(nameof(ServiceCapturePort), DeviceCapturePort); + ServiceWebPort = settings.GetValue(nameof(ServiceWebPort), DeviceWebPort); + ProxyAllChannels = settings.GetValue(nameof(ProxyAllChannels), false); + + _logger.LogInformation("Settings:\n\t{Key1}={Value1}\n\t{Key2}={Value2}\n\t{Key3}={Value3}\n\t{Key4}={Value4}\n\t{Key5}={Value5}\n\t{Key6}={Value6}\n\t{Key7}={Value7}\n\t{Key8}={Value8}\n\t{Key9}={Value9}", + nameof(DeviceHostName), DeviceHostName, + nameof(DeviceCapturePort), DeviceCapturePort, + nameof(DeviceWebPort), DeviceWebPort, + nameof(ServiceHostName), ServiceHostName, + nameof(ServiceEndpoint), ServiceEndpoint, + nameof(ServiceEndpointIPv6), ServiceEndpointIPv6, + nameof(ServiceCapturePort), ServiceCapturePort, + nameof(ServiceWebPort), ServiceWebPort, + nameof(ProxyAllChannels), ProxyAllChannels); + + HostReplaceRegex = new Regex(@"(hdhr-[0-9a-z]+\.local|" + Regex.Escape(DeviceHostName) + @")(:(?\d+))?(?/([\w.-]+/)*v(?\d+\.\d+))?", RegexOptions.Compiled); + + var channels = new Dictionary(); + var channelList = new StringBuilder(); + + foreach (var pair in configuration.GetRequiredSection("Settings:Channels").Get>()!) + { + string key = _rxChannel.IsMatch(pair.Key) ? _rxChannel.Replace(pair.Key, "$1.$2") : string.Empty; + + if (!Enum.TryParse(pair.Value.FromAspectRatio(), out MpegAspectRatio value)) + { + value = MpegAspectRatio.Default; + _logger.LogWarning("Invalid channel override setting ignored: {Channel} => {Aspect}", pair.Key, pair.Value); + } + + if (!string.IsNullOrEmpty(key) && value != MpegAspectRatio.Default) + { + channels.Add(key, value); + channelList.Append($"\n\t{key} => {value.ToRatioString()}"); + } + } + + if (channelList.Length > 0) + { + _logger.LogInformation("Channels to override:{List}", channelList); + } + else + { + _logger.LogWarning("No valid channel overrides are defined. The service will still run, but no streams will be altered."); + } + + Channels = channels; + _ = DetectOriginalDeviceId(); + } + + private async Task DetectOriginalDeviceId() + { + using var http = new HttpClient(); + + try + { + var response = await http.GetStringAsync(RegexPort80Suffix.Replace($"http://{DeviceHostName}:{DeviceWebPort}/", "")); + DetectOriginalDeviceId(response); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to detect device ID on startup."); + } + } + + internal void DetectOriginalDeviceId(string htmlResponse) + { + if (OriginalDeviceId == null) + { + var match = RegexDeviceId.Match(htmlResponse); + + if (match.Success) + { + OriginalDeviceId = match.Value; +#if DEBUG + _logger.LogDebug("Device ID: {Device}", OriginalDeviceId); +#endif + OriginalDeviceRegex = new Regex(@"(?(Func streamFactory, IEnumerable listeners, CancellationToken cancellationToken) where T : ProxyStream + { + ObjectDisposedException.ThrowIf(_isDisposed, typeof(ProxyService)); + + foreach (var listener in listeners) + { + listener.Start(); + } + + try + { + while (!cancellationToken.IsCancellationRequested) + { + bool nop = true; + + foreach (var listener in listeners) + { + if (listener.Pending()) + { + try + { + nop = false; + var stream = streamFactory(); + var client = await listener.AcceptTcpClientAsync(cancellationToken); + _requests.TryAdd(stream.OpenAsync(client, cancellationToken), stream); + } + catch (TaskCanceledException) + { + throw; + } + catch (Exception ex) + { + _logger.LogError(ex, "Unexpected TCP listener error."); + } + } + } + + if (nop) + { + DisposeFinishedRequests(); + await Task.Delay(1, cancellationToken); + } + } + } + catch (TaskCanceledException) + { + } + finally + { + foreach (var listener in listeners) + { + listener.Stop(); + } + } + } + + private void DisposeFinishedRequests() + { + if (_requests?.Count > 0) + { + var completed = _requests.Where(r => true == r.Key?.IsCompleted).ToArray(); + + foreach (var request in completed) + { + if (_requests.TryRemove(request.Key, out _)) + { + request.Value.Close(); + request.Key.Dispose(); + } + } + } + } + + public Task StartCaptureAsync(CancellationToken cancellationToken) + { + var listeners = new List + { + new(ServiceEndpoint, ServiceCapturePort) + }; + + if (ServiceEndpointIPv6 != null) + { + listeners.Add(new TcpListener(ServiceEndpointIPv6, ServiceCapturePort)); + } + + return TcpListenAsync(() => new ProxyVideoStream(this, _logger), listeners, cancellationToken); + } + + public Task StartWebAsync(CancellationToken cancellationToken) + { + var listeners = new List + { + new(ServiceEndpoint, ServiceWebPort) + }; + + if (ServiceEndpointIPv6 != null) + { + listeners.Add(new TcpListener(ServiceEndpointIPv6, ServiceWebPort)); + } + + return TcpListenAsync(() => new ProxyHttpStream(this, _logger), listeners, cancellationToken); + } + + protected virtual void Dispose(bool disposing) + { + if (!_isDisposed) + { + if (disposing) + { + DisposeFinishedRequests(); + } + + _requests?.Clear(); + _isDisposed = true; + } + } + + public void Dispose() + { + Dispose(disposing: true); + GC.SuppressFinalize(this); + } +} diff --git a/FixHdhrAspect/ProxyStream.cs b/FixHdhrAspect/ProxyStream.cs new file mode 100644 index 0000000..5942b18 --- /dev/null +++ b/FixHdhrAspect/ProxyStream.cs @@ -0,0 +1,181 @@ +using System.Net.Sockets; + +namespace JonathanDuke.FixHdhrAspect; + +public abstract class ProxyStream : IDisposable +{ + private bool _isDisposed; + private TcpClient? _client; + private NetworkStream? _clientStream; + private TcpClient? _device; + private NetworkStream? _deviceStream; + + protected readonly ProxyService _service; + protected readonly ILogger _logger; + + protected virtual bool HttpBuffering => false; + protected virtual bool DisconnectAfterFirstRead => false; + + protected ProxyStream(ProxyService service, ILogger logger) + { + _service = service; + _logger = logger; + } + + public async Task OpenAsync(TcpClient client, CancellationToken cancellationToken) + { + ObjectDisposedException.ThrowIf(_isDisposed, typeof(ProxyStream)); + + _client = client; + await StreamAsync(cancellationToken); + } + + protected abstract Task ConnectAsync(TcpClient client, CancellationToken cancellationToken); + + private async Task StreamAsync(CancellationToken cancellationToken) + { + _clientStream = _client!.GetStream(); + await ConnectAsync(_device = new TcpClient(), cancellationToken); + + if (!cancellationToken.IsCancellationRequested && _device.Connected) + { + _deviceStream = _device.GetStream(); + + MemoryStream? responseBuffer = HttpBuffering ? new MemoryStream() : null; + var buffer = new byte[4096]; + var count = await _clientStream.ReadAsync(buffer, cancellationToken); + + if (!cancellationToken.IsCancellationRequested && count > 0) + { + InspectRequest(ref buffer, ref count); + await _deviceStream.WriteAsync(buffer.AsMemory(0, count), cancellationToken); + } + + while (!cancellationToken.IsCancellationRequested) + { + bool deviceDisconnected = false; + + if (!_device.Connected) + { + deviceDisconnected = true; + } + else + { + try + { + count = await _deviceStream.ReadAsync(buffer, cancellationToken); + + if (HttpBuffering) + { + responseBuffer!.Write(buffer, 0, count); + } + + if (!cancellationToken.IsCancellationRequested && count == 0) + { + deviceDisconnected = true; + } + } + catch (IOException) + { + deviceDisconnected = true; + } + } + + if (HttpBuffering) + { + if (!deviceDisconnected) + { + continue; + } + else if (responseBuffer != null) + { + buffer = responseBuffer!.ToArray(); + count = buffer.Length; + responseBuffer.Close(); + responseBuffer = null; + } + else + { + break; + } + } + else if (deviceDisconnected) + { + OnLostDeviceConnection(); + _client.Close(); + break; + } + else if (DisconnectAfterFirstRead) + { + _device.Close(); + } + + if (!cancellationToken.IsCancellationRequested && _clientStream.CanWrite) + { + InspectResponse(ref buffer, ref count); + + if (count > 0) + { + try + { + await _clientStream.WriteAsync(buffer.AsMemory(0, count), cancellationToken); + } + catch (IOException) + { + OnClientDisconnected(); + _device.Close(); + break; + } + } + } + } + } + } + + protected virtual void OnClientDisconnected() + { + } + + protected virtual void OnLostDeviceConnection() + { + } + + protected virtual void InspectRequest(ref byte[] buffer, ref int count) + { + } + + protected virtual void InspectResponse(ref byte[] buffer, ref int count) + { + } + + protected virtual void Dispose(bool disposing) + { + if (!_isDisposed) + { + if (disposing) + { + _deviceStream?.Dispose(); + _device?.Dispose(); + _clientStream?.Dispose(); + _client?.Dispose(); + } + + _deviceStream = null; + _device = null; + _clientStream = null; + _client = null; + _isDisposed = true; + } + } + + public void Close() + { + Dispose(disposing: true); + } + + void IDisposable.Dispose() + { + Close(); + GC.SuppressFinalize(this); + } +} diff --git a/FixHdhrAspect/ProxyVideoStream.cs b/FixHdhrAspect/ProxyVideoStream.cs new file mode 100644 index 0000000..2b69016 --- /dev/null +++ b/FixHdhrAspect/ProxyVideoStream.cs @@ -0,0 +1,109 @@ +using System.Net.Sockets; +using System.Text; +using System.Text.RegularExpressions; + +namespace JonathanDuke.FixHdhrAspect; + +public partial class ProxyVideoStream : ProxyStream +{ + [GeneratedRegex(@"^GET /(?:.*?/)*v(\d+\.\d+)", RegexOptions.Multiline | RegexOptions.Compiled)] + private static partial Regex ChannelRegex(); + private readonly static Regex _rxChannel = ChannelRegex(); + + private string? _channel; + private int _state = 0; + private MpegAspectRatio _aspectRatio = 0; + + public ProxyVideoStream(ProxyService service, ILogger logger) + : base(service, logger) + { + } + + protected override async Task ConnectAsync(TcpClient client, CancellationToken cancellationToken) + { + await client.ConnectAsync(_service!.DeviceHostName, _service!.DeviceCapturePort, cancellationToken); + } + + protected override void OnClientDisconnected() + { + _logger.LogInformation("Connection to channel {Channel} was disconnected at the client.", _channel); + base.OnClientDisconnected(); + } + + protected override void OnLostDeviceConnection() + { + _logger.LogInformation("Connection to channel {Channel} on the capture device was lost.", _channel); + base.OnLostDeviceConnection(); + } + + protected override void InspectRequest(ref byte[] buffer, ref int count) + { + if (_channel == null) + { + var request = Encoding.UTF8.GetString(buffer, 0, count); + Match match; + + if ((match = _rxChannel.Match(request)).Success) + { + _channel = match.Groups[1].Value; + + if (_service.Channels.TryGetValue(_channel, out MpegAspectRatio value) && value != MpegAspectRatio.Default) + { + _aspectRatio = value; + _logger.LogInformation("Streaming channel {Channel} with overridden aspect ratio of {Aspect}.", _channel, value.ToRatioString()); + } + else + { + _logger.LogInformation("Streaming channel {Channel} with default aspect ratio.", _channel); + } + } + } + } + + protected override void InspectResponse(ref byte[] buffer, ref int count) + { + if (_aspectRatio > 0) + { + for (int i = 0; i < count; i++) + { + if (_state == 0 && buffer[i] == 0x00) + { + _state = 1; + } + else if (_state == 1) + { + _state = buffer[i] == 0x00 ? 2 : 0; + } + else if (_state == 2) + { + _state = buffer[i] == 0x01 ? 3 : 0; + } + else if (_state == 3) + { + _state = buffer[i] == 0xB3 ? 4 : 0; + } + else if (_state == 4) + { + _state = (buffer[i] & 0xFE) == 0x2C ? 5 : 0; // allow 720 or 704 + } + else if (_state == 5) + { + _state = buffer[i] == 0x01 ? 6 : 0; + } + else if (_state == 6) + { + _state = buffer[i] == 0xE0 ? 7 : 0; + } + else if (_state == 7) + { + if ((buffer[i] & 0xEF) == 0x24) + { + buffer[i] = (byte)(buffer[i] & 0x0F | (byte)_aspectRatio << 4); + } + + _state = 0; + } + } + } + } +} diff --git a/FixHdhrAspect/Worker.cs b/FixHdhrAspect/Worker.cs new file mode 100644 index 0000000..f9df341 --- /dev/null +++ b/FixHdhrAspect/Worker.cs @@ -0,0 +1,106 @@ +// Template: https://learn.microsoft.com/en-us/dotnet/core/extensions/windows-service?pivots=dotnet-7-0#rewrite-the-worker-class + +namespace JonathanDuke.FixHdhrAspect; + +public sealed class Worker : BackgroundService +{ + private readonly ProxyService _proxyService; + private readonly ILogger _logger; + + public Worker( + ProxyService proxyService, + ILogger logger) + => (_proxyService, _logger) = (proxyService, logger); + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + int exitCode = 0; + Task[]? listeners = null; + + try + { +#if DEBUG + _logger.LogDebug("{time:o}: Worker started.", DateTimeOffset.Now); +#endif + listeners = [ + _proxyService.StartCaptureAsync(stoppingToken), + _proxyService.StartWebAsync(stoppingToken), + ]; + + while (!stoppingToken.IsCancellationRequested && !listeners.Any(l => l.IsCompleted)) + { +#if DEBUG + //_logger.LogDebug("{time:o}: Worker running.", DateTimeOffset.Now); +#endif + await Task.Delay(1000, stoppingToken); + } + } + catch (TaskCanceledException) + { + // When the stopping token is canceled, for example, a call made from services.msc, + // we shouldn't exit with a non-zero exit code. In other words, this is expected... +#if DEBUG + _logger.LogDebug("{time:o}: Cancel signal received.", DateTimeOffset.Now); +#endif + } + catch (Exception ex) + { + _logger.LogError(ex, "{Message}", ex.Message); + exitCode = 1; + } + finally + { + try + { + if (listeners != null) + { + try + { + // give any tasks that are still running a little extra time to gracefully shut down + Task.WaitAll(listeners.Where(l => !l.IsCompleted).ToArray(), 5000, CancellationToken.None); + } + catch { } + + int stillRunning = listeners.Where(l => !l.IsCompleted).Count(); + + if (stillRunning > 0) + { + _logger.LogWarning("Failed to shut down all listeners in a timely manner. ({count})", stillRunning); + } + + foreach (var listener in listeners) + { + if (listener.IsFaulted) + { + foreach (var ex in listener.Exception!.InnerExceptions) + { + _logger.LogError(ex, "{Message}", ex.Message); + } + } + } + } +#if DEBUG + _logger.LogDebug("{time:o}: Worker stopped.", DateTimeOffset.Now); +#endif + } + catch (Exception ex) + { + _logger.LogError(ex, "{Message}", ex.Message); + exitCode = 2; + } + + if (exitCode != 0) + { + // Terminates this process and returns an exit code to the operating system. + // This is required to avoid the 'BackgroundServiceExceptionBehavior', which + // performs one of two scenarios: + // 1. When set to "Ignore": will do nothing at all, errors cause zombie services. + // 2. When set to "StopHost": will cleanly stop the host, and log errors. + // + // In order for the Windows Service Management system to leverage configured + // recovery options, we need to terminate the process with a non-zero exit code. + Environment.Exit(exitCode); + } + } + } +} diff --git a/FixHdhrAspect/appsettings.Development.json b/FixHdhrAspect/appsettings.Development.json new file mode 100644 index 0000000..84a2eda --- /dev/null +++ b/FixHdhrAspect/appsettings.Development.json @@ -0,0 +1,13 @@ +{ + "Settings": { + "ProxyAllChannels": true, + "Channels": { + } + }, + "Logging": { + "LogLevel": { + "Default": "Debug", + "Microsoft.Hosting.Lifetime": "Information" + } + } +} \ No newline at end of file diff --git a/FixHdhrAspect/appsettings.json b/FixHdhrAspect/appsettings.json new file mode 100644 index 0000000..2b8e17e --- /dev/null +++ b/FixHdhrAspect/appsettings.json @@ -0,0 +1,33 @@ +{ + "Settings": { + "DeviceHostName": "hdhomerun.local", + "DeviceCapturePort": "5004", + "DeviceWebPort": "80", + "ServiceHostName": "", + "ServiceEndpoint": "0.0.0.0", + "ServiceEndpointIPv6": "", + "ServiceCapturePort": "5004", + "ServiceWebPort": "80", + "ProxyAllChannels": false, + "Channels": { + "2.2": "4:3", + "2.3": "16:9", + "2.4": "2.21:1", + "2.5": "1:1" + } + }, + "Logging": { + "LogLevel": { + "Default": "Warning" + }, + "EventLog": { + "SourceName": "HDHomeRun Proxy Service", + "LogName": "Application", + "LogLevel": { + "Default": "Information", + "Microsoft": "Information", + "Microsoft.Hosting.Lifetime": "Information" + } + } + } +} \ No newline at end of file diff --git a/README.md b/README.md index a7dcd6c..4399a36 100644 --- a/README.md +++ b/README.md @@ -1 +1,88 @@ -# fix-hdhr-aspect \ No newline at end of file +# HDHomeRun Proxy Service + +This service functions as a proxy for an HDHomeRun device and fixes the aspect ratio on certain MPEG streams. + +## Background + +Some over-the-air stations broadcast their digital subchannels with the incorrect aspect ratio in the MPEG sequence header, causing the video to be displayed incorrectly by HDHomeRun devices and other players like Plex. + +This issue has been discussed on various forums with no resolution: +* https://www.reddit.com/r/hdhomerun/comments/15oha6v/override_incorrect_aspect_ratio_on_ota_channels/ +* https://www.reddit.com/r/PleX/comments/15ohc3w/override_incorrect_aspect_ratio_on_ota_channels/ +* https://forum.silicondust.com/forum/viewtopic.php?p=393781 +* https://forums.plex.tv/t/override-aspect-ratio-on-roku-app-for-plex/850185 + +The original user on the SiliconDust forum (the post no longer exists) had suggested that altering the bits in the MPEG header might be a way to fix the problem. + +## Status + +At this time, neither vendor has updated their video player to support changing the aspect ratio. + +This service successfully serves as a proxy for the channel streams coming from the HDHR device. It simply modifies the 2 bits in the sequence header that specify the aspect ratio, so players like Plex and VLC can now point to the proxy address and play or record the streams in the correct format. + +> *The Plex live TV and DVR feature can detect and use the proxy service as its source if the IP address or hostname of the machine hosting this service is manually entered.* + +To avoid conflicts on the network, the original device ID is altered by one bit, which also results in a different checksum digit, so the last two digits of the device ID for the proxy service will differ from the original. This altered device ID is reflected in the proxy's web interface. + +> **While the proxied web interface is available for testing, viewing the channel lineup, and device discovery, users should not attempt to make changes or upgrade the firmware when accessing the device via the proxy service. Doing so is untested and could cause issues on your device.** + +An unsuccessful attempt was made to also proxy the discovery and control streams on the device so that the aspect ratio could be fixed on the native HDHomeRun video player as well. While two devices were visible on the network, the player detected that a firmware upgrade was necessary and could not handle the fake device. This could possibly be addressed in the future. + +## Configuration + +By default, the proxy service will attempt to listen on the same ports that the HDHR devices use, with port 80 for the web service and port 5004 for the video streams. It assumes the HDHR device uses the same ports, and that the device is accessible using the "hdhomerun.local" hostname. + +```json +{ + "Settings": { + "DeviceHostName": "hdhomerun.local", + "DeviceCapturePort": "5004", + "DeviceWebPort": "80", + "ServiceHostName": "", + "ServiceEndpoint": "0.0.0.0", + "ServiceEndpointIPv6": "", + "ServiceCapturePort": "5004", + "ServiceWebPort": "80", + "ProxyAllChannels": false, + "Channels": { + "2.2": "4:3", + "2.3": "16:9", + "2.4": "2.21:1", + "2.5": "1:1" + } + } +} +``` + +In order to proxy specific channels, each channel number must be added to the "Channels" section (as shown above) with the desired aspect ratio to override in the MPEG stream. Only the values shown above are supported by [the MPEG format](http://dvdnav.mplayerhq.hu/dvdinfo/mpeghdrs.html). + +> By default, only the channels specified will be proxied, with all others being sent directly to the HDHR device. If all channels should pass through the proxy service, even without changing the aspect ratio, then the "ProxyAllChannels" value may be set to true. + +All of the other settings above are optional and reflect the defaults for the service if they are not specified. However, if there are address or port conflicts on your network, the service properties can be overridden. + +## Requirements + +This code requires the [.NET 8 runtime](https://dotnet.microsoft.com/en-us/download) as well as a compatible HDHomeRun device. At the initial release, this has only been tested on the HDHomeRun FLEX 4K (HDFX-4K) with the following firmwares: +* 20231214 +* 20231020 + +## Installation + +Download the [latest version](https://github.com/jonathanduke/fix-hdhr-aspect/releases/tag/latest) that matches your platform. Unzip it in the location of your choice and then edit the [appsettings.json](#configuration) configuration file. + +On Windows, you can create a Windows service using the [sc.exe](https://learn.microsoft.com/en-us/windows-server/administration/windows-commands/sc-create) command if you have Administrator permission: + +```console +sc.exe create FixHdhrAspect binPath="C:\Path\To\FixHdhrAspect.exe" DisplayName="HDHomeRun Proxy Service" +``` + +Similarly, the service can be started, stopped, deleted, etc. with the same command: +```console +sc.exe start FixHdhrAspect +sc.exe stop FixHdhrAspect +sc.exe delete FixHdhrAspect +``` + +More detailed instructions can be found [here](https://learn.microsoft.com/en-us/dotnet/core/extensions/windows-service?pivots=dotnet-7-0#create-the-windows-service). + +In theory, this service should work on other operating systems as well, but that is currently untested. \ No newline at end of file