From 198fc4c58df9f263584766800dfdb78dc944966d Mon Sep 17 00:00:00 2001 From: Duncan Tourolle Date: Sun, 7 Dec 2025 13:29:13 +0100 Subject: [PATCH] more consolidation --- Jellyfin.Plugin.SRFPlay.Tests/Program.cs | 11 +- Jellyfin.Plugin.SRFPlay/Api/SRFApiClient.cs | 49 +--- .../Channels/SRFPlayChannel.cs | 17 +- .../Controllers/StreamProxyController.cs | 29 +++ .../Services/ContentRefreshService.cs | 238 ++++++------------ .../Interfaces/IStreamProxyService.cs | 3 +- .../Services/MediaSourceFactory.cs | 19 +- .../Services/StreamProxyService.cs | 166 +++++------- .../Services/StreamUrlResolver.cs | 38 +-- 9 files changed, 209 insertions(+), 361 deletions(-) diff --git a/Jellyfin.Plugin.SRFPlay.Tests/Program.cs b/Jellyfin.Plugin.SRFPlay.Tests/Program.cs index aff53aa..126257d 100644 --- a/Jellyfin.Plugin.SRFPlay.Tests/Program.cs +++ b/Jellyfin.Plugin.SRFPlay.Tests/Program.cs @@ -12,6 +12,14 @@ using Microsoft.Extensions.Logging; namespace Jellyfin.Plugin.SRFPlay.Tests { + /// + /// Simple IHttpClientFactory implementation for tests. + /// + internal sealed class TestHttpClientFactory : IHttpClientFactory + { + public HttpClient CreateClient(string name) => new HttpClient(); + } + class Program { static async Task Main(string[] args) @@ -33,8 +41,9 @@ namespace Jellyfin.Plugin.SRFPlay.Tests builder.SetMinimumLevel(LogLevel.Warning); // Only show warnings and errors }); + var httpClientFactory = new TestHttpClientFactory(); var apiClient = new SRFApiClient(loggerFactory); - var streamResolver = new StreamUrlResolver(loggerFactory.CreateLogger()); + var streamResolver = new StreamUrlResolver(loggerFactory.CreateLogger(), httpClientFactory); var cancellationToken = CancellationToken.None; diff --git a/Jellyfin.Plugin.SRFPlay/Api/SRFApiClient.cs b/Jellyfin.Plugin.SRFPlay/Api/SRFApiClient.cs index 14f27fc..925a8a8 100644 --- a/Jellyfin.Plugin.SRFPlay/Api/SRFApiClient.cs +++ b/Jellyfin.Plugin.SRFPlay/Api/SRFApiClient.cs @@ -135,54 +135,11 @@ public class SRFApiClient : IDisposable { try { - var url = $"/mediaComposition/byUrn/{urn}.json"; - var fullUrl = $"{ApiEndpoints.IntegrationLayerBaseUrl}{url}"; - _logger.LogInformation("Fetching media composition for URN: {Urn} from {Url}", urn, fullUrl); + var fullUrl = $"{ApiEndpoints.IntegrationLayerBaseUrl}/mediaComposition/byUrn/{urn}.json"; + _logger.LogDebug("Fetching media composition for URN: {Urn} from {Url}", urn, fullUrl); - // HttpClient consistently fails with 404, use curl directly - // This is likely due to routing/network configuration on the Jellyfin server + // Use curl - HttpClient returns 404 due to server routing/network configuration return await FetchWithCurlAsync(fullUrl, cancellationToken).ConfigureAwait(false); - - /* HttpClient fallback disabled - always returns 404 - var response = await _httpClient.GetAsync(url, cancellationToken).ConfigureAwait(false); - - // Log response headers to diagnose geo-blocking - var xLocation = response.Headers.Contains("x-location") - ? string.Join(", ", response.Headers.GetValues("x-location")) - : "not present"; - - _logger.LogInformation( - "Media composition response for URN {Urn}: StatusCode={StatusCode}, x-location={XLocation}", - urn, - response.StatusCode, - xLocation); - - // If HttpClient fails, try curl as fallback - if (!response.IsSuccessStatusCode) - { - _logger.LogWarning("HttpClient failed with {StatusCode}, trying curl fallback", response.StatusCode); - return await FetchWithCurlAsync(fullUrl, cancellationToken).ConfigureAwait(false); - } - - response.EnsureSuccessStatusCode(); - - var content = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); - var result = JsonSerializer.Deserialize(content, _jsonOptions); - - if (result?.ChapterList != null && result.ChapterList.Count > 0) - { - _logger.LogInformation( - "Successfully fetched media composition for URN: {Urn} - Chapters: {ChapterCount}", - urn, - result.ChapterList.Count); - } - else - { - _logger.LogWarning("Media composition for URN {Urn} has no chapters", urn); - } - - return result; - */ } catch (Exception ex) { diff --git a/Jellyfin.Plugin.SRFPlay/Channels/SRFPlayChannel.cs b/Jellyfin.Plugin.SRFPlay/Channels/SRFPlayChannel.cs index 4c07127..abaa0dc 100644 --- a/Jellyfin.Plugin.SRFPlay/Channels/SRFPlayChannel.cs +++ b/Jellyfin.Plugin.SRFPlay/Channels/SRFPlayChannel.cs @@ -6,6 +6,7 @@ using System.Security.Cryptography; using System.Text; using System.Threading; using System.Threading.Tasks; +using Jellyfin.Plugin.SRFPlay.Api; using Jellyfin.Plugin.SRFPlay.Constants; using Jellyfin.Plugin.SRFPlay.Services.Interfaces; using MediaBrowser.Controller; @@ -24,11 +25,11 @@ namespace Jellyfin.Plugin.SRFPlay.Channels; public class SRFPlayChannel : IChannel, IHasCacheKey { private readonly ILogger _logger; - private readonly ILoggerFactory _loggerFactory; private readonly IContentRefreshService _contentRefreshService; private readonly IStreamUrlResolver _streamResolver; private readonly IMediaSourceFactory _mediaSourceFactory; private readonly ICategoryService? _categoryService; + private readonly ISRFApiClientFactory _apiClientFactory; /// /// Initializes a new instance of the class. @@ -38,26 +39,28 @@ public class SRFPlayChannel : IChannel, IHasCacheKey /// The stream resolver. /// The media source factory. /// The category service (optional). + /// The API client factory. public SRFPlayChannel( ILoggerFactory loggerFactory, IContentRefreshService contentRefreshService, IStreamUrlResolver streamResolver, IMediaSourceFactory mediaSourceFactory, - ICategoryService? categoryService = null) + ICategoryService? categoryService, + ISRFApiClientFactory apiClientFactory) { - _loggerFactory = loggerFactory; _logger = loggerFactory.CreateLogger(); _contentRefreshService = contentRefreshService; _streamResolver = streamResolver; _mediaSourceFactory = mediaSourceFactory; _categoryService = categoryService; + _apiClientFactory = apiClientFactory; if (_categoryService == null) { _logger.LogWarning("CategoryService not available - category folders will be disabled"); } - _logger.LogInformation("=== SRFPlayChannel constructor called! Channel is being instantiated ==="); + _logger.LogDebug("SRFPlayChannel initialized"); } /// @@ -236,7 +239,7 @@ public class SRFPlayChannel : IChannel, IHasCacheKey { var businessUnit = config?.BusinessUnit.ToString().ToLowerInvariant() ?? "srf"; - using var apiClient = new Api.SRFApiClient(_loggerFactory); + using var apiClient = _apiClientFactory.CreateClient(); var scheduledLivestreams = await apiClient.GetScheduledLivestreamsAsync(businessUnit, "SPORT", cancellationToken).ConfigureAwait(false); if (scheduledLivestreams == null) @@ -301,7 +304,7 @@ public class SRFPlayChannel : IChannel, IHasCacheKey var shows = await _categoryService.GetShowsByTopicAsync(topicId, businessUnit, 20, cancellationToken).ConfigureAwait(false); var urns = new List(); - using var apiClient = new Api.SRFApiClient(_loggerFactory); + using var apiClient = _apiClientFactory.CreateClient(); foreach (var show in shows) { @@ -407,7 +410,7 @@ public class SRFPlayChannel : IChannel, IHasCacheKey _logger.LogInformation("Converting {Count} URNs to channel items", urns.Count); - using var apiClient = new Api.SRFApiClient(_loggerFactory); + using var apiClient = _apiClientFactory.CreateClient(); int successCount = 0; int failedCount = 0; int expiredCount = 0; diff --git a/Jellyfin.Plugin.SRFPlay/Controllers/StreamProxyController.cs b/Jellyfin.Plugin.SRFPlay/Controllers/StreamProxyController.cs index ccf065a..72fb6da 100644 --- a/Jellyfin.Plugin.SRFPlay/Controllers/StreamProxyController.cs +++ b/Jellyfin.Plugin.SRFPlay/Controllers/StreamProxyController.cs @@ -52,6 +52,29 @@ public class StreamProxyController : ControllerBase Response.Headers["Access-Control-Max-Age"] = "86400"; // Cache preflight for 24 hours } + /// + /// Adds Cache-Control headers appropriate for the stream type. + /// Livestreams need frequent manifest refresh, VOD can be cached longer. + /// + /// The item ID to check. + private void AddManifestCacheHeaders(string itemId) + { + var metadata = _proxyService.GetStreamMetadata(itemId); + var isLiveStream = metadata?.IsLiveStream ?? false; + + if (isLiveStream) + { + // Livestreams need frequent manifest refresh (segments rotate every ~6-10s) + Response.Headers["Cache-Control"] = "max-age=2, must-revalidate"; + _logger.LogDebug("Setting livestream cache headers for {ItemId}", itemId); + } + else + { + // VOD manifests are static, can cache longer + Response.Headers["Cache-Control"] = "max-age=3600"; + } + } + /// /// Handles CORS preflight OPTIONS requests for all proxy endpoints. /// @@ -120,6 +143,9 @@ public class StreamProxyController : ControllerBase return NotFound(); } + // Set cache headers based on stream type (live vs VOD) + AddManifestCacheHeaders(actualItemId); + _logger.LogDebug("Returning master manifest for item {ItemId} ({Length} bytes)", itemId, manifestContent.Length); return Content(manifestContent, "application/vnd.apple.mpegurl"); } @@ -170,6 +196,9 @@ public class StreamProxyController : ControllerBase var baseProxyUrl = $"{scheme}://{Request.Host}/Plugins/SRFPlay/Proxy/{itemId}"; var rewrittenContent = RewriteSegmentUrls(manifestContent, baseProxyUrl); + // Set cache headers based on stream type (live vs VOD) + AddManifestCacheHeaders(actualItemId); + _logger.LogDebug("Returning variant manifest for item {ItemId} ({Length} bytes)", itemId, rewrittenContent.Length); return Content(rewrittenContent, "application/vnd.apple.mpegurl"); } diff --git a/Jellyfin.Plugin.SRFPlay/Services/ContentRefreshService.cs b/Jellyfin.Plugin.SRFPlay/Services/ContentRefreshService.cs index a76041d..2361b54 100644 --- a/Jellyfin.Plugin.SRFPlay/Services/ContentRefreshService.cs +++ b/Jellyfin.Plugin.SRFPlay/Services/ContentRefreshService.cs @@ -37,111 +37,20 @@ public class ContentRefreshService : IContentRefreshService /// List of URNs for new content. public async Task> RefreshLatestContentAsync(CancellationToken cancellationToken) { - var urns = new List(); - - try + var config = Plugin.Instance?.Configuration; + if (config == null || !config.EnableLatestContent) { - var config = Plugin.Instance?.Configuration; - if (config == null || !config.EnableLatestContent) - { - _logger.LogDebug("Latest content refresh is disabled"); - return urns; - } - - _logger.LogInformation("Refreshing latest content for business unit: {BusinessUnit}", config.BusinessUnit); - - using var apiClient = _apiClientFactory.CreateClient(); - var businessUnit = config.BusinessUnit.ToString().ToLowerInvariant(); - - // Get all shows from Play v3 API - var shows = await apiClient.GetAllShowsAsync(businessUnit, cancellationToken).ConfigureAwait(false); - - if (shows == null || shows.Count == 0) - { - _logger.LogWarning("No shows found for business unit: {BusinessUnit}", config.BusinessUnit); - return urns; - } - - _logger.LogInformation("Found {Count} shows, fetching latest episodes from each", shows.Count); - - // Get latest episodes from each show (limit to 20 shows to avoid overwhelming) - var showsToFetch = shows.Where(s => s.NumberOfEpisodes > 0) - .OrderByDescending(s => s.NumberOfEpisodes) - .Take(20) - .ToList(); - - foreach (var show in showsToFetch) - { - if (show.Id == null) - { - continue; - } - - try - { - var videos = await apiClient.GetVideosForShowAsync(businessUnit, show.Id, cancellationToken).ConfigureAwait(false); - if (videos != null && videos.Count > 0) - { - _logger.LogDebug("Show {Show} ({ShowId}): Found {Count} videos", show.Title, show.Id, videos.Count); - - // Filter to videos that are actually published (validFrom in the past) - var now = DateTime.UtcNow; - var publishedVideos = videos.Where(v => - v.ValidFrom == null || v.ValidFrom.Value.ToUniversalTime() <= now).ToList(); - - _logger.LogDebug("Show {Show}: {PublishedCount} published out of {TotalCount} videos", show.Title, publishedVideos.Count, videos.Count); - - if (publishedVideos.Count > 0) - { - // Take only the most recent published video from each show - var latestVideo = publishedVideos.OrderByDescending(v => v.Date).FirstOrDefault(); - if (latestVideo?.Urn != null) - { - urns.Add(latestVideo.Urn); - _logger.LogInformation( - "Added latest video from show {Show}: {Title} (URN: {Urn}, Date: {Date}, ValidFrom: {ValidFrom}, ValidTo: {ValidTo})", - show.Title, - latestVideo.Title, - latestVideo.Urn, - latestVideo.Date, - latestVideo.ValidFrom, - latestVideo.ValidTo); - } - else - { - _logger.LogWarning("Show {Show}: Latest video has null URN", show.Title); - } - } - else - { - _logger.LogDebug("Show {Show} has no published videos yet", show.Title); - } - } - else - { - _logger.LogDebug("Show {Show} ({ShowId}): No videos returned from API", show.Title, show.Id); - } - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Error fetching videos for show {ShowId}", show.Id); - } - - // Respect cancellation - if (cancellationToken.IsCancellationRequested) - { - break; - } - } - - _logger.LogInformation("Refreshed {Count} latest content items from {ShowCount} shows", urns.Count, showsToFetch.Count); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error refreshing latest content"); + _logger.LogDebug("Latest content refresh is disabled"); + return new List(); } - return urns; + return await FetchVideosFromShowsAsync( + config.BusinessUnit.ToString().ToLowerInvariant(), + minEpisodeCount: 0, + maxShows: 20, + videosPerShow: 1, + contentType: "latest", + cancellationToken).ConfigureAwait(false); } /// @@ -151,43 +60,62 @@ public class ContentRefreshService : IContentRefreshService /// The cancellation token. /// List of URNs for trending content. public async Task> RefreshTrendingContentAsync(CancellationToken cancellationToken) + { + var config = Plugin.Instance?.Configuration; + if (config == null || !config.EnableTrendingContent) + { + _logger.LogDebug("Trending content refresh is disabled"); + return new List(); + } + + return await FetchVideosFromShowsAsync( + config.BusinessUnit.ToString().ToLowerInvariant(), + minEpisodeCount: 10, + maxShows: 15, + videosPerShow: 2, + contentType: "trending", + cancellationToken).ConfigureAwait(false); + } + + /// + /// Fetches videos from shows based on filter criteria. + /// + private async Task> FetchVideosFromShowsAsync( + string businessUnit, + int minEpisodeCount, + int maxShows, + int videosPerShow, + string contentType, + CancellationToken cancellationToken) { var urns = new List(); try { - var config = Plugin.Instance?.Configuration; - if (config == null || !config.EnableTrendingContent) - { - _logger.LogDebug("Trending content refresh is disabled"); - return urns; - } - - _logger.LogInformation("Refreshing trending content for business unit: {BusinessUnit}", config.BusinessUnit); + _logger.LogInformation("Refreshing {ContentType} content for business unit: {BusinessUnit}", contentType, businessUnit); using var apiClient = _apiClientFactory.CreateClient(); - var businessUnit = config.BusinessUnit.ToString().ToLowerInvariant(); - - // Get all shows from Play v3 API var shows = await apiClient.GetAllShowsAsync(businessUnit, cancellationToken).ConfigureAwait(false); if (shows == null || shows.Count == 0) { - _logger.LogWarning("No shows found for business unit: {BusinessUnit}", config.BusinessUnit); + _logger.LogWarning("No shows found for business unit: {BusinessUnit}", businessUnit); return urns; } - _logger.LogInformation("Found {Count} shows, fetching popular content", shows.Count); + _logger.LogInformation("Found {Count} shows, fetching {ContentType} content", shows.Count, contentType); - // Get videos from popular shows (those with many episodes) - var popularShows = shows.Where(s => s.NumberOfEpisodes > 10) + var filteredShows = shows + .Where(s => s.NumberOfEpisodes > minEpisodeCount) .OrderByDescending(s => s.NumberOfEpisodes) - .Take(15) + .Take(maxShows) .ToList(); - foreach (var show in popularShows) + var now = DateTime.UtcNow; + + foreach (var show in filteredShows) { - if (show.Id == null) + if (show.Id == null || cancellationToken.IsCancellationRequested) { continue; } @@ -195,64 +123,46 @@ public class ContentRefreshService : IContentRefreshService try { var videos = await apiClient.GetVideosForShowAsync(businessUnit, show.Id, cancellationToken).ConfigureAwait(false); - if (videos != null && videos.Count > 0) + if (videos == null || videos.Count == 0) { - _logger.LogDebug("Show {Show} ({ShowId}): Found {Count} videos for trending", show.Title, show.Id, videos.Count); - - // Filter to videos that are actually published (validFrom in the past) - var now = DateTime.UtcNow; - var publishedVideos = videos.Where(v => - v.ValidFrom == null || v.ValidFrom.Value.ToUniversalTime() <= now).ToList(); - - _logger.LogDebug("Show {Show}: {PublishedCount} published out of {TotalCount} videos for trending", show.Title, publishedVideos.Count, videos.Count); - - if (publishedVideos.Count > 0) - { - // Take 2 recent published videos from each popular show - var recentVideos = publishedVideos.OrderByDescending(v => v.Date).Take(2); - foreach (var video in recentVideos) - { - if (video.Urn != null) - { - urns.Add(video.Urn); - _logger.LogInformation( - "Added trending video from show {Show}: {Title} (URN: {Urn}, Date: {Date}, ValidFrom: {ValidFrom}, ValidTo: {ValidTo})", - show.Title, - video.Title, - video.Urn, - video.Date, - video.ValidFrom, - video.ValidTo); - } - else - { - _logger.LogWarning("Show {Show}: Trending video has null URN - {Title}", show.Title, video.Title); - } - } - } + _logger.LogDebug("Show {Show} ({ShowId}): No videos returned from API", show.Title, show.Id); + continue; } - else + + _logger.LogDebug("Show {Show} ({ShowId}): Found {Count} videos", show.Title, show.Id, videos.Count); + + // Filter to videos that are actually published (validFrom in the past) + var publishedVideos = videos + .Where(v => v.ValidFrom == null || v.ValidFrom.Value.ToUniversalTime() <= now) + .OrderByDescending(v => v.Date) + .Take(videosPerShow) + .ToList(); + + foreach (var video in publishedVideos) { - _logger.LogDebug("Show {Show} ({ShowId}): No videos returned from API for trending", show.Title, show.Id); + if (video.Urn != null) + { + urns.Add(video.Urn); + _logger.LogDebug( + "Added {ContentType} video from show {Show}: {Title} (URN: {Urn})", + contentType, + show.Title, + video.Title, + video.Urn); + } } } catch (Exception ex) { _logger.LogWarning(ex, "Error fetching videos for show {ShowId}", show.Id); } - - // Respect cancellation - if (cancellationToken.IsCancellationRequested) - { - break; - } } - _logger.LogInformation("Refreshed {Count} trending content items from {ShowCount} shows", urns.Count, popularShows.Count); + _logger.LogInformation("Refreshed {Count} {ContentType} content items from {ShowCount} shows", urns.Count, contentType, filteredShows.Count); } catch (Exception ex) { - _logger.LogError(ex, "Error refreshing trending content"); + _logger.LogError(ex, "Error refreshing {ContentType} content", contentType); } return urns; diff --git a/Jellyfin.Plugin.SRFPlay/Services/Interfaces/IStreamProxyService.cs b/Jellyfin.Plugin.SRFPlay/Services/Interfaces/IStreamProxyService.cs index f1db98a..b3e7346 100644 --- a/Jellyfin.Plugin.SRFPlay/Services/Interfaces/IStreamProxyService.cs +++ b/Jellyfin.Plugin.SRFPlay/Services/Interfaces/IStreamProxyService.cs @@ -38,8 +38,9 @@ public interface IStreamProxyService /// Gets the authenticated URL for an item. /// /// The item ID. + /// Cancellation token. /// The authenticated URL, or null if not found or expired. - string? GetAuthenticatedUrl(string itemId); + Task GetAuthenticatedUrlAsync(string itemId, CancellationToken cancellationToken = default); /// /// Fetches and rewrites an HLS manifest to use proxy URLs. diff --git a/Jellyfin.Plugin.SRFPlay/Services/MediaSourceFactory.cs b/Jellyfin.Plugin.SRFPlay/Services/MediaSourceFactory.cs index cc93b2e..ee4dfd4 100644 --- a/Jellyfin.Plugin.SRFPlay/Services/MediaSourceFactory.cs +++ b/Jellyfin.Plugin.SRFPlay/Services/MediaSourceFactory.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Threading; using System.Threading.Tasks; using Jellyfin.Plugin.SRFPlay.Api.Models; @@ -78,16 +79,18 @@ public class MediaSourceFactory : IMediaSourceFactory itemId, isLiveStream); - // Create MediaSourceInfo with minimal settings to let clients determine playback - // Don't specify Container or MediaStreams - let the .m3u8 path trigger HLS detection + // Create MediaSourceInfo with codec info so clients know they can direct play + // Provide MediaStreams with H.264+AAC so Android TV/ExoPlayer doesn't trigger transcoding + var mediaStreams = CreateMediaStreams(qualityPreference); + var mediaSource = new MediaSourceInfo { Id = itemId, Name = chapter.Title, Path = proxyUrl, Protocol = MediaProtocol.Http, - // Empty container - let clients detect HLS from .m3u8 extension - Container = string.Empty, + // Use "hls" to trigger hls.js player in web client + Container = "hls", SupportsDirectStream = true, SupportsDirectPlay = true, SupportsTranscoding = false, @@ -98,15 +101,13 @@ public class MediaSourceFactory : IMediaSourceFactory IsInfiniteStream = isLiveStream, RequiresOpening = false, RequiresClosing = false, + // Disable probing - we provide stream info directly SupportsProbing = false, ReadAtNativeFramerate = isLiveStream, - // Don't specify MediaStreams - let client determine codec compatibility - MediaStreams = new List(), - // Reduce analyze duration for faster startup (3000ms is Jellyfin default, 1000ms for live) + // Provide codec info so clients know they can direct play H.264+AAC + MediaStreams = mediaStreams.ToList(), AnalyzeDurationMs = isLiveStream ? 1000 : 3000, - // Ignore DTS timestamps for live streams to avoid sync issues IgnoreDts = isLiveStream, - // Ignore index for live streams IgnoreIndex = isLiveStream, }; diff --git a/Jellyfin.Plugin.SRFPlay/Services/StreamProxyService.cs b/Jellyfin.Plugin.SRFPlay/Services/StreamProxyService.cs index d5a5bca..789c579 100644 --- a/Jellyfin.Plugin.SRFPlay/Services/StreamProxyService.cs +++ b/Jellyfin.Plugin.SRFPlay/Services/StreamProxyService.cs @@ -10,6 +10,7 @@ using System.Web; using Jellyfin.Plugin.SRFPlay.Api; using Jellyfin.Plugin.SRFPlay.Configuration; using Jellyfin.Plugin.SRFPlay.Services.Interfaces; +using MediaBrowser.Common.Net; using Microsoft.Extensions.Logging; namespace Jellyfin.Plugin.SRFPlay.Services; @@ -17,14 +18,13 @@ namespace Jellyfin.Plugin.SRFPlay.Services; /// /// Service for proxying SRF Play streams and managing authentication. /// -public class StreamProxyService : IStreamProxyService, IDisposable +public class StreamProxyService : IStreamProxyService { private readonly ILogger _logger; private readonly IStreamUrlResolver _streamResolver; private readonly IMediaCompositionFetcher _compositionFetcher; - private readonly HttpClient _httpClient; + private readonly IHttpClientFactory _httpClientFactory; private readonly ConcurrentDictionary _streamMappings; - private bool _disposed; /// /// Initializes a new instance of the class. @@ -32,18 +32,17 @@ public class StreamProxyService : IStreamProxyService, IDisposable /// The logger. /// The stream URL resolver. /// The media composition fetcher. + /// The HTTP client factory. public StreamProxyService( ILogger logger, IStreamUrlResolver streamResolver, - IMediaCompositionFetcher compositionFetcher) + IMediaCompositionFetcher compositionFetcher, + IHttpClientFactory httpClientFactory) { _logger = logger; _streamResolver = streamResolver; _compositionFetcher = compositionFetcher; - _httpClient = new HttpClient - { - Timeout = TimeSpan.FromSeconds(30) - }; + _httpClientFactory = httpClientFactory; _streamMappings = new ConcurrentDictionary(); } @@ -70,29 +69,7 @@ public class StreamProxyService : IStreamProxyService, IDisposable LastLivestreamFetchAt = isLiveStream ? DateTime.UtcNow : null }; - // Register with the provided item ID - _streamMappings.AddOrUpdate(itemId, streamInfo, (key, old) => streamInfo); - - // Also register with alternative GUID formats to handle Jellyfin's ID transformations - if (Guid.TryParse(itemId, out var guid)) - { - var formats = new[] - { - guid.ToString("N"), // Without dashes: 00000000000000000000000000000000 - guid.ToString("D"), // With dashes: 00000000-0000-0000-0000-000000000000 - guid.ToString("B"), // With braces: {00000000-0000-0000-0000-000000000000} - }; - - foreach (var format in formats) - { - if (format != itemId) // Don't duplicate the original - { - _streamMappings.AddOrUpdate(format, streamInfo, (key, old) => streamInfo); - } - } - - _logger.LogDebug("Registered stream with {Count} GUID format variations", formats.Length); - } + RegisterWithGuidFormats(itemId, streamInfo); if (tokenExpiry.HasValue) { @@ -129,27 +106,7 @@ public class StreamProxyService : IStreamProxyService, IDisposable NeedsAuthentication = true }; - // Register with the provided item ID - _streamMappings.AddOrUpdate(itemId, streamInfo, (key, old) => streamInfo); - - // Also register with alternative GUID formats - if (Guid.TryParse(itemId, out var guid)) - { - var formats = new[] - { - guid.ToString("N"), - guid.ToString("D"), - guid.ToString("B"), - }; - - foreach (var format in formats) - { - if (format != itemId) - { - _streamMappings.AddOrUpdate(format, streamInfo, (key, old) => streamInfo); - } - } - } + RegisterWithGuidFormats(itemId, streamInfo); _logger.LogDebug( "Registered deferred stream for item {ItemId} (URN: {Urn}, will authenticate on first access)", @@ -191,10 +148,11 @@ public class StreamProxyService : IStreamProxyService, IDisposable /// Gets the authenticated URL for an item. /// /// The item ID. + /// Cancellation token. /// The authenticated URL, or null if not found or expired. - public string? GetAuthenticatedUrl(string itemId) + public async Task GetAuthenticatedUrlAsync(string itemId, CancellationToken cancellationToken = default) { - _logger.LogInformation("GetAuthenticatedUrl called for itemId: {ItemId}", itemId); + _logger.LogInformation("GetAuthenticatedUrlAsync called for itemId: {ItemId}", itemId); // Try direct lookup first if (_streamMappings.TryGetValue(itemId, out var streamInfo)) @@ -204,7 +162,7 @@ public class StreamProxyService : IStreamProxyService, IDisposable ? (streamInfo.TokenExpiresAt.Value - DateTime.UtcNow).TotalSeconds : -1; _logger.LogInformation( - "✅ Found stream by direct lookup for itemId: {ItemId} - NeedsAuth={NeedsAuth}, IsLive={IsLive}, Urn={Urn}, TokenLeft={TokenLeft:F0}s, AuthUrl={HasAuth}", + "Found stream by direct lookup for itemId: {ItemId} - NeedsAuth={NeedsAuth}, IsLive={IsLive}, Urn={Urn}, TokenLeft={TokenLeft:F0}s, AuthUrl={HasAuth}", itemId, streamInfo.NeedsAuthentication, streamInfo.IsLiveStream, @@ -225,14 +183,14 @@ public class StreamProxyService : IStreamProxyService, IDisposable itemId, freshStream.Value.Key); _streamMappings.AddOrUpdate(itemId, freshStream.Value.Value, (key, old) => freshStream.Value.Value); - return ValidateAndReturnStream(itemId, freshStream.Value.Value); + return await ValidateAndReturnStreamAsync(itemId, freshStream.Value.Value, cancellationToken).ConfigureAwait(false); } } - return ValidateAndReturnStream(itemId, streamInfo); + return await ValidateAndReturnStreamAsync(itemId, streamInfo, cancellationToken).ConfigureAwait(false); } - _logger.LogWarning("❌ No direct match for itemId: {ItemId}, trying fallbacks... (Registered streams: {Count})", itemId, _streamMappings.Count); + _logger.LogWarning("No direct match for itemId: {ItemId}, trying fallbacks... (Registered streams: {Count})", itemId, _streamMappings.Count); // Fallback: Try to find by GUID variations (with/without dashes) // This handles cases where Jellyfin uses different GUID formats @@ -248,7 +206,7 @@ public class StreamProxyService : IStreamProxyService, IDisposable "Found stream by GUID normalization - Requested: {RequestedId}, Registered: {RegisteredId}", itemId, kvp.Key); - var url = ValidateAndReturnStream(kvp.Key, kvp.Value); + var url = await ValidateAndReturnStreamAsync(kvp.Key, kvp.Value, cancellationToken).ConfigureAwait(false); if (url != null) { return url; // Found valid stream @@ -282,7 +240,7 @@ public class StreamProxyService : IStreamProxyService, IDisposable // Register the transcoding session ID as an alias (update if stale alias exists) _streamMappings.AddOrUpdate(itemId, activeStreams[0].Value, (key, old) => activeStreams[0].Value); - return ValidateAndReturnStream(activeStreams[0].Key, activeStreams[0].Value); + return await ValidateAndReturnStreamAsync(activeStreams[0].Key, activeStreams[0].Value, cancellationToken).ConfigureAwait(false); } // If multiple active streams, use the most recently registered one (likely the one being transcoded) @@ -305,7 +263,7 @@ public class StreamProxyService : IStreamProxyService, IDisposable // Register the transcoding session ID as an alias (update if stale alias exists) _streamMappings.AddOrUpdate(itemId, mostRecent.Value, (key, old) => mostRecent.Value); - return ValidateAndReturnStream(mostRecent.Key, mostRecent.Value); + return await ValidateAndReturnStreamAsync(mostRecent.Key, mostRecent.Value, cancellationToken).ConfigureAwait(false); } } @@ -320,7 +278,7 @@ public class StreamProxyService : IStreamProxyService, IDisposable /// /// Validates a stream and returns its URL if valid. /// - private string? ValidateAndReturnStream(string itemId, StreamInfo streamInfo) + private async Task ValidateAndReturnStreamAsync(string itemId, StreamInfo streamInfo, CancellationToken cancellationToken) { // Handle deferred authentication (first playback after browsing) if (streamInfo.NeedsAuthentication) @@ -329,7 +287,7 @@ public class StreamProxyService : IStreamProxyService, IDisposable "First playback for item {ItemId} - authenticating stream on-demand", itemId); - var authenticatedUrl = AuthenticateOnDemand(itemId, streamInfo); + var authenticatedUrl = await AuthenticateOnDemandAsync(itemId, streamInfo, cancellationToken).ConfigureAwait(false); if (authenticatedUrl != null) { return authenticatedUrl; @@ -369,7 +327,7 @@ public class StreamProxyService : IStreamProxyService, IDisposable tokenTimeLeft, timeSinceLastFetch); - var freshUrl = FetchFreshStreamUrl(itemId, streamInfo); + var freshUrl = await FetchFreshStreamUrlAsync(itemId, streamInfo, cancellationToken).ConfigureAwait(false); if (freshUrl != null) { return freshUrl; @@ -403,7 +361,7 @@ public class StreamProxyService : IStreamProxyService, IDisposable now); // Try to refresh the token - var refreshedUrl = RefreshToken(itemId, streamInfo); + var refreshedUrl = await RefreshTokenAsync(itemId, streamInfo, cancellationToken).ConfigureAwait(false); if (refreshedUrl != null) { _logger.LogInformation("Successfully refreshed token for item {ItemId}", itemId); @@ -428,7 +386,7 @@ public class StreamProxyService : IStreamProxyService, IDisposable /// /// Fetches a fresh stream URL from the SRF API for livestreams. /// - private string? FetchFreshStreamUrl(string itemId, StreamInfo streamInfo) + private async Task FetchFreshStreamUrlAsync(string itemId, StreamInfo streamInfo, CancellationToken cancellationToken) { if (string.IsNullOrEmpty(streamInfo.Urn)) { @@ -438,8 +396,7 @@ public class StreamProxyService : IStreamProxyService, IDisposable try { // Use short cache duration (5 min) for livestreams - var mediaComposition = _compositionFetcher.GetMediaCompositionAsync(streamInfo.Urn, CancellationToken.None, 5) - .GetAwaiter().GetResult(); + var mediaComposition = await _compositionFetcher.GetMediaCompositionAsync(streamInfo.Urn, cancellationToken, 5).ConfigureAwait(false); if (mediaComposition?.ChapterList == null || mediaComposition.ChapterList.Count == 0) { @@ -458,8 +415,7 @@ public class StreamProxyService : IStreamProxyService, IDisposable } // Authenticate the fresh URL - var authenticatedUrl = _streamResolver.GetAuthenticatedStreamUrlAsync(streamUrl, CancellationToken.None) - .GetAwaiter().GetResult(); + var authenticatedUrl = await _streamResolver.GetAuthenticatedStreamUrlAsync(streamUrl, cancellationToken).ConfigureAwait(false); // Update the stored stream info with the fresh data var newTokenExpiry = ExtractTokenExpiry(authenticatedUrl); @@ -486,7 +442,7 @@ public class StreamProxyService : IStreamProxyService, IDisposable /// /// Attempts to refresh an expired token. /// - private string? RefreshToken(string itemId, StreamInfo streamInfo) + private async Task RefreshTokenAsync(string itemId, StreamInfo streamInfo, CancellationToken cancellationToken) { if (string.IsNullOrEmpty(streamInfo.UnauthenticatedUrl)) { @@ -496,10 +452,10 @@ public class StreamProxyService : IStreamProxyService, IDisposable try { - // Re-authenticate the stream URL synchronously (blocking call) - var newAuthenticatedUrl = _streamResolver.GetAuthenticatedStreamUrlAsync( + // Re-authenticate the stream URL + var newAuthenticatedUrl = await _streamResolver.GetAuthenticatedStreamUrlAsync( streamInfo.UnauthenticatedUrl, - CancellationToken.None).GetAwaiter().GetResult(); + cancellationToken).ConfigureAwait(false); if (string.IsNullOrEmpty(newAuthenticatedUrl)) { @@ -528,7 +484,7 @@ public class StreamProxyService : IStreamProxyService, IDisposable /// /// Authenticates a stream on-demand (first playback after browsing). /// - private string? AuthenticateOnDemand(string itemId, StreamInfo streamInfo) + private async Task AuthenticateOnDemandAsync(string itemId, StreamInfo streamInfo, CancellationToken cancellationToken) { if (string.IsNullOrEmpty(streamInfo.UnauthenticatedUrl)) { @@ -539,9 +495,9 @@ public class StreamProxyService : IStreamProxyService, IDisposable try { // Authenticate the stream URL - var authenticatedUrl = _streamResolver.GetAuthenticatedStreamUrlAsync( + var authenticatedUrl = await _streamResolver.GetAuthenticatedStreamUrlAsync( streamInfo.UnauthenticatedUrl, - CancellationToken.None).GetAwaiter().GetResult(); + cancellationToken).ConfigureAwait(false); if (string.IsNullOrEmpty(authenticatedUrl)) { @@ -669,7 +625,7 @@ public class StreamProxyService : IStreamProxyService, IDisposable string baseProxyUrl, CancellationToken cancellationToken = default) { - var authenticatedUrl = GetAuthenticatedUrl(itemId); + var authenticatedUrl = await GetAuthenticatedUrlAsync(itemId, cancellationToken).ConfigureAwait(false); if (authenticatedUrl == null) { return null; @@ -678,14 +634,15 @@ public class StreamProxyService : IStreamProxyService, IDisposable try { _logger.LogInformation("Fetching manifest from: {Url}", authenticatedUrl); - var manifestContent = await _httpClient.GetStringAsync(authenticatedUrl, cancellationToken).ConfigureAwait(false); + using var httpClient = _httpClientFactory.CreateClient(NamedClient.Default); + var manifestContent = await httpClient.GetStringAsync(authenticatedUrl, cancellationToken).ConfigureAwait(false); - _logger.LogInformation("Original manifest ({Length} bytes):\n{Content}", manifestContent.Length, manifestContent); + _logger.LogDebug("Original manifest ({Length} bytes):\n{Content}", manifestContent.Length, manifestContent); // Rewrite the manifest to replace Akamai URLs with proxy URLs var rewrittenContent = RewriteManifestUrls(manifestContent, authenticatedUrl, baseProxyUrl); - _logger.LogInformation("Rewritten manifest for item {ItemId} ({Length} bytes):\n{Content}", itemId, rewrittenContent.Length, rewrittenContent); + _logger.LogDebug("Rewritten manifest for item {ItemId} ({Length} bytes):\n{Content}", itemId, rewrittenContent.Length, rewrittenContent); return rewrittenContent; } catch (Exception ex) @@ -707,7 +664,7 @@ public class StreamProxyService : IStreamProxyService, IDisposable string segmentPath, CancellationToken cancellationToken = default) { - var authenticatedUrl = GetAuthenticatedUrl(itemId); + var authenticatedUrl = await GetAuthenticatedUrlAsync(itemId, cancellationToken).ConfigureAwait(false); if (authenticatedUrl == null) { return null; @@ -725,13 +682,14 @@ public class StreamProxyService : IStreamProxyService, IDisposable // Build full segment URL var segmentUrl = $"{baseUrl}/{segmentPath}{queryParams}"; - _logger.LogInformation( + _logger.LogDebug( "Fetching segment - BaseUri: {BaseUri}, BaseUrl: {BaseUrl}, SegmentPath: {SegmentPath}, FullUrl: {FullUrl}", authenticatedUrl, baseUrl, segmentPath, segmentUrl); - var segmentData = await _httpClient.GetByteArrayAsync(segmentUrl, cancellationToken).ConfigureAwait(false); + using var httpClient = _httpClientFactory.CreateClient(NamedClient.Default); + var segmentData = await httpClient.GetByteArrayAsync(segmentUrl, cancellationToken).ConfigureAwait(false); _logger.LogDebug("Successfully fetched segment {SegmentPath} ({Size} bytes)", segmentPath, segmentData.Length); return segmentData; @@ -892,31 +850,33 @@ public class StreamProxyService : IStreamProxyService, IDisposable } /// - /// Disposes the service. + /// Registers a stream with multiple GUID format variations to handle Jellyfin's ID transformations. /// - public void Dispose() + /// The item ID. + /// The stream information to register. + private void RegisterWithGuidFormats(string itemId, StreamInfo streamInfo) { - Dispose(true); - GC.SuppressFinalize(this); - } + _streamMappings.AddOrUpdate(itemId, streamInfo, (key, old) => streamInfo); - /// - /// Disposes the service. - /// - /// True if disposing. - protected virtual void Dispose(bool disposing) - { - if (_disposed) + if (Guid.TryParse(itemId, out var guid)) { - return; - } + var formats = new[] + { + guid.ToString("N"), // Without dashes: 00000000000000000000000000000000 + guid.ToString("D"), // With dashes: 00000000-0000-0000-0000-000000000000 + guid.ToString("B"), // With braces: {00000000-0000-0000-0000-000000000000} + }; - if (disposing) - { - _httpClient?.Dispose(); - } + foreach (var format in formats) + { + if (format != itemId) // Don't duplicate the original + { + _streamMappings.AddOrUpdate(format, streamInfo, (key, old) => streamInfo); + } + } - _disposed = true; + _logger.LogDebug("Registered stream with {Count} GUID format variations", formats.Length); + } } /// diff --git a/Jellyfin.Plugin.SRFPlay/Services/StreamUrlResolver.cs b/Jellyfin.Plugin.SRFPlay/Services/StreamUrlResolver.cs index dc75844..8d56d6b 100644 --- a/Jellyfin.Plugin.SRFPlay/Services/StreamUrlResolver.cs +++ b/Jellyfin.Plugin.SRFPlay/Services/StreamUrlResolver.cs @@ -8,6 +8,7 @@ using Jellyfin.Plugin.SRFPlay.Api.Models; using Jellyfin.Plugin.SRFPlay.Configuration; using Jellyfin.Plugin.SRFPlay.Constants; using Jellyfin.Plugin.SRFPlay.Services.Interfaces; +using MediaBrowser.Common.Net; using Microsoft.Extensions.Logging; namespace Jellyfin.Plugin.SRFPlay.Services; @@ -15,20 +16,20 @@ namespace Jellyfin.Plugin.SRFPlay.Services; /// /// Service for resolving stream URLs from media composition resources. /// -public class StreamUrlResolver : IStreamUrlResolver, IDisposable +public class StreamUrlResolver : IStreamUrlResolver { private readonly ILogger _logger; - private readonly HttpClient _httpClient; - private bool _disposed; + private readonly IHttpClientFactory _httpClientFactory; /// /// Initializes a new instance of the class. /// /// The logger instance. - public StreamUrlResolver(ILogger logger) + /// The HTTP client factory. + public StreamUrlResolver(ILogger logger, IHttpClientFactory httpClientFactory) { _logger = logger; - _httpClient = new HttpClient(); + _httpClientFactory = httpClientFactory; } /// @@ -247,7 +248,8 @@ public class StreamUrlResolver : IStreamUrlResolver, IDisposable _logger.LogDebug("Fetching auth token from: {TokenUrl}", tokenUrl); - var response = await _httpClient.GetAsync(tokenUrl, cancellationToken).ConfigureAwait(false); + using var httpClient = _httpClientFactory.CreateClient(NamedClient.Default); + var response = await httpClient.GetAsync(tokenUrl, cancellationToken).ConfigureAwait(false); response.EnsureSuccessStatusCode(); var jsonContent = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); @@ -278,28 +280,4 @@ public class StreamUrlResolver : IStreamUrlResolver, IDisposable return streamUrl; // Return original URL as fallback } } - - /// - public void Dispose() - { - Dispose(true); - GC.SuppressFinalize(this); - } - - /// - /// Releases the unmanaged resources and optionally releases the managed resources. - /// - /// True to release both managed and unmanaged resources; false to release only unmanaged resources. - protected virtual void Dispose(bool disposing) - { - if (!_disposed) - { - if (disposing) - { - _httpClient?.Dispose(); - } - - _disposed = true; - } - } }