From cfe510e15cb6aa52c0ff1feeb563cf8d51b9e43b Mon Sep 17 00:00:00 2001 From: Duncan Tourolle Date: Sat, 15 Nov 2025 22:34:21 +0100 Subject: [PATCH] Finaly working version of livestreams --- .../Channels/SRFPlayChannel.cs | 69 ++++- .../Controllers/StreamProxyController.cs | 220 +++++++++++++++ .../Providers/SRFMediaProvider.cs | 108 ++++++-- Jellyfin.Plugin.SRFPlay/ServiceRegistrator.cs | 1 + .../Services/StreamProxyService.cs | 250 ++++++++++++++++++ 5 files changed, 608 insertions(+), 40 deletions(-) create mode 100644 Jellyfin.Plugin.SRFPlay/Controllers/StreamProxyController.cs create mode 100644 Jellyfin.Plugin.SRFPlay/Services/StreamProxyService.cs diff --git a/Jellyfin.Plugin.SRFPlay/Channels/SRFPlayChannel.cs b/Jellyfin.Plugin.SRFPlay/Channels/SRFPlayChannel.cs index 5e63e77..3d2fd0d 100644 --- a/Jellyfin.Plugin.SRFPlay/Channels/SRFPlayChannel.cs +++ b/Jellyfin.Plugin.SRFPlay/Channels/SRFPlayChannel.cs @@ -25,6 +25,7 @@ public class SRFPlayChannel : IChannel, IHasCacheKey private readonly ILoggerFactory _loggerFactory; private readonly ContentRefreshService _contentRefreshService; private readonly StreamUrlResolver _streamResolver; + private readonly StreamProxyService _proxyService; private readonly CategoryService? _categoryService; /// @@ -33,17 +34,20 @@ public class SRFPlayChannel : IChannel, IHasCacheKey /// The logger factory. /// The content refresh service. /// The stream resolver. + /// The stream proxy service. /// The category service (optional). public SRFPlayChannel( ILoggerFactory loggerFactory, ContentRefreshService contentRefreshService, StreamUrlResolver streamResolver, + StreamProxyService proxyService, CategoryService? categoryService = null) { _loggerFactory = loggerFactory; _logger = loggerFactory.CreateLogger(); _contentRefreshService = contentRefreshService; _streamResolver = streamResolver; + _proxyService = proxyService; _categoryService = categoryService; if (_categoryService == null) @@ -61,7 +65,7 @@ public class SRFPlayChannel : IChannel, IHasCacheKey public string Description => "Swiss Radio and Television video-on-demand content"; /// - public string DataVersion => "1.1"; + public string DataVersion => "2.0"; // Back to authenticating at channel refresh with auto-refresh for fresh tokens /// public string HomePageUrl => "https://www.srf.ch/play"; @@ -433,7 +437,7 @@ public class SRFPlayChannel : IChannel, IHasCacheKey // Generate deterministic GUID from URN var itemId = UrnToGuid(urn); - // Get stream URL (unauthenticated - token will be added at playback time by SRFMediaProvider) + // Get stream URL and authenticate it var streamUrl = _streamResolver.GetStreamUrl(chapter, config.QualityPreference); // Skip scheduled livestreams that haven't started yet (no stream URL available) @@ -447,6 +451,12 @@ public class SRFPlayChannel : IChannel, IHasCacheKey continue; } + // Authenticate the stream URL with fresh token + if (!string.IsNullOrEmpty(streamUrl)) + { + streamUrl = await _streamResolver.GetAuthenticatedStreamUrlAsync(streamUrl, cancellationToken).ConfigureAwait(false); + } + // Skip items without a valid stream URL if (string.IsNullOrEmpty(streamUrl)) { @@ -458,6 +468,13 @@ public class SRFPlayChannel : IChannel, IHasCacheKey continue; } + // Register stream with proxy service + _proxyService.RegisterStream(itemId, streamUrl); + + // Create proxy URL as absolute HTTP URL (required for ffmpeg) + // Use localhost as Jellyfin should be able to access its own endpoints + var proxyUrl = $"http://localhost:8096/Plugins/SRFPlay/Proxy/{itemId}/master.m3u8"; + // Build overview var overview = chapter.Description ?? chapter.Lead; @@ -477,7 +494,7 @@ public class SRFPlayChannel : IChannel, IHasCacheKey // Use ValidFrom for premiere date if this is a scheduled livestream, otherwise use Date var premiereDate = chapter.Type == "SCHEDULED_LIVESTREAM" ? chapter.ValidFrom?.ToUniversalTime() : chapter.Date?.ToUniversalTime(); - // Store unauthenticated URL as placeholder - SRFMediaProvider will provide authenticated version at playback + // Store authenticated URL - tokens refresh automatically via scheduled channel scans var item = new ChannelItemInfo { Id = itemId, @@ -501,15 +518,38 @@ public class SRFPlayChannel : IChannel, IHasCacheKey { Id = itemId, Name = chapter.Title, - Path = streamUrl, // Unauthenticated URL - placeholder only + Path = proxyUrl, // Proxy URL instead of direct Akamai URL Protocol = MediaBrowser.Model.MediaInfo.MediaProtocol.Http, - Container = "m3u8", - SupportsDirectPlay = false, // Disable direct play - requires auth from provider - SupportsDirectStream = false, // Disable direct stream - requires auth from provider - SupportsTranscoding = false, // Force use of IMediaSourceProvider - IsRemote = true, - Type = MediaBrowser.Model.Dto.MediaSourceType.Placeholder, // Mark as placeholder - VideoType = VideoType.VideoFile + Container = "hls", + SupportsDirectStream = true, + SupportsDirectPlay = true, // ✅ Enabled! Proxy handles auth + SupportsTranscoding = true, + IsRemote = false, // False because it's a local proxy endpoint + Type = MediaBrowser.Model.Dto.MediaSourceType.Default, + VideoType = VideoType.VideoFile, + RequiresOpening = false, + RequiresClosing = false, + SupportsProbing = false, // Disable probing for proxy URLs + ReadAtNativeFramerate = false, + MediaStreams = new List + { + new MediaBrowser.Model.Entities.MediaStream + { + Type = MediaBrowser.Model.Entities.MediaStreamType.Video, + Codec = "h264", + Profile = "high", + IsInterlaced = false, + IsDefault = true, + Index = 0 + }, + new MediaBrowser.Model.Entities.MediaStream + { + Type = MediaBrowser.Model.Entities.MediaStreamType.Audio, + Codec = "aac", + IsDefault = true, + Index = 1 + } + } } } }; @@ -523,6 +563,13 @@ public class SRFPlayChannel : IChannel, IHasCacheKey items.Add(item); successCount++; _logger.LogInformation("URN {Urn}: Successfully converted to channel item - {Title}", urn, chapter.Title); + _logger.LogInformation( + "URN {Urn}: MediaSource configured - DirectStream={DirectStream}, DirectPlay={DirectPlay}, Transcoding={Transcoding}, Container={Container}", + urn, + true, + true, + true, + "hls"); } catch (Exception ex) { diff --git a/Jellyfin.Plugin.SRFPlay/Controllers/StreamProxyController.cs b/Jellyfin.Plugin.SRFPlay/Controllers/StreamProxyController.cs new file mode 100644 index 0000000..4a643ae --- /dev/null +++ b/Jellyfin.Plugin.SRFPlay/Controllers/StreamProxyController.cs @@ -0,0 +1,220 @@ +using System; +using System.Globalization; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Plugin.SRFPlay.Services; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.Plugin.SRFPlay.Controllers; + +/// +/// Controller for proxying SRF Play streams. +/// +[ApiController] +[Route("Plugins/SRFPlay/Proxy")] +public class StreamProxyController : ControllerBase +{ + private readonly ILogger _logger; + private readonly StreamProxyService _proxyService; + + /// + /// Initializes a new instance of the class. + /// + /// The logger. + /// The proxy service. + public StreamProxyController(ILogger logger, StreamProxyService proxyService) + { + _logger = logger; + _proxyService = proxyService; + } + + /// + /// Proxies HLS master manifest requests. + /// + /// The item ID. + /// Cancellation token. + /// The HLS manifest with rewritten URLs. + [HttpGet("{itemId}/master.m3u8")] + [AllowAnonymous] // Allow anonymous since Jellyfin handles auth upstream + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task GetMasterManifest( + [FromRoute] string itemId, + CancellationToken cancellationToken) + { + _logger.LogInformation("Proxy request for master manifest - ItemId: {ItemId}", itemId); + + try + { + // Build the base proxy URL for this item + var baseProxyUrl = $"{Request.Scheme}://{Request.Host}/Plugins/SRFPlay/Proxy/{itemId}"; + + var manifestContent = await _proxyService.GetRewrittenManifestAsync(itemId, baseProxyUrl, cancellationToken).ConfigureAwait(false); + + if (manifestContent == null) + { + _logger.LogWarning("Manifest not found for item {ItemId}", itemId); + return NotFound(); + } + + _logger.LogDebug("Returning master manifest for item {ItemId} ({Length} bytes)", itemId, manifestContent.Length); + return Content(manifestContent, "application/vnd.apple.mpegurl"); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error proxying master manifest for item {ItemId}", itemId); + return StatusCode(StatusCodes.Status500InternalServerError); + } + } + + /// + /// Proxies HLS variant manifest requests. + /// + /// The item ID. + /// The manifest path (e.g., "index_0_av"). + /// Cancellation token. + /// The HLS manifest with rewritten segment URLs. + [HttpGet("{itemId}/{manifestPath}.m3u8")] + [AllowAnonymous] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task GetVariantManifest( + [FromRoute] string itemId, + [FromRoute] string manifestPath, + CancellationToken cancellationToken) + { + var fullPath = $"{manifestPath}.m3u8"; + _logger.LogInformation("Proxy request for variant manifest - ItemId: {ItemId}, Path: {Path}", itemId, fullPath); + + try + { + // Fetch the variant manifest as a segment + var manifestData = await _proxyService.GetSegmentAsync(itemId, fullPath, cancellationToken).ConfigureAwait(false); + + if (manifestData == null) + { + _logger.LogWarning("Variant manifest not found - ItemId: {ItemId}, Path: {Path}", itemId, fullPath); + return NotFound(); + } + + // Convert to string and rewrite URLs + var manifestContent = System.Text.Encoding.UTF8.GetString(manifestData); + var baseProxyUrl = $"{Request.Scheme}://{Request.Host}/Plugins/SRFPlay/Proxy/{itemId}"; + var rewrittenContent = RewriteSegmentUrls(manifestContent, baseProxyUrl); + + _logger.LogDebug("Returning variant manifest for item {ItemId} ({Length} bytes)", itemId, rewrittenContent.Length); + return Content(rewrittenContent, "application/vnd.apple.mpegurl"); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error proxying variant manifest - ItemId: {ItemId}, Path: {Path}", itemId, fullPath); + return StatusCode(StatusCodes.Status500InternalServerError); + } + } + + /// + /// Proxies HLS segment requests (.ts, .mp4, .m4s, .aac files). + /// + /// The item ID. + /// The segment path. + /// Cancellation token. + /// The segment data. + [HttpGet("{itemId}/{*segmentPath}")] + [AllowAnonymous] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task GetSegment( + [FromRoute] string itemId, + [FromRoute] string segmentPath, + CancellationToken cancellationToken) + { + _logger.LogDebug("Proxy request for segment - ItemId: {ItemId}, Path: {SegmentPath}", itemId, segmentPath); + + try + { + var segmentData = await _proxyService.GetSegmentAsync(itemId, segmentPath, cancellationToken).ConfigureAwait(false); + + if (segmentData == null) + { + _logger.LogWarning("Segment not found - ItemId: {ItemId}, Path: {SegmentPath}", itemId, segmentPath); + return NotFound(); + } + + // Determine content type based on file extension + var contentType = GetContentType(segmentPath); + + _logger.LogDebug("Returning segment {SegmentPath} ({Length} bytes, {ContentType})", segmentPath, segmentData.Length, contentType); + return File(segmentData, contentType); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error proxying segment - ItemId: {ItemId}, Path: {SegmentPath}", itemId, segmentPath); + return StatusCode(StatusCodes.Status500InternalServerError); + } + } + + /// + /// Rewrites segment URLs in a manifest to point to proxy. + /// + /// The manifest content. + /// The base proxy URL. + /// The rewritten manifest. + private string RewriteSegmentUrls(string manifestContent, string baseProxyUrl) + { + var lines = manifestContent.Split('\n'); + var result = new System.Text.StringBuilder(); + + foreach (var line in lines) + { + if (line.StartsWith('#') || string.IsNullOrWhiteSpace(line)) + { + // Keep metadata and blank lines as-is + result.AppendLine(line); + } + else if (line.Contains("://", StringComparison.Ordinal)) + { + // Absolute URL - extract the path and rewrite + var uri = new Uri(line.Trim()); + var segments = uri.AbsolutePath.Split('/'); + var fileName = segments[^1]; + result.AppendLine(CultureInfo.InvariantCulture, $"{baseProxyUrl}/{fileName}"); + } + else + { + // Relative URL - rewrite to proxy + result.AppendLine(CultureInfo.InvariantCulture, $"{baseProxyUrl}/{line.Trim()}"); + } + } + + return result.ToString(); + } + + /// + /// Gets the content type for a segment based on file extension. + /// + /// The segment path. + /// The MIME content type. + private static string GetContentType(string path) + { + if (path.EndsWith(".ts", StringComparison.OrdinalIgnoreCase)) + { + return "video/MP2T"; + } + + if (path.EndsWith(".mp4", StringComparison.OrdinalIgnoreCase) || + path.EndsWith(".m4s", StringComparison.OrdinalIgnoreCase)) + { + return "video/mp4"; + } + + if (path.EndsWith(".aac", StringComparison.OrdinalIgnoreCase)) + { + return "audio/aac"; + } + + return "application/octet-stream"; + } +} diff --git a/Jellyfin.Plugin.SRFPlay/Providers/SRFMediaProvider.cs b/Jellyfin.Plugin.SRFPlay/Providers/SRFMediaProvider.cs index 51b497b..cec0baa 100644 --- a/Jellyfin.Plugin.SRFPlay/Providers/SRFMediaProvider.cs +++ b/Jellyfin.Plugin.SRFPlay/Providers/SRFMediaProvider.cs @@ -23,6 +23,7 @@ public class SRFMediaProvider : IMediaSourceProvider private readonly ILoggerFactory _loggerFactory; private readonly MetadataCache _metadataCache; private readonly StreamUrlResolver _streamResolver; + private readonly StreamProxyService _proxyService; /// /// Initializes a new instance of the class. @@ -30,15 +31,18 @@ public class SRFMediaProvider : IMediaSourceProvider /// The logger factory. /// The metadata cache. /// The stream URL resolver. + /// The stream proxy service. public SRFMediaProvider( ILoggerFactory loggerFactory, MetadataCache metadataCache, - StreamUrlResolver streamResolver) + StreamUrlResolver streamResolver, + StreamProxyService proxyService) { _loggerFactory = loggerFactory; _logger = loggerFactory.CreateLogger(); _metadataCache = metadataCache; _streamResolver = streamResolver; + _proxyService = proxyService; } /// @@ -52,24 +56,35 @@ public class SRFMediaProvider : IMediaSourceProvider /// The item. /// The cancellation token. /// List of media sources. - public Task> GetMediaSources(BaseItem item, CancellationToken cancellationToken) + public async Task> GetMediaSources(BaseItem item, CancellationToken cancellationToken) { var sources = new List(); try { + // Log detailed information about the request + var stackTrace = new System.Diagnostics.StackTrace(true); + var callingMethod = stackTrace.GetFrame(1)?.GetMethod(); + _logger.LogInformation( + "GetMediaSources called - Item: {ItemName}, Type: {ItemType}, Id: {ItemId}, CalledBy: {CallingMethod}", + item.Name, + item.GetType().Name, + item.Id, + callingMethod?.DeclaringType?.Name + "." + callingMethod?.Name); + // Check if this is an SRF item if (!item.ProviderIds.TryGetValue("SRF", out var urn) || string.IsNullOrEmpty(urn)) { - return Task.FromResult>(sources); + _logger.LogDebug("Item {ItemName} is not an SRF item, returning empty sources", item.Name); + return sources; } - _logger.LogDebug("Getting media sources for URN: {Urn}", urn); + _logger.LogInformation("Getting media sources for URN: {Urn}, Item: {ItemName}", urn, item.Name); var config = Plugin.Instance?.Configuration; if (config == null) { - return Task.FromResult>(sources); + return sources; } // For scheduled livestreams, use shorter cache TTL (5 minutes) so they refresh when they go live @@ -85,7 +100,7 @@ public class SRFMediaProvider : IMediaSourceProvider if (mediaComposition == null) { using var apiClient = new SRFApiClient(_loggerFactory); - mediaComposition = apiClient.GetMediaCompositionByUrnAsync(urn, cancellationToken).GetAwaiter().GetResult(); + mediaComposition = await apiClient.GetMediaCompositionByUrnAsync(urn, cancellationToken).ConfigureAwait(false); if (mediaComposition != null) { @@ -96,7 +111,7 @@ public class SRFMediaProvider : IMediaSourceProvider if (mediaComposition?.ChapterList == null || mediaComposition.ChapterList.Count == 0) { _logger.LogWarning("No chapters found for URN: {Urn}", urn); - return Task.FromResult>(sources); + return sources; } // Get the first chapter (main video) @@ -106,14 +121,14 @@ public class SRFMediaProvider : IMediaSourceProvider if (_streamResolver.IsContentExpired(chapter)) { _logger.LogWarning("Content expired for URN: {Urn}, ValidTo: {ValidTo}", urn, chapter.ValidTo); - return Task.FromResult>(sources); + return sources; } // Check if content has playable streams if (!_streamResolver.HasPlayableContent(chapter)) { _logger.LogWarning("No playable content found for URN: {Urn}", urn); - return Task.FromResult>(sources); + return sources; } // Get stream URL based on quality preference @@ -125,7 +140,7 @@ public class SRFMediaProvider : IMediaSourceProvider _logger.LogDebug("URN {Urn}: Scheduled livestream has no stream URL, fetching fresh data", urn); using var freshApiClient = new SRFApiClient(_loggerFactory); - var freshMediaComposition = freshApiClient.GetMediaCompositionByUrnAsync(urn, cancellationToken).GetAwaiter().GetResult(); + var freshMediaComposition = await freshApiClient.GetMediaCompositionByUrnAsync(urn, cancellationToken).ConfigureAwait(false); if (freshMediaComposition?.ChapterList != null && freshMediaComposition.ChapterList.Count > 0) { @@ -145,58 +160,91 @@ public class SRFMediaProvider : IMediaSourceProvider if (string.IsNullOrEmpty(streamUrl)) { _logger.LogWarning("Could not resolve stream URL for URN: {Urn}", urn); - return Task.FromResult>(sources); + return sources; } // Authenticate the stream URL (required for all SRF streams, especially livestreams) if (!string.IsNullOrEmpty(streamUrl)) { - streamUrl = _streamResolver.GetAuthenticatedStreamUrlAsync(streamUrl, cancellationToken).GetAwaiter().GetResult(); + streamUrl = await _streamResolver.GetAuthenticatedStreamUrlAsync(streamUrl, cancellationToken).ConfigureAwait(false); _logger.LogDebug("Authenticated stream URL for URN: {Urn}", urn); } - // Create media source + // Register stream with proxy service + var itemIdStr = item.Id.ToString("N"); // Use hex format without dashes + _proxyService.RegisterStream(itemIdStr, streamUrl); + + // Create proxy URL as absolute HTTP URL (required for ffmpeg) + // Use localhost as Jellyfin should be able to access its own endpoints + var proxyUrl = $"http://localhost:8096/Plugins/SRFPlay/Proxy/{itemIdStr}/master.m3u8"; + + _logger.LogInformation("Using proxy URL for item {ItemId}: {ProxyUrl}", itemIdStr, proxyUrl); + + // Create media source using proxy URL - enables DirectPlay! var mediaSource = new MediaSourceInfo { Id = item.Id.ToString(), // Use item GUID, not URN string (required for transcoding) Name = chapter.Title, - Path = streamUrl, + Path = proxyUrl, // Proxy URL instead of direct Akamai URL Protocol = MediaProtocol.Http, - Container = "m3u8", + Container = "hls", SupportsDirectStream = true, - SupportsDirectPlay = false, // Disabled: auth tokens don't carry over to HLS segments in browser + SupportsDirectPlay = true, // ✅ Enabled! Proxy handles auth SupportsTranscoding = true, - IsRemote = true, + IsRemote = false, // False because it's a local proxy endpoint Type = MediaSourceType.Default, RunTimeTicks = chapter.Duration > 0 ? TimeSpan.FromMilliseconds(chapter.Duration).Ticks : null, VideoType = VideoType.VideoFile, IsInfiniteStream = false, RequiresOpening = false, RequiresClosing = false, - SupportsProbing = true - }; - - // Add video stream info - mediaSource.MediaStreams = new List - { - new MediaStream + SupportsProbing = false, // Disable probing for proxy URLs + ReadAtNativeFramerate = false, + MediaStreams = new List { - Type = MediaStreamType.Video, - Codec = "h264", - IsInterlaced = false, - IsDefault = true + new MediaBrowser.Model.Entities.MediaStream + { + Type = MediaStreamType.Video, + Codec = "h264", + Profile = "high", + IsInterlaced = false, + IsDefault = true, + Index = 0 + }, + new MediaBrowser.Model.Entities.MediaStream + { + Type = MediaStreamType.Audio, + Codec = "aac", + IsDefault = true, + Index = 1 + } } }; sources.Add(mediaSource); _logger.LogInformation("Resolved stream URL for {Title}: {Url}", chapter.Title, streamUrl); + _logger.LogInformation( + "MediaSource created - Id={Id}, DirectStream={DirectStream}, DirectPlay={DirectPlay}, Probing={Probing}, Container={Container}, Protocol={Protocol}, IsRemote={IsRemote}", + mediaSource.Id, + mediaSource.SupportsDirectStream, + mediaSource.SupportsDirectPlay, + mediaSource.SupportsProbing, + mediaSource.Container, + mediaSource.Protocol, + mediaSource.IsRemote); + _logger.LogInformation( + "MediaSource capabilities - SupportsTranscoding={Transcoding}, RequiresOpening={RequiresOpening}, RequiresClosing={RequiresClosing}, Type={Type}", + mediaSource.SupportsTranscoding, + mediaSource.RequiresOpening, + mediaSource.RequiresClosing, + mediaSource.Type); } catch (Exception ex) { _logger.LogError(ex, "Error getting media sources for item: {Name}", item.Name); } - return Task.FromResult>(sources); + return sources; } /// @@ -207,6 +255,7 @@ public class SRFMediaProvider : IMediaSourceProvider /// The direct stream provider. public Task GetDirectStreamProviderByUniqueId(string uniqueId, CancellationToken cancellationToken) { + _logger.LogInformation("GetDirectStreamProviderByUniqueId called with uniqueId: {UniqueId}", uniqueId); // Not needed for HTTP streams return Task.FromResult(null); } @@ -214,6 +263,7 @@ public class SRFMediaProvider : IMediaSourceProvider /// public Task OpenMediaSource(string openToken, List currentLiveStreams, CancellationToken cancellationToken) { + _logger.LogWarning("OpenMediaSource called with openToken: {OpenToken} - This should not be called for HTTP streams!", openToken); // Not needed for static HTTP streams throw new NotImplementedException(); } diff --git a/Jellyfin.Plugin.SRFPlay/ServiceRegistrator.cs b/Jellyfin.Plugin.SRFPlay/ServiceRegistrator.cs index 49495ec..cf08f17 100644 --- a/Jellyfin.Plugin.SRFPlay/ServiceRegistrator.cs +++ b/Jellyfin.Plugin.SRFPlay/ServiceRegistrator.cs @@ -25,6 +25,7 @@ public class ServiceRegistrator : IPluginServiceRegistrator serviceCollection.AddSingleton(); serviceCollection.AddSingleton(); serviceCollection.AddSingleton(); + serviceCollection.AddSingleton(); // Stream proxy service // Register metadata providers serviceCollection.AddSingleton(); diff --git a/Jellyfin.Plugin.SRFPlay/Services/StreamProxyService.cs b/Jellyfin.Plugin.SRFPlay/Services/StreamProxyService.cs new file mode 100644 index 0000000..e05a1c8 --- /dev/null +++ b/Jellyfin.Plugin.SRFPlay/Services/StreamProxyService.cs @@ -0,0 +1,250 @@ +using System; +using System.Collections.Concurrent; +using System.Net.Http; +using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.Plugin.SRFPlay.Services; + +/// +/// Service for proxying SRF Play streams and managing authentication. +/// +public class StreamProxyService : IDisposable +{ + private readonly ILogger _logger; + private readonly StreamUrlResolver _streamResolver; + private readonly HttpClient _httpClient; + private readonly ConcurrentDictionary _streamMappings; + private bool _disposed; + + /// + /// Initializes a new instance of the class. + /// + /// The logger. + /// The stream URL resolver. + public StreamProxyService(ILogger logger, StreamUrlResolver streamResolver) + { + _logger = logger; + _streamResolver = streamResolver; + _httpClient = new HttpClient + { + Timeout = TimeSpan.FromSeconds(30) + }; + _streamMappings = new ConcurrentDictionary(); + } + + /// + /// Registers a stream for proxying. + /// + /// The item ID. + /// The authenticated stream URL. + public void RegisterStream(string itemId, string authenticatedUrl) + { + var streamInfo = new StreamInfo + { + AuthenticatedUrl = authenticatedUrl, + RegisteredAt = DateTime.UtcNow + }; + + _streamMappings.AddOrUpdate(itemId, streamInfo, (key, old) => streamInfo); + _logger.LogInformation("Registered stream for item {ItemId}: {Url}", itemId, authenticatedUrl); + } + + /// + /// Gets the authenticated URL for an item. + /// + /// The item ID. + /// The authenticated URL, or null if not found. + public string? GetAuthenticatedUrl(string itemId) + { + if (_streamMappings.TryGetValue(itemId, out var streamInfo)) + { + return streamInfo.AuthenticatedUrl; + } + + _logger.LogWarning("No stream mapping found for item {ItemId}", itemId); + return null; + } + + /// + /// Fetches and rewrites an HLS manifest to use proxy URLs. + /// + /// The item ID. + /// The base proxy URL (e.g., https://jellyfin-server/Plugins/SRFPlay/Proxy/{itemId}). + /// Cancellation token. + /// The rewritten manifest content. + public async Task GetRewrittenManifestAsync( + string itemId, + string baseProxyUrl, + CancellationToken cancellationToken = default) + { + var authenticatedUrl = GetAuthenticatedUrl(itemId); + if (authenticatedUrl == null) + { + return null; + } + + try + { + _logger.LogDebug("Fetching manifest from: {Url}", authenticatedUrl); + var manifestContent = await _httpClient.GetStringAsync(authenticatedUrl, cancellationToken).ConfigureAwait(false); + + // Rewrite the manifest to replace Akamai URLs with proxy URLs + var rewrittenContent = RewriteManifestUrls(manifestContent, authenticatedUrl, baseProxyUrl); + + _logger.LogDebug("Successfully rewrote manifest for item {ItemId}", itemId); + return rewrittenContent; + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to fetch manifest for item {ItemId} from {Url}", itemId, authenticatedUrl); + return null; + } + } + + /// + /// Fetches a segment from the original source. + /// + /// The item ID. + /// The segment path. + /// Cancellation token. + /// The segment content as bytes. + public async Task GetSegmentAsync( + string itemId, + string segmentPath, + CancellationToken cancellationToken = default) + { + var authenticatedUrl = GetAuthenticatedUrl(itemId); + if (authenticatedUrl == null) + { + return null; + } + + try + { + // Build the full segment URL by combining the base URL with the segment path + var baseUri = new Uri(authenticatedUrl); + var baseUrl = $"{baseUri.Scheme}://{baseUri.Host}{string.Join('/', baseUri.AbsolutePath.Split('/')[..^1])}"; + + // Extract query parameters (auth tokens) from authenticated URL + var queryParams = baseUri.Query; + + // Build full segment URL + var segmentUrl = $"{baseUrl}/{segmentPath}{queryParams}"; + + _logger.LogDebug("Fetching segment: {SegmentUrl}", segmentUrl); + var segmentData = await _httpClient.GetByteArrayAsync(segmentUrl, cancellationToken).ConfigureAwait(false); + + _logger.LogDebug("Successfully fetched segment {SegmentPath} ({Size} bytes)", segmentPath, segmentData.Length); + return segmentData; + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to fetch segment {SegmentPath} for item {ItemId}", segmentPath, itemId); + return null; + } + } + + /// + /// Rewrites URLs in HLS manifest to point to proxy. + /// + /// The original manifest content. + /// The original base URL. + /// The proxy base URL. + /// The rewritten manifest. + private string RewriteManifestUrls(string manifestContent, string originalBaseUrl, string proxyBaseUrl) + { + var baseUri = new Uri(originalBaseUrl); + var baseUrl = $"{baseUri.Scheme}://{baseUri.Host}{string.Join('/', baseUri.AbsolutePath.Split('/')[..^1])}"; + + // Pattern to match .m3u8 and .ts/.mp4 segment references + var pattern = @"(?:^|\n)([^#\n][^\n]*\.(?:m3u8|ts|mp4|m4s|aac)[^\n]*)"; + + var rewritten = Regex.Replace(manifestContent, pattern, match => + { + var url = match.Groups[1].Value.Trim(); + + // Skip if it's already an absolute URL + if (url.StartsWith("http://", StringComparison.OrdinalIgnoreCase) || + url.StartsWith("https://", StringComparison.OrdinalIgnoreCase)) + { + // Rewrite absolute URLs to proxy + var relativePath = url.Replace(baseUrl + "/", string.Empty, StringComparison.Ordinal); + return $"\n{proxyBaseUrl}/{relativePath}"; + } + + // Relative URL - rewrite to proxy + return $"\n{proxyBaseUrl}/{url}"; + }); + + return rewritten; + } + + /// + /// Cleans up old stream mappings. + /// + public void CleanupOldMappings() + { + var cutoff = DateTime.UtcNow.AddHours(-24); + var keysToRemove = new System.Collections.Generic.List(); + + foreach (var kvp in _streamMappings) + { + if (kvp.Value.RegisteredAt < cutoff) + { + keysToRemove.Add(kvp.Key); + } + } + + foreach (var key in keysToRemove) + { + _streamMappings.TryRemove(key, out _); + _logger.LogDebug("Removed old stream mapping for item {ItemId}", key); + } + + if (keysToRemove.Count > 0) + { + _logger.LogInformation("Cleaned up {Count} old stream mappings", keysToRemove.Count); + } + } + + /// + /// Disposes the service. + /// + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + /// + /// Disposes the service. + /// + /// True if disposing. + protected virtual void Dispose(bool disposing) + { + if (_disposed) + { + return; + } + + if (disposing) + { + _httpClient?.Dispose(); + } + + _disposed = true; + } + + /// + /// Stream information. + /// + private sealed class StreamInfo + { + public string AuthenticatedUrl { get; set; } = string.Empty; + + public DateTime RegisteredAt { get; set; } + } +}