|  |  | 1 |  | #nullable disable | 
|  |  | 2 |  |  | 
|  |  | 3 |  | #pragma warning disable CS1591 | 
|  |  | 4 |  |  | 
|  |  | 5 |  | using System; | 
|  |  | 6 |  | using System.Collections.Generic; | 
|  |  | 7 |  | using System.Globalization; | 
|  |  | 8 |  | using System.Linq; | 
|  |  | 9 |  | using System.Net; | 
|  |  | 10 |  | using System.Net.Http; | 
|  |  | 11 |  | using System.Net.Http.Json; | 
|  |  | 12 |  | using System.Text.Json; | 
|  |  | 13 |  | using System.Threading; | 
|  |  | 14 |  | using System.Threading.Tasks; | 
|  |  | 15 |  | using Jellyfin.Extensions; | 
|  |  | 16 |  | using Jellyfin.Extensions.Json; | 
|  |  | 17 |  | using Jellyfin.Extensions.Json.Converters; | 
|  |  | 18 |  | using MediaBrowser.Common.Extensions; | 
|  |  | 19 |  | using MediaBrowser.Common.Net; | 
|  |  | 20 |  | using MediaBrowser.Controller; | 
|  |  | 21 |  | using MediaBrowser.Controller.Configuration; | 
|  |  | 22 |  | using MediaBrowser.Controller.Library; | 
|  |  | 23 |  | using MediaBrowser.Controller.LiveTv; | 
|  |  | 24 |  | using MediaBrowser.Model.Dto; | 
|  |  | 25 |  | using MediaBrowser.Model.Entities; | 
|  |  | 26 |  | using MediaBrowser.Model.IO; | 
|  |  | 27 |  | using MediaBrowser.Model.LiveTv; | 
|  |  | 28 |  | using MediaBrowser.Model.MediaInfo; | 
|  |  | 29 |  | using MediaBrowser.Model.Net; | 
|  |  | 30 |  | using Microsoft.Extensions.Logging; | 
|  |  | 31 |  |  | 
|  |  | 32 |  | namespace Jellyfin.LiveTv.TunerHosts.HdHomerun | 
|  |  | 33 |  | { | 
|  |  | 34 |  |     public class HdHomerunHost : BaseTunerHost, ITunerHost, IConfigurableTunerHost | 
|  |  | 35 |  |     { | 
|  |  | 36 |  |         private readonly IHttpClientFactory _httpClientFactory; | 
|  |  | 37 |  |         private readonly IServerApplicationHost _appHost; | 
|  |  | 38 |  |         private readonly ISocketFactory _socketFactory; | 
|  |  | 39 |  |         private readonly IStreamHelper _streamHelper; | 
|  |  | 40 |  |  | 
|  |  | 41 |  |         private readonly JsonSerializerOptions _jsonOptions; | 
|  |  | 42 |  |  | 
|  | 28 | 43 |  |         private readonly Dictionary<string, DiscoverResponse> _modelCache = new Dictionary<string, DiscoverResponse>(); | 
|  |  | 44 |  |  | 
|  |  | 45 |  |         public HdHomerunHost( | 
|  |  | 46 |  |             IServerConfigurationManager config, | 
|  |  | 47 |  |             ILogger<HdHomerunHost> logger, | 
|  |  | 48 |  |             IFileSystem fileSystem, | 
|  |  | 49 |  |             IHttpClientFactory httpClientFactory, | 
|  |  | 50 |  |             IServerApplicationHost appHost, | 
|  |  | 51 |  |             ISocketFactory socketFactory, | 
|  |  | 52 |  |             IStreamHelper streamHelper) | 
|  | 28 | 53 |  |             : base(config, logger, fileSystem) | 
|  |  | 54 |  |         { | 
|  | 28 | 55 |  |             _httpClientFactory = httpClientFactory; | 
|  | 28 | 56 |  |             _appHost = appHost; | 
|  | 28 | 57 |  |             _socketFactory = socketFactory; | 
|  | 28 | 58 |  |             _streamHelper = streamHelper; | 
|  |  | 59 |  |  | 
|  | 28 | 60 |  |             _jsonOptions = new JsonSerializerOptions(JsonDefaults.Options); | 
|  | 28 | 61 |  |             _jsonOptions.Converters.Add(new JsonBoolNumberConverter()); | 
|  | 28 | 62 |  |         } | 
|  |  | 63 |  |  | 
|  | 0 | 64 |  |         public string Name => "HD Homerun"; | 
|  |  | 65 |  |  | 
|  | 6 | 66 |  |         public override string Type => "hdhomerun"; | 
|  |  | 67 |  |  | 
|  | 0 | 68 |  |         protected override string ChannelIdPrefix => "hdhr_"; | 
|  |  | 69 |  |  | 
|  |  | 70 |  |         private string GetChannelId(Channels i) | 
|  | 0 | 71 |  |             => ChannelIdPrefix + i.GuideNumber; | 
|  |  | 72 |  |  | 
|  |  | 73 |  |         internal async Task<List<Channels>> GetLineup(TunerHostInfo info, CancellationToken cancellationToken) | 
|  |  | 74 |  |         { | 
|  |  | 75 |  |             var model = await GetModelInfo(info, false, cancellationToken).ConfigureAwait(false); | 
|  |  | 76 |  |  | 
|  |  | 77 |  |             using var response = await _httpClientFactory.CreateClient(NamedClient.Default).GetAsync(model.LineupURL ??  | 
|  |  | 78 |  |             var lineup = await response.Content.ReadFromJsonAsync<IEnumerable<Channels>>(_jsonOptions, cancellationToken | 
|  |  | 79 |  |             if (info.ImportFavoritesOnly) | 
|  |  | 80 |  |             { | 
|  |  | 81 |  |                 lineup = lineup.Where(i => i.Favorite); | 
|  |  | 82 |  |             } | 
|  |  | 83 |  |  | 
|  |  | 84 |  |             return lineup.Where(i => !i.DRM).ToList(); | 
|  |  | 85 |  |         } | 
|  |  | 86 |  |  | 
|  |  | 87 |  |         protected override async Task<List<ChannelInfo>> GetChannelsInternal(TunerHostInfo tuner, CancellationToken canc | 
|  |  | 88 |  |         { | 
|  |  | 89 |  |             var lineup = await GetLineup(tuner, cancellationToken).ConfigureAwait(false); | 
|  |  | 90 |  |  | 
|  |  | 91 |  |             return lineup.Select(i => new HdHomerunChannelInfo | 
|  |  | 92 |  |             { | 
|  |  | 93 |  |                 Name = i.GuideName, | 
|  |  | 94 |  |                 Number = i.GuideNumber, | 
|  |  | 95 |  |                 Id = GetChannelId(i), | 
|  |  | 96 |  |                 IsFavorite = i.Favorite, | 
|  |  | 97 |  |                 TunerHostId = tuner.Id, | 
|  |  | 98 |  |                 IsHD = i.HD, | 
|  |  | 99 |  |                 AudioCodec = i.AudioCodec, | 
|  |  | 100 |  |                 VideoCodec = i.VideoCodec, | 
|  |  | 101 |  |                 ChannelType = ChannelType.TV, | 
|  |  | 102 |  |                 IsLegacyTuner = (i.URL ?? string.Empty).StartsWith("hdhomerun", StringComparison.OrdinalIgnoreCase), | 
|  |  | 103 |  |                 Path = i.URL | 
|  |  | 104 |  |             }).Cast<ChannelInfo>().ToList(); | 
|  |  | 105 |  |         } | 
|  |  | 106 |  |  | 
|  |  | 107 |  |         internal async Task<DiscoverResponse> GetModelInfo(TunerHostInfo info, bool throwAllExceptions, CancellationToke | 
|  |  | 108 |  |         { | 
|  |  | 109 |  |             var cacheKey = info.Id; | 
|  |  | 110 |  |  | 
|  |  | 111 |  |             lock (_modelCache) | 
|  |  | 112 |  |             { | 
|  |  | 113 |  |                 if (!string.IsNullOrEmpty(cacheKey)) | 
|  |  | 114 |  |                 { | 
|  |  | 115 |  |                     if (_modelCache.TryGetValue(cacheKey, out DiscoverResponse response)) | 
|  |  | 116 |  |                     { | 
|  |  | 117 |  |                         return response; | 
|  |  | 118 |  |                     } | 
|  |  | 119 |  |                 } | 
|  |  | 120 |  |             } | 
|  |  | 121 |  |  | 
|  |  | 122 |  |             try | 
|  |  | 123 |  |             { | 
|  |  | 124 |  |                 using var response = await _httpClientFactory.CreateClient(NamedClient.Default) | 
|  |  | 125 |  |                     .GetAsync(GetApiUrl(info) + "/discover.json", HttpCompletionOption.ResponseHeadersRead, cancellation | 
|  |  | 126 |  |                     .ConfigureAwait(false); | 
|  |  | 127 |  |                 response.EnsureSuccessStatusCode(); | 
|  |  | 128 |  |                 var discoverResponse = await response.Content.ReadFromJsonAsync<DiscoverResponse>(_jsonOptions, cancella | 
|  |  | 129 |  |  | 
|  |  | 130 |  |                 if (!string.IsNullOrEmpty(cacheKey)) | 
|  |  | 131 |  |                 { | 
|  |  | 132 |  |                     lock (_modelCache) | 
|  |  | 133 |  |                     { | 
|  |  | 134 |  |                         _modelCache[cacheKey] = discoverResponse; | 
|  |  | 135 |  |                     } | 
|  |  | 136 |  |                 } | 
|  |  | 137 |  |  | 
|  |  | 138 |  |                 return discoverResponse; | 
|  |  | 139 |  |             } | 
|  |  | 140 |  |             catch (HttpRequestException ex) | 
|  |  | 141 |  |             { | 
|  |  | 142 |  |                 if (!throwAllExceptions && ex.StatusCode.HasValue && ex.StatusCode.Value == HttpStatusCode.NotFound) | 
|  |  | 143 |  |                 { | 
|  |  | 144 |  |                     const string DefaultValue = "HDHR"; | 
|  |  | 145 |  |                     var discoverResponse = new DiscoverResponse | 
|  |  | 146 |  |                     { | 
|  |  | 147 |  |                         ModelNumber = DefaultValue | 
|  |  | 148 |  |                     }; | 
|  |  | 149 |  |                     if (!string.IsNullOrEmpty(cacheKey)) | 
|  |  | 150 |  |                     { | 
|  |  | 151 |  |                         // HDHR4 doesn't have this api | 
|  |  | 152 |  |                         lock (_modelCache) | 
|  |  | 153 |  |                         { | 
|  |  | 154 |  |                             _modelCache[cacheKey] = discoverResponse; | 
|  |  | 155 |  |                         } | 
|  |  | 156 |  |                     } | 
|  |  | 157 |  |  | 
|  |  | 158 |  |                     return discoverResponse; | 
|  |  | 159 |  |                 } | 
|  |  | 160 |  |  | 
|  |  | 161 |  |                 throw; | 
|  |  | 162 |  |             } | 
|  |  | 163 |  |         } | 
|  |  | 164 |  |  | 
|  |  | 165 |  |         private static string GetApiUrl(TunerHostInfo info) | 
|  |  | 166 |  |         { | 
|  | 7 | 167 |  |             var url = info.Url; | 
|  |  | 168 |  |  | 
|  | 7 | 169 |  |             if (string.IsNullOrWhiteSpace(url)) | 
|  |  | 170 |  |             { | 
|  | 1 | 171 |  |                 throw new ArgumentException("Invalid tuner info"); | 
|  |  | 172 |  |             } | 
|  |  | 173 |  |  | 
|  | 6 | 174 |  |             if (!url.StartsWith("http", StringComparison.OrdinalIgnoreCase)) | 
|  |  | 175 |  |             { | 
|  | 6 | 176 |  |                 url = "http://" + url; | 
|  |  | 177 |  |             } | 
|  |  | 178 |  |  | 
|  | 6 | 179 |  |             return new Uri(url).AbsoluteUri.TrimEnd('/'); | 
|  |  | 180 |  |         } | 
|  |  | 181 |  |  | 
|  |  | 182 |  |         private static string GetHdHrIdFromChannelId(string channelId) | 
|  |  | 183 |  |         { | 
|  | 0 | 184 |  |             return channelId.Split('_')[1]; | 
|  |  | 185 |  |         } | 
|  |  | 186 |  |  | 
|  |  | 187 |  |         private MediaSourceInfo GetMediaSource(TunerHostInfo info, string channelId, ChannelInfo channelInfo, string pro | 
|  |  | 188 |  |         { | 
|  | 0 | 189 |  |             int? width = null; | 
|  | 0 | 190 |  |             int? height = null; | 
|  | 0 | 191 |  |             bool isInterlaced = true; | 
|  | 0 | 192 |  |             string videoCodec = null; | 
|  |  | 193 |  |  | 
|  | 0 | 194 |  |             int? videoBitrate = null; | 
|  |  | 195 |  |  | 
|  | 0 | 196 |  |             var isHd = channelInfo.IsHD ?? true; | 
|  |  | 197 |  |  | 
|  | 0 | 198 |  |             if (string.Equals(profile, "mobile", StringComparison.OrdinalIgnoreCase)) | 
|  |  | 199 |  |             { | 
|  | 0 | 200 |  |                 width = 1280; | 
|  | 0 | 201 |  |                 height = 720; | 
|  | 0 | 202 |  |                 isInterlaced = false; | 
|  | 0 | 203 |  |                 videoCodec = "h264"; | 
|  | 0 | 204 |  |                 videoBitrate = 2000000; | 
|  |  | 205 |  |             } | 
|  | 0 | 206 |  |             else if (string.Equals(profile, "heavy", StringComparison.OrdinalIgnoreCase)) | 
|  |  | 207 |  |             { | 
|  | 0 | 208 |  |                 width = 1920; | 
|  | 0 | 209 |  |                 height = 1080; | 
|  | 0 | 210 |  |                 isInterlaced = false; | 
|  | 0 | 211 |  |                 videoCodec = "h264"; | 
|  | 0 | 212 |  |                 videoBitrate = 15000000; | 
|  |  | 213 |  |             } | 
|  | 0 | 214 |  |             else if (string.Equals(profile, "internet720", StringComparison.OrdinalIgnoreCase)) | 
|  |  | 215 |  |             { | 
|  | 0 | 216 |  |                 width = 1280; | 
|  | 0 | 217 |  |                 height = 720; | 
|  | 0 | 218 |  |                 isInterlaced = false; | 
|  | 0 | 219 |  |                 videoCodec = "h264"; | 
|  | 0 | 220 |  |                 videoBitrate = 8000000; | 
|  |  | 221 |  |             } | 
|  | 0 | 222 |  |             else if (string.Equals(profile, "internet540", StringComparison.OrdinalIgnoreCase)) | 
|  |  | 223 |  |             { | 
|  | 0 | 224 |  |                 width = 960; | 
|  | 0 | 225 |  |                 height = 540; | 
|  | 0 | 226 |  |                 isInterlaced = false; | 
|  | 0 | 227 |  |                 videoCodec = "h264"; | 
|  | 0 | 228 |  |                 videoBitrate = 2500000; | 
|  |  | 229 |  |             } | 
|  | 0 | 230 |  |             else if (string.Equals(profile, "internet480", StringComparison.OrdinalIgnoreCase)) | 
|  |  | 231 |  |             { | 
|  | 0 | 232 |  |                 width = 848; | 
|  | 0 | 233 |  |                 height = 480; | 
|  | 0 | 234 |  |                 isInterlaced = false; | 
|  | 0 | 235 |  |                 videoCodec = "h264"; | 
|  | 0 | 236 |  |                 videoBitrate = 2000000; | 
|  |  | 237 |  |             } | 
|  | 0 | 238 |  |             else if (string.Equals(profile, "internet360", StringComparison.OrdinalIgnoreCase)) | 
|  |  | 239 |  |             { | 
|  | 0 | 240 |  |                 width = 640; | 
|  | 0 | 241 |  |                 height = 360; | 
|  | 0 | 242 |  |                 isInterlaced = false; | 
|  | 0 | 243 |  |                 videoCodec = "h264"; | 
|  | 0 | 244 |  |                 videoBitrate = 1500000; | 
|  |  | 245 |  |             } | 
|  | 0 | 246 |  |             else if (string.Equals(profile, "internet240", StringComparison.OrdinalIgnoreCase)) | 
|  |  | 247 |  |             { | 
|  | 0 | 248 |  |                 width = 432; | 
|  | 0 | 249 |  |                 height = 240; | 
|  | 0 | 250 |  |                 isInterlaced = false; | 
|  | 0 | 251 |  |                 videoCodec = "h264"; | 
|  | 0 | 252 |  |                 videoBitrate = 1000000; | 
|  |  | 253 |  |             } | 
|  |  | 254 |  |             else | 
|  |  | 255 |  |             { | 
|  |  | 256 |  |                 // This is for android tv's 1200 condition. Remove once not needed anymore so that we can avoid possible | 
|  | 0 | 257 |  |                 if (isHd) | 
|  |  | 258 |  |                 { | 
|  | 0 | 259 |  |                     width = 1920; | 
|  | 0 | 260 |  |                     height = 1080; | 
|  |  | 261 |  |                 } | 
|  |  | 262 |  |             } | 
|  |  | 263 |  |  | 
|  | 0 | 264 |  |             if (string.IsNullOrWhiteSpace(videoCodec)) | 
|  |  | 265 |  |             { | 
|  | 0 | 266 |  |                 videoCodec = channelInfo.VideoCodec; | 
|  |  | 267 |  |             } | 
|  |  | 268 |  |  | 
|  | 0 | 269 |  |             string audioCodec = channelInfo.AudioCodec; | 
|  |  | 270 |  |  | 
|  | 0 | 271 |  |             videoBitrate ??= isHd ? 15000000 : 2000000; | 
|  |  | 272 |  |  | 
|  | 0 | 273 |  |             int? audioBitrate = isHd ? 448000 : 192000; | 
|  |  | 274 |  |  | 
|  |  | 275 |  |             // normalize | 
|  | 0 | 276 |  |             if (string.Equals(videoCodec, "mpeg2", StringComparison.OrdinalIgnoreCase)) | 
|  |  | 277 |  |             { | 
|  | 0 | 278 |  |                 videoCodec = "mpeg2video"; | 
|  |  | 279 |  |             } | 
|  |  | 280 |  |  | 
|  | 0 | 281 |  |             string nal = null; | 
|  | 0 | 282 |  |             if (string.Equals(videoCodec, "h264", StringComparison.OrdinalIgnoreCase)) | 
|  |  | 283 |  |             { | 
|  | 0 | 284 |  |                 nal = "0"; | 
|  |  | 285 |  |             } | 
|  |  | 286 |  |  | 
|  | 0 | 287 |  |             var url = GetApiUrl(info); | 
|  |  | 288 |  |  | 
|  | 0 | 289 |  |             var id = profile; | 
|  | 0 | 290 |  |             if (string.IsNullOrWhiteSpace(id)) | 
|  |  | 291 |  |             { | 
|  | 0 | 292 |  |                 id = "native"; | 
|  |  | 293 |  |             } | 
|  |  | 294 |  |  | 
|  | 0 | 295 |  |             id += "_" + channelId.GetMD5().ToString("N", CultureInfo.InvariantCulture) + "_" + url.GetMD5().ToString("N" | 
|  |  | 296 |  |  | 
|  | 0 | 297 |  |             var mediaSource = new MediaSourceInfo | 
|  | 0 | 298 |  |             { | 
|  | 0 | 299 |  |                 Path = url, | 
|  | 0 | 300 |  |                 Protocol = MediaProtocol.Udp, | 
|  | 0 | 301 |  |                 MediaStreams = new MediaStream[] | 
|  | 0 | 302 |  |                 { | 
|  | 0 | 303 |  |                     new MediaStream | 
|  | 0 | 304 |  |                     { | 
|  | 0 | 305 |  |                         Type = MediaStreamType.Video, | 
|  | 0 | 306 |  |                         // Set the index to -1 because we don't know the exact index of the video stream within the cont | 
|  | 0 | 307 |  |                         Index = -1, | 
|  | 0 | 308 |  |                         IsInterlaced = isInterlaced, | 
|  | 0 | 309 |  |                         Codec = videoCodec, | 
|  | 0 | 310 |  |                         Width = width, | 
|  | 0 | 311 |  |                         Height = height, | 
|  | 0 | 312 |  |                         BitRate = videoBitrate, | 
|  | 0 | 313 |  |                         NalLengthSize = nal | 
|  | 0 | 314 |  |                     }, | 
|  | 0 | 315 |  |                     new MediaStream | 
|  | 0 | 316 |  |                     { | 
|  | 0 | 317 |  |                         Type = MediaStreamType.Audio, | 
|  | 0 | 318 |  |                         // Set the index to -1 because we don't know the exact index of the audio stream within the cont | 
|  | 0 | 319 |  |                         Index = -1, | 
|  | 0 | 320 |  |                         Codec = audioCodec, | 
|  | 0 | 321 |  |                         BitRate = audioBitrate | 
|  | 0 | 322 |  |                     } | 
|  | 0 | 323 |  |                 }, | 
|  | 0 | 324 |  |                 RequiresOpening = true, | 
|  | 0 | 325 |  |                 RequiresClosing = true, | 
|  | 0 | 326 |  |                 BufferMs = 0, | 
|  | 0 | 327 |  |                 Container = "ts", | 
|  | 0 | 328 |  |                 Id = id, | 
|  | 0 | 329 |  |                 SupportsDirectPlay = false, | 
|  | 0 | 330 |  |                 SupportsDirectStream = true, | 
|  | 0 | 331 |  |                 SupportsTranscoding = true, | 
|  | 0 | 332 |  |                 IsInfiniteStream = true, | 
|  | 0 | 333 |  |                 IgnoreDts = true, | 
|  | 0 | 334 |  |                 UseMostCompatibleTranscodingProfile = true, // All HDHR tuners require this | 
|  | 0 | 335 |  |                 FallbackMaxStreamingBitrate = info.FallbackMaxStreamingBitrate, | 
|  | 0 | 336 |  |                 // IgnoreIndex = true, | 
|  | 0 | 337 |  |                 // ReadAtNativeFramerate = true | 
|  | 0 | 338 |  |             }; | 
|  |  | 339 |  |  | 
|  | 0 | 340 |  |             mediaSource.InferTotalBitrate(); | 
|  |  | 341 |  |  | 
|  | 0 | 342 |  |             return mediaSource; | 
|  |  | 343 |  |         } | 
|  |  | 344 |  |  | 
|  |  | 345 |  |         protected override async Task<List<MediaSourceInfo>> GetChannelStreamMediaSources(TunerHostInfo tuner, ChannelIn | 
|  |  | 346 |  |         { | 
|  |  | 347 |  |             var list = new List<MediaSourceInfo>(); | 
|  |  | 348 |  |  | 
|  |  | 349 |  |             var channelId = channel.Id; | 
|  |  | 350 |  |             var hdhrId = GetHdHrIdFromChannelId(channelId); | 
|  |  | 351 |  |  | 
|  |  | 352 |  |             if (channel is HdHomerunChannelInfo hdHomerunChannelInfo && hdHomerunChannelInfo.IsLegacyTuner) | 
|  |  | 353 |  |             { | 
|  |  | 354 |  |                 list.Add(GetMediaSource(tuner, hdhrId, channel, "native")); | 
|  |  | 355 |  |             } | 
|  |  | 356 |  |             else | 
|  |  | 357 |  |             { | 
|  |  | 358 |  |                 var modelInfo = await GetModelInfo(tuner, false, cancellationToken).ConfigureAwait(false); | 
|  |  | 359 |  |  | 
|  |  | 360 |  |                 if (modelInfo is not null && modelInfo.SupportsTranscoding) | 
|  |  | 361 |  |                 { | 
|  |  | 362 |  |                     if (tuner.AllowHWTranscoding) | 
|  |  | 363 |  |                     { | 
|  |  | 364 |  |                         list.Add(GetMediaSource(tuner, hdhrId, channel, "heavy")); | 
|  |  | 365 |  |  | 
|  |  | 366 |  |                         list.Add(GetMediaSource(tuner, hdhrId, channel, "internet540")); | 
|  |  | 367 |  |                         list.Add(GetMediaSource(tuner, hdhrId, channel, "internet480")); | 
|  |  | 368 |  |                         list.Add(GetMediaSource(tuner, hdhrId, channel, "internet360")); | 
|  |  | 369 |  |                         list.Add(GetMediaSource(tuner, hdhrId, channel, "internet240")); | 
|  |  | 370 |  |                         list.Add(GetMediaSource(tuner, hdhrId, channel, "mobile")); | 
|  |  | 371 |  |                     } | 
|  |  | 372 |  |  | 
|  |  | 373 |  |                     list.Add(GetMediaSource(tuner, hdhrId, channel, "native")); | 
|  |  | 374 |  |                 } | 
|  |  | 375 |  |  | 
|  |  | 376 |  |                 if (list.Count == 0) | 
|  |  | 377 |  |                 { | 
|  |  | 378 |  |                     list.Add(GetMediaSource(tuner, hdhrId, channel, "native")); | 
|  |  | 379 |  |                 } | 
|  |  | 380 |  |             } | 
|  |  | 381 |  |  | 
|  |  | 382 |  |             return list; | 
|  |  | 383 |  |         } | 
|  |  | 384 |  |  | 
|  |  | 385 |  |         protected override async Task<ILiveStream> GetChannelStream(TunerHostInfo tunerHost, ChannelInfo channel, string | 
|  |  | 386 |  |         { | 
|  |  | 387 |  |             var tunerCount = tunerHost.TunerCount; | 
|  |  | 388 |  |  | 
|  |  | 389 |  |             if (tunerCount > 0) | 
|  |  | 390 |  |             { | 
|  |  | 391 |  |                 var tunerHostId = tunerHost.Id; | 
|  |  | 392 |  |                 var liveStreams = currentLiveStreams.Where(i => string.Equals(i.TunerHostId, tunerHostId, StringComparis | 
|  |  | 393 |  |  | 
|  |  | 394 |  |                 if (liveStreams.Count() >= tunerCount) | 
|  |  | 395 |  |                 { | 
|  |  | 396 |  |                     throw new LiveTvConflictException("HDHomeRun simultaneous stream limit has been reached."); | 
|  |  | 397 |  |                 } | 
|  |  | 398 |  |             } | 
|  |  | 399 |  |  | 
|  |  | 400 |  |             var profile = streamId.AsSpan().LeftPart('_').ToString(); | 
|  |  | 401 |  |  | 
|  |  | 402 |  |             Logger.LogInformation("GetChannelStream: channel id: {0}. stream id: {1} profile: {2}", channel.Id, streamId | 
|  |  | 403 |  |  | 
|  |  | 404 |  |             var hdhrId = GetHdHrIdFromChannelId(channel.Id); | 
|  |  | 405 |  |  | 
|  |  | 406 |  |             var hdhomerunChannel = channel as HdHomerunChannelInfo; | 
|  |  | 407 |  |  | 
|  |  | 408 |  |             var modelInfo = await GetModelInfo(tunerHost, false, cancellationToken).ConfigureAwait(false); | 
|  |  | 409 |  |  | 
|  |  | 410 |  |             if (!modelInfo.SupportsTranscoding) | 
|  |  | 411 |  |             { | 
|  |  | 412 |  |                 profile = "native"; | 
|  |  | 413 |  |             } | 
|  |  | 414 |  |  | 
|  |  | 415 |  |             var mediaSource = GetMediaSource(tunerHost, hdhrId, channel, profile); | 
|  |  | 416 |  |  | 
|  |  | 417 |  |             if (hdhomerunChannel is not null && hdhomerunChannel.IsLegacyTuner) | 
|  |  | 418 |  |             { | 
|  |  | 419 |  |                 return new HdHomerunUdpStream( | 
|  |  | 420 |  |                     mediaSource, | 
|  |  | 421 |  |                     tunerHost, | 
|  |  | 422 |  |                     streamId, | 
|  |  | 423 |  |                     new LegacyHdHomerunChannelCommands(hdhomerunChannel.Path), | 
|  |  | 424 |  |                     modelInfo.TunerCount, | 
|  |  | 425 |  |                     FileSystem, | 
|  |  | 426 |  |                     Logger, | 
|  |  | 427 |  |                     Config, | 
|  |  | 428 |  |                     _appHost, | 
|  |  | 429 |  |                     _streamHelper); | 
|  |  | 430 |  |             } | 
|  |  | 431 |  |  | 
|  |  | 432 |  |             mediaSource.Protocol = MediaProtocol.Http; | 
|  |  | 433 |  |  | 
|  |  | 434 |  |             var httpUrl = channel.Path; | 
|  |  | 435 |  |  | 
|  |  | 436 |  |             // If raw was used, the tuner doesn't support params | 
|  |  | 437 |  |             if (!string.IsNullOrWhiteSpace(profile) && !string.Equals(profile, "native", StringComparison.OrdinalIgnoreC | 
|  |  | 438 |  |             { | 
|  |  | 439 |  |                 httpUrl += "?transcode=" + profile; | 
|  |  | 440 |  |             } | 
|  |  | 441 |  |  | 
|  |  | 442 |  |             mediaSource.Path = httpUrl; | 
|  |  | 443 |  |  | 
|  |  | 444 |  |             return new SharedHttpStream( | 
|  |  | 445 |  |                 mediaSource, | 
|  |  | 446 |  |                 tunerHost, | 
|  |  | 447 |  |                 streamId, | 
|  |  | 448 |  |                 FileSystem, | 
|  |  | 449 |  |                 _httpClientFactory, | 
|  |  | 450 |  |                 Logger, | 
|  |  | 451 |  |                 Config, | 
|  |  | 452 |  |                 _appHost, | 
|  |  | 453 |  |                 _streamHelper); | 
|  |  | 454 |  |         } | 
|  |  | 455 |  |  | 
|  |  | 456 |  |         public async Task Validate(TunerHostInfo info) | 
|  |  | 457 |  |         { | 
|  |  | 458 |  |             lock (_modelCache) | 
|  |  | 459 |  |             { | 
|  |  | 460 |  |                 _modelCache.Clear(); | 
|  |  | 461 |  |             } | 
|  |  | 462 |  |  | 
|  |  | 463 |  |             try | 
|  |  | 464 |  |             { | 
|  |  | 465 |  |                 // Test it by pulling down the lineup | 
|  |  | 466 |  |                 var modelInfo = await GetModelInfo(info, true, CancellationToken.None).ConfigureAwait(false); | 
|  |  | 467 |  |                 info.DeviceId = modelInfo.DeviceID; | 
|  |  | 468 |  |             } | 
|  |  | 469 |  |             catch (HttpRequestException ex) | 
|  |  | 470 |  |             { | 
|  |  | 471 |  |                 if (ex.StatusCode.HasValue && ex.StatusCode.Value == HttpStatusCode.NotFound) | 
|  |  | 472 |  |                 { | 
|  |  | 473 |  |                     // HDHR4 doesn't have this api | 
|  |  | 474 |  |                     return; | 
|  |  | 475 |  |                 } | 
|  |  | 476 |  |  | 
|  |  | 477 |  |                 throw; | 
|  |  | 478 |  |             } | 
|  |  | 479 |  |         } | 
|  |  | 480 |  |  | 
|  |  | 481 |  |         public async Task<List<TunerHostInfo>> DiscoverDevices(int discoveryDurationMs, CancellationToken cancellationTo | 
|  |  | 482 |  |         { | 
|  |  | 483 |  |             lock (_modelCache) | 
|  |  | 484 |  |             { | 
|  |  | 485 |  |                 _modelCache.Clear(); | 
|  |  | 486 |  |             } | 
|  |  | 487 |  |  | 
|  |  | 488 |  |             using var timedCancellationToken = new CancellationTokenSource(discoveryDurationMs); | 
|  |  | 489 |  |             using var linkedCancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(timedCancellationT | 
|  |  | 490 |  |             cancellationToken = linkedCancellationTokenSource.Token; | 
|  |  | 491 |  |             var list = new List<TunerHostInfo>(); | 
|  |  | 492 |  |  | 
|  |  | 493 |  |             // Create udp broadcast discovery message | 
|  |  | 494 |  |             byte[] discBytes = { 0, 2, 0, 12, 1, 4, 255, 255, 255, 255, 2, 4, 255, 255, 255, 255, 115, 204, 125, 143 }; | 
|  |  | 495 |  |             using (var udpClient = _socketFactory.CreateUdpBroadcastSocket(0)) | 
|  |  | 496 |  |             { | 
|  |  | 497 |  |                 // Need a way to set the Receive timeout on the socket otherwise this might never timeout? | 
|  |  | 498 |  |                 try | 
|  |  | 499 |  |                 { | 
|  |  | 500 |  |                     await udpClient.SendToAsync(discBytes, new IPEndPoint(IPAddress.Broadcast, 65001), cancellationToken | 
|  |  | 501 |  |                     var receiveBuffer = new byte[8192]; | 
|  |  | 502 |  |  | 
|  |  | 503 |  |                     while (!cancellationToken.IsCancellationRequested) | 
|  |  | 504 |  |                     { | 
|  |  | 505 |  |                         var response = await udpClient.ReceiveMessageFromAsync(receiveBuffer, new IPEndPoint(IPAddress.A | 
|  |  | 506 |  |                         var deviceIP = ((IPEndPoint)response.RemoteEndPoint).Address.ToString(); | 
|  |  | 507 |  |  | 
|  |  | 508 |  |                         // Check to make sure we have enough bytes received to be a valid message and make sure the 2nd  | 
|  |  | 509 |  |                         if (response.ReceivedBytes > 13 && receiveBuffer[1] == 3) | 
|  |  | 510 |  |                         { | 
|  |  | 511 |  |                             var deviceAddress = "http://" + deviceIP; | 
|  |  | 512 |  |  | 
|  |  | 513 |  |                             var info = await TryGetTunerHostInfo(deviceAddress, cancellationToken).ConfigureAwait(false) | 
|  |  | 514 |  |  | 
|  |  | 515 |  |                             if (info is not null) | 
|  |  | 516 |  |                             { | 
|  |  | 517 |  |                                 list.Add(info); | 
|  |  | 518 |  |                             } | 
|  |  | 519 |  |                         } | 
|  |  | 520 |  |                     } | 
|  |  | 521 |  |                 } | 
|  |  | 522 |  |                 catch (OperationCanceledException) | 
|  |  | 523 |  |                 { | 
|  |  | 524 |  |                 } | 
|  |  | 525 |  |                 catch (Exception ex) | 
|  |  | 526 |  |                 { | 
|  |  | 527 |  |                     // Socket timeout indicates all messages have been received. | 
|  |  | 528 |  |                     Logger.LogError(ex, "Error while sending discovery message"); | 
|  |  | 529 |  |                 } | 
|  |  | 530 |  |             } | 
|  |  | 531 |  |  | 
|  |  | 532 |  |             return list; | 
|  |  | 533 |  |         } | 
|  |  | 534 |  |  | 
|  |  | 535 |  |         internal async Task<TunerHostInfo> TryGetTunerHostInfo(string url, CancellationToken cancellationToken) | 
|  |  | 536 |  |         { | 
|  |  | 537 |  |             var hostInfo = new TunerHostInfo | 
|  |  | 538 |  |             { | 
|  |  | 539 |  |                 Type = Type, | 
|  |  | 540 |  |                 Url = url | 
|  |  | 541 |  |             }; | 
|  |  | 542 |  |  | 
|  |  | 543 |  |             var modelInfo = await GetModelInfo(hostInfo, false, cancellationToken).ConfigureAwait(false); | 
|  |  | 544 |  |  | 
|  |  | 545 |  |             hostInfo.DeviceId = modelInfo.DeviceID; | 
|  |  | 546 |  |             hostInfo.FriendlyName = modelInfo.FriendlyName; | 
|  |  | 547 |  |             hostInfo.TunerCount = modelInfo.TunerCount; | 
|  |  | 548 |  |  | 
|  |  | 549 |  |             return hostInfo; | 
|  |  | 550 |  |         } | 
|  |  | 551 |  |  | 
|  |  | 552 |  |         private class HdHomerunChannelInfo : ChannelInfo | 
|  |  | 553 |  |         { | 
|  |  | 554 |  |             public bool IsLegacyTuner { get; set; } | 
|  |  | 555 |  |         } | 
|  |  | 556 |  |     } | 
|  |  | 557 |  | } |