diff --git a/Jellyfin.Plugin.SRFPlay/Api/SRFApiClient.cs b/Jellyfin.Plugin.SRFPlay/Api/SRFApiClient.cs index 23358bc..14f27fc 100644 --- a/Jellyfin.Plugin.SRFPlay/Api/SRFApiClient.cs +++ b/Jellyfin.Plugin.SRFPlay/Api/SRFApiClient.cs @@ -8,6 +8,7 @@ using System.Threading.Tasks; using Jellyfin.Plugin.SRFPlay.Api.Models; using Jellyfin.Plugin.SRFPlay.Api.Models.PlayV3; using Jellyfin.Plugin.SRFPlay.Configuration; +using Jellyfin.Plugin.SRFPlay.Constants; using Microsoft.Extensions.Logging; namespace Jellyfin.Plugin.SRFPlay.Api; @@ -17,9 +18,7 @@ namespace Jellyfin.Plugin.SRFPlay.Api; /// public class SRFApiClient : IDisposable { - private const string BaseUrl = "https://il.srgssr.ch/integrationlayer/2.0"; - private const string PlayV3BaseUrlTemplate = "https://www.{0}.ch/play/v3/api/{0}/production/"; - private static readonly System.Text.CompositeFormat PlayV3UrlFormat = System.Text.CompositeFormat.Parse(PlayV3BaseUrlTemplate); + private static readonly System.Text.CompositeFormat PlayV3UrlFormat = System.Text.CompositeFormat.Parse(ApiEndpoints.PlayV3BaseUrlTemplate); private readonly HttpClient _httpClient; private readonly HttpClient _playV3HttpClient; private readonly ILogger _logger; @@ -47,7 +46,7 @@ public class SRFApiClient : IDisposable _logger.LogInformation("SRFApiClient initializing without proxy"); } - _httpClient = CreateHttpClient(BaseUrl); + _httpClient = CreateHttpClient(ApiEndpoints.IntegrationLayerBaseUrl); _playV3HttpClient = CreateHttpClient(null); _jsonOptions = new JsonSerializerOptions @@ -137,7 +136,7 @@ public class SRFApiClient : IDisposable try { var url = $"/mediaComposition/byUrn/{urn}.json"; - var fullUrl = $"{BaseUrl}{url}"; + var fullUrl = $"{ApiEndpoints.IntegrationLayerBaseUrl}{url}"; _logger.LogInformation("Fetching media composition for URN: {Urn} from {Url}", urn, fullUrl); // HttpClient consistently fails with 404, use curl directly diff --git a/Jellyfin.Plugin.SRFPlay/Channels/SRFPlayChannel.cs b/Jellyfin.Plugin.SRFPlay/Channels/SRFPlayChannel.cs index 2d84f2b..4c07127 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.Constants; using Jellyfin.Plugin.SRFPlay.Services.Interfaces; using MediaBrowser.Controller; using MediaBrowser.Controller.Channels; @@ -26,9 +27,8 @@ public class SRFPlayChannel : IChannel, IHasCacheKey private readonly ILoggerFactory _loggerFactory; private readonly IContentRefreshService _contentRefreshService; private readonly IStreamUrlResolver _streamResolver; - private readonly IStreamProxyService _proxyService; + private readonly IMediaSourceFactory _mediaSourceFactory; private readonly ICategoryService? _categoryService; - private readonly IServerApplicationHost _appHost; /// /// Initializes a new instance of the class. @@ -36,23 +36,20 @@ public class SRFPlayChannel : IChannel, IHasCacheKey /// The logger factory. /// The content refresh service. /// The stream resolver. - /// The stream proxy service. - /// The server application host. + /// The media source factory. /// The category service (optional). public SRFPlayChannel( ILoggerFactory loggerFactory, IContentRefreshService contentRefreshService, IStreamUrlResolver streamResolver, - IStreamProxyService proxyService, - IServerApplicationHost appHost, + IMediaSourceFactory mediaSourceFactory, ICategoryService? categoryService = null) { _loggerFactory = loggerFactory; _logger = loggerFactory.CreateLogger(); _contentRefreshService = contentRefreshService; _streamResolver = streamResolver; - _proxyService = proxyService; - _appHost = appHost; + _mediaSourceFactory = mediaSourceFactory; _categoryService = categoryService; if (_categoryService == null) @@ -73,7 +70,7 @@ public class SRFPlayChannel : IChannel, IHasCacheKey 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"; + public string HomePageUrl => ApiEndpoints.SrfPlayHomepage; /// public ChannelParentalRating ParentalRating => ChannelParentalRating.GeneralAudience; @@ -129,252 +126,261 @@ public class SRFPlayChannel : IChannel, IHasCacheKey { _logger.LogInformation("=== GetChannelItems called! FolderId: {FolderId} ===", query.FolderId); + try + { + var items = await GetFolderItemsAsync(query.FolderId, cancellationToken).ConfigureAwait(false); + _logger.LogInformation("Returning {Count} channel items for folder {FolderId}", items.Count, query.FolderId); + + return new ChannelItemResult + { + Items = items, + TotalRecordCount = items.Count + }; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error getting channel items for folder {FolderId}", query.FolderId); + return new ChannelItemResult { Items = new List(), TotalRecordCount = 0 }; + } + } + + private async Task> GetFolderItemsAsync(string? folderId, CancellationToken cancellationToken) + { + // Root level - show folder list + if (string.IsNullOrEmpty(folderId)) + { + return await GetRootFoldersAsync(cancellationToken).ConfigureAwait(false); + } + + // Handle known folder types + return folderId switch + { + "latest" => await GetLatestVideosAsync(cancellationToken).ConfigureAwait(false), + "trending" => await GetTrendingVideosAsync(cancellationToken).ConfigureAwait(false), + "live_sports" => await GetLiveSportsAsync(cancellationToken).ConfigureAwait(false), + _ when folderId.StartsWith("category_", StringComparison.Ordinal) => await GetCategoryVideosAsync(folderId, cancellationToken).ConfigureAwait(false), + _ => new List() + }; + } + + private async Task> GetRootFoldersAsync(CancellationToken cancellationToken) + { + var items = new List + { + CreateFolder("latest", "Latest Videos"), + CreateFolder("trending", "Trending Videos"), + CreateFolder("live_sports", "Live Sports & Events") + }; + + // Add category folders if enabled + var config = Plugin.Instance?.Configuration; + if (config?.EnableCategoryFolders == true && _categoryService != null) + { + try + { + var businessUnit = config.BusinessUnit.ToString().ToLowerInvariant(); + var topics = await _categoryService.GetTopicsAsync(businessUnit, cancellationToken).ConfigureAwait(false); + + foreach (var topic in topics.Where(t => !string.IsNullOrEmpty(t.Id))) + { + if (config.EnabledTopics != null && config.EnabledTopics.Count > 0 && !config.EnabledTopics.Contains(topic.Id!)) + { + continue; + } + + items.Add(CreateFolder($"category_{topic.Id}", topic.Title ?? topic.Id!, topic.Lead)); + } + + _logger.LogInformation("Added {Count} category folders", topics.Count); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to load category folders - continuing without categories"); + } + } + + return items; + } + + private static ChannelItemInfo CreateFolder(string id, string name, string? overview = null) + { + return new ChannelItemInfo + { + Id = id, + Name = name, + Type = ChannelItemType.Folder, + FolderType = ChannelFolderType.Container, + ImageUrl = null, + Overview = overview + }; + } + + private async Task> GetLatestVideosAsync(CancellationToken cancellationToken) + { + var urns = await _contentRefreshService.RefreshLatestContentAsync(cancellationToken).ConfigureAwait(false); + return await ConvertUrnsToChannelItems(urns, cancellationToken).ConfigureAwait(false); + } + + private async Task> GetTrendingVideosAsync(CancellationToken cancellationToken) + { + var urns = await _contentRefreshService.RefreshTrendingContentAsync(cancellationToken).ConfigureAwait(false); + return await ConvertUrnsToChannelItems(urns, cancellationToken).ConfigureAwait(false); + } + + private async Task> GetLiveSportsAsync(CancellationToken cancellationToken) + { var items = new List(); var config = Plugin.Instance?.Configuration; try { - // Root level - show categories - if (string.IsNullOrEmpty(query.FolderId)) + var businessUnit = config?.BusinessUnit.ToString().ToLowerInvariant() ?? "srf"; + + using var apiClient = new Api.SRFApiClient(_loggerFactory); + var scheduledLivestreams = await apiClient.GetScheduledLivestreamsAsync(businessUnit, "SPORT", cancellationToken).ConfigureAwait(false); + + if (scheduledLivestreams == null) { - items.Add(new ChannelItemInfo + return items; + } + + // Filter for upcoming/current events (within next 7 days) + var now = DateTime.UtcNow; + var weekFromNow = now.AddDays(7); + + var upcomingEvents = scheduledLivestreams + .Where(p => p.Urn != null && + !string.IsNullOrEmpty(p.Title) && + p.ValidFrom != null && + p.ValidFrom.Value.ToUniversalTime() <= weekFromNow && + (p.ValidTo == null || p.ValidTo.Value.ToUniversalTime() > now)) + .OrderBy(p => p.ValidFrom) + .ToList(); + + _logger.LogInformation("Found {Count} scheduled live sports events", upcomingEvents.Count); + + var urns = upcomingEvents.Select(e => e.Urn!).ToList(); + items = await ConvertUrnsToChannelItems(urns, cancellationToken).ConfigureAwait(false); + + // Enhance items with scheduled time information + foreach (var item in items) + { + var matchingEvent = upcomingEvents.FirstOrDefault(e => item.ProviderIds.ContainsKey("SRF") && item.ProviderIds["SRF"] == e.Urn); + if (matchingEvent?.ValidFrom != null) { - Id = "latest", - Name = "Latest Videos", - Type = ChannelItemType.Folder, - FolderType = ChannelFolderType.Container, - ImageUrl = null - }); + var eventTime = matchingEvent.ValidFrom.Value; + item.Name = $"[{eventTime:dd.MM HH:mm}] {matchingEvent.Title}"; + item.PremiereDate = eventTime; + } + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to load live sports events"); + } - items.Add(new ChannelItemInfo + return items; + } + + private async Task> GetCategoryVideosAsync(string folderId, CancellationToken cancellationToken) + { + var items = new List(); + + if (_categoryService == null) + { + _logger.LogWarning("CategoryService not available - cannot display category folder"); + return items; + } + + try + { + var config = Plugin.Instance?.Configuration; + var topicId = folderId.Substring("category_".Length); + var businessUnit = config?.BusinessUnit.ToString().ToLowerInvariant() ?? "srf"; + + var shows = await _categoryService.GetShowsByTopicAsync(topicId, businessUnit, 20, cancellationToken).ConfigureAwait(false); + var urns = new List(); + + using var apiClient = new Api.SRFApiClient(_loggerFactory); + + foreach (var show in shows) + { + if (show.Id == null || cancellationToken.IsCancellationRequested) { - Id = "trending", - Name = "Trending Videos", - Type = ChannelItemType.Folder, - FolderType = ChannelFolderType.Container, - ImageUrl = null - }); - - items.Add(new ChannelItemInfo - { - Id = "live_sports", - Name = "Live Sports & Events", - Type = ChannelItemType.Folder, - FolderType = ChannelFolderType.Container, - ImageUrl = null - }); - - // Add category folders if enabled and CategoryService is available - if (config?.EnableCategoryFolders == true && _categoryService != null) - { - try - { - var businessUnit = config.BusinessUnit.ToString().ToLowerInvariant(); - var topics = await _categoryService.GetTopicsAsync(businessUnit, cancellationToken).ConfigureAwait(false); - - foreach (var topic in topics.Where(t => !string.IsNullOrEmpty(t.Id))) - { - // Filter by enabled topics if configured - if (config.EnabledTopics != null && config.EnabledTopics.Count > 0 && !config.EnabledTopics.Contains(topic.Id!)) - { - continue; - } - - items.Add(new ChannelItemInfo - { - Id = $"category_{topic.Id}", - Name = topic.Title ?? topic.Id!, - Type = ChannelItemType.Folder, - FolderType = ChannelFolderType.Container, - ImageUrl = null, - Overview = topic.Lead - }); - } - - _logger.LogInformation("Added {Count} category folders", topics.Count); - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to load category folders - continuing without categories"); - } + continue; } - return new ChannelItemResult - { - Items = items, - TotalRecordCount = items.Count - }; - } - - // Latest videos - if (query.FolderId == "latest") - { - var urns = await _contentRefreshService.RefreshLatestContentAsync(cancellationToken).ConfigureAwait(false); - items = await ConvertUrnsToChannelItems(urns, cancellationToken).ConfigureAwait(false); - } - - // Trending videos - else if (query.FolderId == "trending") - { - var urns = await _contentRefreshService.RefreshTrendingContentAsync(cancellationToken).ConfigureAwait(false); - items = await ConvertUrnsToChannelItems(urns, cancellationToken).ConfigureAwait(false); - } - - // Live Sports & Events - else if (query.FolderId == "live_sports") - { try { - var businessUnit = config?.BusinessUnit.ToString().ToLowerInvariant() ?? "srf"; - - using var apiClient = new Api.SRFApiClient(_loggerFactory); - var scheduledLivestreams = await apiClient.GetScheduledLivestreamsAsync(businessUnit, "SPORT", cancellationToken).ConfigureAwait(false); - - if (scheduledLivestreams != null) + var latestUrn = await GetLatestVideoUrnForShowAsync(apiClient, businessUnit, show, topicId, cancellationToken).ConfigureAwait(false); + if (latestUrn != null) { - // Filter for upcoming/current events (within next 7 days) that have URNs - var now = DateTime.UtcNow; - var weekFromNow = now.AddDays(7); - - var upcomingEvents = scheduledLivestreams - .Where(p => p.Urn != null && - !string.IsNullOrEmpty(p.Title) && - p.ValidFrom != null && - p.ValidFrom.Value.ToUniversalTime() <= weekFromNow && - (p.ValidTo == null || p.ValidTo.Value.ToUniversalTime() > now)) - .OrderBy(p => p.ValidFrom) - .ToList(); - - _logger.LogInformation("Found {Count} scheduled live sports events", upcomingEvents.Count); - - var urns = upcomingEvents.Select(e => e.Urn!).ToList(); - items = await ConvertUrnsToChannelItems(urns, cancellationToken).ConfigureAwait(false); - - // Enhance items with scheduled time information - foreach (var item in items) - { - var matchingEvent = upcomingEvents.FirstOrDefault(e => item.ProviderIds.ContainsKey("SRF") && item.ProviderIds["SRF"] == e.Urn); - if (matchingEvent?.ValidFrom != null) - { - var eventTime = matchingEvent.ValidFrom.Value; - item.Name = $"[{eventTime:dd.MM HH:mm}] {matchingEvent.Title}"; - item.PremiereDate = eventTime; - } - } + urns.Add(latestUrn); } } catch (Exception ex) { - _logger.LogError(ex, "Failed to load live sports events"); + _logger.LogWarning(ex, "Error fetching videos for show {ShowId} in category {TopicId}", show.Id, topicId); } } - // Category folder - show videos for this category - else if (query.FolderId?.StartsWith("category_", StringComparison.Ordinal) == true) - { - if (_categoryService == null) - { - _logger.LogWarning("CategoryService not available - cannot display category folder"); - } - else - { - try - { - var topicId = query.FolderId.Substring("category_".Length); - var businessUnit = config?.BusinessUnit.ToString().ToLowerInvariant() ?? "srf"; - - var shows = await _categoryService.GetShowsByTopicAsync(topicId, businessUnit, 20, cancellationToken).ConfigureAwait(false); - var urns = new List(); - - using var apiClient = new Api.SRFApiClient(_loggerFactory); - - foreach (var show in shows) - { - 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("Category {TopicId}, Show {Show} ({ShowId}): Found {Count} videos", topicId, show.Title, show.Id, videos.Count); - - // Filter to videos that are actually published and not expired - var now = DateTime.UtcNow; - var availableVideos = videos.Where(v => - (v.ValidFrom == null || v.ValidFrom.Value.ToUniversalTime() <= now) && - (v.ValidTo == null || v.ValidTo.Value.ToUniversalTime() > now)).ToList(); - - _logger.LogDebug("Category {TopicId}, Show {Show}: {AvailableCount} available out of {TotalCount} videos", topicId, show.Title, availableVideos.Count, videos.Count); - - if (availableVideos.Count > 0) - { - // Get most recent available video from this show - var latestVideo = availableVideos.OrderByDescending(v => v.Date).FirstOrDefault(); - if (latestVideo?.Urn != null) - { - urns.Add(latestVideo.Urn); - _logger.LogInformation( - "Category {TopicId}: Added video from show {Show}: {Title} (URN: {Urn}, Date: {Date}, ValidFrom: {ValidFrom}, ValidTo: {ValidTo})", - topicId, - show.Title, - latestVideo.Title, - latestVideo.Urn, - latestVideo.Date, - latestVideo.ValidFrom, - latestVideo.ValidTo); - } - else - { - _logger.LogWarning("Category {TopicId}, Show {Show}: Latest available video has null URN", topicId, show.Title); - } - } - else - { - _logger.LogDebug("Category {TopicId}, Show {Show}: No available videos (all expired or not yet published)", topicId, show.Title); - } - } - else - { - _logger.LogDebug("Category {TopicId}, Show {Show} ({ShowId}): No videos returned from API", topicId, show.Title, show.Id); - } - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Error fetching videos for show {ShowId} in category {TopicId}", show.Id, topicId); - } - - if (cancellationToken.IsCancellationRequested) - { - break; - } - } - - items = await ConvertUrnsToChannelItems(urns, cancellationToken).ConfigureAwait(false); - _logger.LogInformation("Found {Count} videos for category {TopicId}", items.Count, topicId); - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to load category videos"); - } - } - } - - _logger.LogInformation("Returning {Count} channel items for folder {FolderId}", items.Count, query.FolderId); + items = await ConvertUrnsToChannelItems(urns, cancellationToken).ConfigureAwait(false); + _logger.LogInformation("Found {Count} videos for category {TopicId}", items.Count, topicId); } catch (Exception ex) { - _logger.LogError(ex, "Error getting channel items for folder {FolderId}", query.FolderId); + _logger.LogError(ex, "Failed to load category videos"); } - return new ChannelItemResult + return items; + } + + private async Task GetLatestVideoUrnForShowAsync( + Api.SRFApiClient apiClient, + string businessUnit, + Api.Models.PlayV3Show show, + string topicId, + CancellationToken cancellationToken) + { + var videos = await apiClient.GetVideosForShowAsync(businessUnit, show.Id!, cancellationToken).ConfigureAwait(false); + if (videos == null || videos.Count == 0) { - Items = items, - TotalRecordCount = items.Count - }; + _logger.LogDebug("Category {TopicId}, Show {Show} ({ShowId}): No videos returned from API", topicId, show.Title, show.Id); + return null; + } + + _logger.LogDebug("Category {TopicId}, Show {Show} ({ShowId}): Found {Count} videos", topicId, show.Title, show.Id, videos.Count); + + // Filter to available videos + var now = DateTime.UtcNow; + var availableVideos = videos.Where(v => + (v.ValidFrom == null || v.ValidFrom.Value.ToUniversalTime() <= now) && + (v.ValidTo == null || v.ValidTo.Value.ToUniversalTime() > now)).ToList(); + + _logger.LogDebug("Category {TopicId}, Show {Show}: {AvailableCount} available out of {TotalCount} videos", topicId, show.Title, availableVideos.Count, videos.Count); + + if (availableVideos.Count == 0) + { + _logger.LogDebug("Category {TopicId}, Show {Show}: No available videos (all expired or not yet published)", topicId, show.Title); + return null; + } + + var latestVideo = availableVideos.OrderByDescending(v => v.Date).FirstOrDefault(); + if (latestVideo?.Urn == null) + { + _logger.LogWarning("Category {TopicId}, Show {Show}: Latest available video has null URN", topicId, show.Title); + return null; + } + + _logger.LogInformation( + "Category {TopicId}: Added video from show {Show}: {Title} (URN: {Urn}, Date: {Date})", + topicId, + show.Title, + latestVideo.Title, + latestVideo.Urn, + latestVideo.Date); + + return latestVideo.Urn; } /// @@ -442,49 +448,37 @@ public class SRFPlayChannel : IChannel, IHasCacheKey // Generate deterministic GUID from URN var itemId = UrnToGuid(urn); - // Get stream URL and authenticate it - var streamUrl = _streamResolver.GetStreamUrl(chapter, config.QualityPreference); + // Use factory to create MediaSourceInfo (handles stream URL, auth, proxy registration) + var mediaSource = await _mediaSourceFactory.CreateMediaSourceAsync( + chapter, + itemId, + urn, + config.QualityPreference, + cancellationToken).ConfigureAwait(false); - // Skip scheduled livestreams that haven't started yet (no stream URL available) - if (chapter.Type == "SCHEDULED_LIVESTREAM" && string.IsNullOrEmpty(streamUrl)) + // Skip items without a valid media source (no stream URL available) + if (mediaSource == null) { - _logger.LogDebug( - "URN {Urn}: Skipping upcoming livestream '{Title}' - stream not yet available (starts at {ValidFrom})", - urn, - chapter.Title, - chapter.ValidFrom); + if (chapter.Type == "SCHEDULED_LIVESTREAM") + { + _logger.LogDebug( + "URN {Urn}: Skipping upcoming livestream '{Title}' - stream not yet available (starts at {ValidFrom})", + urn, + chapter.Title, + chapter.ValidFrom); + } + else + { + _logger.LogWarning( + "URN {Urn}: Skipping '{Title}' - no valid stream URL available", + urn, + chapter.Title); + noStreamCount++; + } + 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)) - { - _logger.LogWarning( - "URN {Urn}: Skipping '{Title}' - no valid stream URL available", - urn, - chapter.Title); - noStreamCount++; - continue; - } - - // Register stream with proxy service - _proxyService.RegisterStream(itemId, streamUrl); - - // Get the server URL for proxy - prefer configured public URL for remote clients - var serverUrl = !string.IsNullOrWhiteSpace(config.PublicServerUrl) - ? config.PublicServerUrl.TrimEnd('/') // Use configured public URL (important for Android/remote clients) - : _appHost.GetSmartApiUrl(string.Empty); // Fall back to Jellyfin's smart URL resolution - - // Create proxy URL as absolute HTTP URL (required for ffmpeg) - // Use the actual server URL so remote clients can access it - var proxyUrl = $"{serverUrl}/Plugins/SRFPlay/Proxy/{itemId}/master.m3u8"; - // Build overview var overview = chapter.Description ?? chapter.Lead; @@ -502,7 +496,7 @@ public class SRFPlayChannel : IChannel, IHasCacheKey } // Proxy image URL to fix Content-Type headers from SRF CDN - var imageUrl = CreateProxiedImageUrl(originalImageUrl, serverUrl); + var imageUrl = CreateProxiedImageUrl(originalImageUrl, _mediaSourceFactory.GetServerBaseUrl()); // 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(); @@ -525,46 +519,7 @@ public class SRFPlayChannel : IChannel, IHasCacheKey { { "SRF", urn } }, - MediaSources = new List - { - new MediaSourceInfo - { - Id = itemId, - Name = chapter.Title, - Path = proxyUrl, // Proxy URL instead of direct Akamai URL - Protocol = MediaBrowser.Model.MediaInfo.MediaProtocol.Http, - 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 - } - } - } - } + MediaSources = new List { mediaSource } }; // Add series info if available @@ -576,13 +531,11 @@ 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}", + _logger.LogDebug( + "URN {Urn}: MediaSource created via factory - DirectPlay={DirectPlay}, Transcoding={Transcoding}", urn, - true, - true, - true, - "hls"); + mediaSource.SupportsDirectPlay, + mediaSource.SupportsTranscoding); } catch (Exception ex) { diff --git a/Jellyfin.Plugin.SRFPlay/Constants/ApiEndpoints.cs b/Jellyfin.Plugin.SRFPlay/Constants/ApiEndpoints.cs new file mode 100644 index 0000000..9d1e6a1 --- /dev/null +++ b/Jellyfin.Plugin.SRFPlay/Constants/ApiEndpoints.cs @@ -0,0 +1,73 @@ +namespace Jellyfin.Plugin.SRFPlay.Constants; + +/// +/// Centralized API endpoints and URL templates used throughout the plugin. +/// +public static class ApiEndpoints +{ + /// + /// SRG SSR Integration Layer API base URL. + /// Used for fetching media compositions, video metadata, and stream URLs. + /// + public const string IntegrationLayerBaseUrl = "https://il.srgssr.ch/integrationlayer/2.0"; + + /// + /// Play V3 API URL template. Format: {0} = business unit (srf, rts, rsi, rtr, swi). + /// Used for fetching shows, topics, and latest content. + /// + public const string PlayV3BaseUrlTemplate = "https://www.{0}.ch/play/v3/api/{0}/production/"; + + /// + /// Akamai token authentication endpoint. + /// Used to get hdnts tokens for stream authentication. + /// + public const string AkamaiTokenEndpoint = "https://tp.srgssr.ch/akahd/token"; + + /// + /// SRF Play homepage URL. + /// + public const string SrfPlayHomepage = "https://www.srf.ch/play"; + + /// + /// Media composition endpoint template (relative to IntegrationLayerBaseUrl). + /// Format: {0} = URN. + /// + public const string MediaCompositionByUrnPath = "/mediaComposition/byUrn/{0}.json"; + + /// + /// Base proxy route for stream proxying (relative to server root). + /// + public const string ProxyBasePath = "/Plugins/SRFPlay/Proxy"; + + /// + /// HLS master manifest route template (relative to server root). + /// Format: {0} = item ID. + /// + public const string ProxyMasterManifestPath = "/Plugins/SRFPlay/Proxy/{0}/master.m3u8"; + + /// + /// Image proxy route template (relative to server root). + /// Format: {0} = base64-encoded original URL. + /// + public const string ImageProxyPath = "/Plugins/SRFPlay/Image/{0}"; + + /// + /// Play V3 shows endpoint (relative to PlayV3 base URL). + /// + public const string PlayV3ShowsPath = "shows"; + + /// + /// Play V3 topics endpoint (relative to PlayV3 base URL). + /// + public const string PlayV3TopicsPath = "topics"; + + /// + /// Play V3 livestreams endpoint (relative to PlayV3 base URL). + /// + public const string PlayV3LivestreamsPath = "livestreams"; + + /// + /// Play V3 scheduled livestreams endpoint (relative to PlayV3 base URL). + /// + public const string PlayV3ScheduledLivestreamsPath = "scheduled-livestreams"; +} diff --git a/Jellyfin.Plugin.SRFPlay/Controllers/StreamProxyController.cs b/Jellyfin.Plugin.SRFPlay/Controllers/StreamProxyController.cs index 84b01d5..ccf065a 100644 --- a/Jellyfin.Plugin.SRFPlay/Controllers/StreamProxyController.cs +++ b/Jellyfin.Plugin.SRFPlay/Controllers/StreamProxyController.cs @@ -40,6 +40,32 @@ public class StreamProxyController : ControllerBase _httpClientFactory = httpClientFactory; } + /// + /// Adds CORS headers to allow cross-origin requests from hls.js in browsers. + /// + private void AddCorsHeaders() + { + Response.Headers["Access-Control-Allow-Origin"] = "*"; + Response.Headers["Access-Control-Allow-Methods"] = "GET, HEAD, OPTIONS"; + Response.Headers["Access-Control-Allow-Headers"] = "Content-Type, Range, Accept, Origin"; + Response.Headers["Access-Control-Expose-Headers"] = "Content-Length, Content-Range, Accept-Ranges"; + Response.Headers["Access-Control-Max-Age"] = "86400"; // Cache preflight for 24 hours + } + + /// + /// Handles CORS preflight OPTIONS requests for all proxy endpoints. + /// + /// Empty response with CORS headers. + [HttpOptions("{itemId}/master.m3u8")] + [HttpOptions("{itemId}/{manifestPath}.m3u8")] + [HttpOptions("{itemId}/{*segmentPath}")] + [AllowAnonymous] + public IActionResult HandleOptions() + { + AddCorsHeaders(); + return Ok(); + } + /// /// Proxies HLS master manifest requests. /// @@ -54,6 +80,7 @@ public class StreamProxyController : ControllerBase [FromRoute] string itemId, CancellationToken cancellationToken) { + AddCorsHeaders(); _logger.LogInformation("Proxy request for master manifest - Path ItemId: {PathItemId}, Query params: {QueryString}", itemId, Request.QueryString); // Try to resolve the actual item ID (path ID might be a session ID during transcoding) @@ -119,6 +146,7 @@ public class StreamProxyController : ControllerBase [FromRoute] string manifestPath, CancellationToken cancellationToken) { + AddCorsHeaders(); var fullPath = $"{manifestPath}.m3u8"; _logger.LogInformation("Proxy request for variant manifest - ItemId: {ItemId}, Path: {Path}", itemId, fullPath); @@ -168,6 +196,7 @@ public class StreamProxyController : ControllerBase [FromRoute] string segmentPath, CancellationToken cancellationToken) { + AddCorsHeaders(); _logger.LogDebug("Proxy request for segment - ItemId: {ItemId}, Path: {SegmentPath}", itemId, segmentPath); // Try to resolve the actual item ID @@ -244,8 +273,8 @@ public class StreamProxyController : ControllerBase return queryItemId.ToString(); } - // No query parameters - use path ID as-is (TRANSCODING SESSION ID CASE) - _logger.LogWarning("⚠️ No query parameters found, using path ID as-is: {PathItemId} (likely transcoding session ID)", pathItemId); + // No query parameters - use path ID as-is (normal case for segments and transcoding sessions) + _logger.LogDebug("No query parameters, using path ID as-is: {PathItemId}", pathItemId); return pathItemId; } @@ -272,28 +301,52 @@ public class StreamProxyController : ControllerBase queryParams = string.Empty; } + // Helper function to rewrite a single URL + string RewriteUrl(string url) + { + if (url.Contains("://", StringComparison.Ordinal)) + { + // Absolute URL - extract filename and rewrite + var uri = new Uri(url.Trim()); + var segments = uri.AbsolutePath.Split('/'); + var fileName = segments[^1]; + return $"{baseProxyUrl}/{fileName}{queryParams}"; + } + + // Relative URL - rewrite to proxy + return $"{baseProxyUrl}/{url.Trim()}{queryParams}"; + } + var lines = manifestContent.Split('\n'); var result = new System.Text.StringBuilder(); foreach (var line in lines) { - if (line.StartsWith('#') || string.IsNullOrWhiteSpace(line)) + if (string.IsNullOrWhiteSpace(line)) { - // Keep metadata and blank lines as-is result.AppendLine(line); } - else if (line.Contains("://", StringComparison.Ordinal)) + else if (line.StartsWith('#')) { - // 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}{queryParams}"); + // HLS tag line - check for URI="..." attributes (e.g., #EXT-X-MAP:URI="init.mp4") + if (line.Contains("URI=\"", StringComparison.Ordinal)) + { + var rewrittenLine = System.Text.RegularExpressions.Regex.Replace( + line, + @"URI=""([^""]+)""", + match => $"URI=\"{RewriteUrl(match.Groups[1].Value)}\""); + result.AppendLine(rewrittenLine); + } + else + { + // Keep other metadata lines as-is + result.AppendLine(line); + } } else { - // Relative URL - rewrite to proxy - result.AppendLine(CultureInfo.InvariantCulture, $"{baseProxyUrl}/{line.Trim()}{queryParams}"); + // Non-tag line with URL - rewrite it + result.AppendLine(RewriteUrl(line)); } } diff --git a/Jellyfin.Plugin.SRFPlay/Providers/SRFLiveStream.cs b/Jellyfin.Plugin.SRFPlay/Providers/SRFLiveStream.cs deleted file mode 100644 index f7928ec..0000000 --- a/Jellyfin.Plugin.SRFPlay/Providers/SRFLiveStream.cs +++ /dev/null @@ -1,139 +0,0 @@ -using System; -using System.IO; -using System.Threading; -using System.Threading.Tasks; -using Jellyfin.Plugin.SRFPlay.Services.Interfaces; -using MediaBrowser.Controller.Library; -using MediaBrowser.Model.Dto; -using Microsoft.Extensions.Logging; - -namespace Jellyfin.Plugin.SRFPlay.Providers; - -/// -/// Live stream wrapper for SRF Play streams to handle transcoding sessions. -/// -internal sealed class SRFLiveStream : ILiveStream -{ - private readonly ILogger _logger; - private readonly IStreamProxyService _proxyService; - private readonly string _originalItemId; - private MediaSourceInfo? _mediaSource; - - /// - /// Initializes a new instance of the class. - /// - /// The logger. - /// The stream proxy service. - /// The original item ID. - /// The open token. - public SRFLiveStream( - ILogger logger, - IStreamProxyService proxyService, - string originalItemId, - string openToken) - { - _logger = logger; - _proxyService = proxyService; - _originalItemId = originalItemId; - OriginalStreamId = openToken; - UniqueId = openToken; - } - - /// - public int ConsumerCount { get; set; } - - /// - public string OriginalStreamId { get; set; } - - /// - public string UniqueId { get; } - - /// - public string TunerHostId => string.Empty; - - /// - public bool EnableStreamSharing => false; - - /// - public MediaSourceInfo MediaSource - { - get => _mediaSource ?? throw new InvalidOperationException("MediaSource not set"); - set - { - _mediaSource = value; - _logger.LogInformation( - "SRFLiveStream MediaSource set - Id: {MediaSourceId}, Path: {Path}, OriginalItemId: {OriginalItemId}", - value.Id, - value.Path, - _originalItemId); - - // When Jellyfin assigns a live stream ID (for transcoding), register the stream with that ID too - if (value.Id != _originalItemId) - { - _logger.LogInformation( - "Transcoding session detected - LiveStream ID {LiveStreamId} differs from original item ID {OriginalItemId}. Registering stream with both IDs.", - value.Id, - _originalItemId); - - // Get the authenticated URL and metadata from the original registration - var authenticatedUrl = _proxyService.GetAuthenticatedUrl(_originalItemId); - var metadata = _proxyService.GetStreamMetadata(_originalItemId); - if (authenticatedUrl != null) - { - // Register the same stream URL with the transcoding session ID, preserving metadata - var urn = metadata?.Urn; - var isLiveStream = metadata?.IsLiveStream ?? false; - _proxyService.RegisterStream(value.Id, authenticatedUrl, urn, isLiveStream); - _logger.LogInformation( - "Registered stream for transcoding session ID: {LiveStreamId} (URN: {Urn}, IsLiveStream: {IsLiveStream})", - value.Id, - urn ?? "null", - isLiveStream); - } - else - { - _logger.LogWarning("Could not find authenticated URL for original item {OriginalItemId}", _originalItemId); - } - } - } - } - - /// - public Task Close() - { - _logger.LogInformation("Closing SRF live stream for item {OriginalItemId}", _originalItemId); - return Task.CompletedTask; - } - - /// - public Task Open(CancellationToken cancellationToken) - { - _logger.LogInformation("Opening SRF live stream for item {OriginalItemId}", _originalItemId); - return Task.CompletedTask; - } - - /// - public Stream GetStream() - { - throw new NotSupportedException("Direct stream access not supported for SRF streams"); - } - - /// - public void Dispose() - { - Dispose(true); - GC.SuppressFinalize(this); - } - - /// - /// Releases the unmanaged resources used by the SRFLiveStream and optionally releases the managed resources. - /// - /// True to release both managed and unmanaged resources; false to release only unmanaged resources. - private void Dispose(bool disposing) - { - if (disposing) - { - _logger.LogDebug("Disposing SRF live stream for item {OriginalItemId}", _originalItemId); - } - } -} diff --git a/Jellyfin.Plugin.SRFPlay/Providers/SRFMediaProvider.cs b/Jellyfin.Plugin.SRFPlay/Providers/SRFMediaProvider.cs index d89d76c..9f9518f 100644 --- a/Jellyfin.Plugin.SRFPlay/Providers/SRFMediaProvider.cs +++ b/Jellyfin.Plugin.SRFPlay/Providers/SRFMediaProvider.cs @@ -4,12 +4,9 @@ using System.Threading; using System.Threading.Tasks; using Jellyfin.Plugin.SRFPlay.Configuration; using Jellyfin.Plugin.SRFPlay.Services.Interfaces; -using MediaBrowser.Controller; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; using MediaBrowser.Model.Dto; -using MediaBrowser.Model.Entities; -using MediaBrowser.Model.MediaInfo; using Microsoft.Extensions.Logging; namespace Jellyfin.Plugin.SRFPlay.Providers; @@ -22,8 +19,7 @@ public class SRFMediaProvider : IMediaSourceProvider private readonly ILogger _logger; private readonly IMediaCompositionFetcher _compositionFetcher; private readonly IStreamUrlResolver _streamResolver; - private readonly IStreamProxyService _proxyService; - private readonly IServerApplicationHost _appHost; + private readonly IMediaSourceFactory _mediaSourceFactory; /// /// Initializes a new instance of the class. @@ -31,20 +27,17 @@ public class SRFMediaProvider : IMediaSourceProvider /// The logger factory. /// The media composition fetcher. /// The stream URL resolver. - /// The stream proxy service. - /// The server application host. + /// The media source factory. public SRFMediaProvider( ILoggerFactory loggerFactory, IMediaCompositionFetcher compositionFetcher, IStreamUrlResolver streamResolver, - IStreamProxyService proxyService, - IServerApplicationHost appHost) + IMediaSourceFactory mediaSourceFactory) { _logger = loggerFactory.CreateLogger(); _compositionFetcher = compositionFetcher; _streamResolver = streamResolver; - _proxyService = proxyService; - _appHost = appHost; + _mediaSourceFactory = mediaSourceFactory; } /// @@ -64,24 +57,13 @@ public class SRFMediaProvider : IMediaSourceProvider 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)) { - _logger.LogDebug("Item {ItemName} is not an SRF item, returning empty sources", item.Name); return sources; } - _logger.LogInformation("Getting media sources for URN: {Urn}, Item: {ItemName}", urn, item.Name); + _logger.LogDebug("GetMediaSources for URN: {Urn}, Item: {ItemName}", urn, item.Name); // For scheduled livestreams, use shorter cache TTL (5 minutes) so they refresh when they go live var cacheDuration = urn.Contains("scheduled_livestream", StringComparison.OrdinalIgnoreCase) ? 5 : (int?)null; @@ -111,13 +93,23 @@ public class SRFMediaProvider : IMediaSourceProvider return sources; } - // Get stream URL based on quality preference + // Get quality preference from config var config = Plugin.Instance?.Configuration; var qualityPref = config?.QualityPreference ?? QualityPreference.HD; - var streamUrl = _streamResolver.GetStreamUrl(chapter, qualityPref); - // For scheduled livestreams, always fetch fresh data to ensure stream URL is current - if (chapter.Type == "SCHEDULED_LIVESTREAM" && string.IsNullOrEmpty(streamUrl)) + // Use item ID in hex format without dashes + var itemIdStr = item.Id.ToString("N"); + + // Use factory to create MediaSourceInfo + var mediaSource = await _mediaSourceFactory.CreateMediaSourceAsync( + chapter, + itemIdStr, + urn, + qualityPref, + cancellationToken).ConfigureAwait(false); + + // For scheduled livestreams, retry with fresh data if no stream URL + if (mediaSource == null && chapter.Type == "SCHEDULED_LIVESTREAM") { _logger.LogDebug("URN {Urn}: Scheduled livestream has no stream URL, fetching fresh data", urn); @@ -127,9 +119,14 @@ public class SRFMediaProvider : IMediaSourceProvider if (freshMediaComposition?.ChapterList != null && freshMediaComposition.ChapterList.Count > 0) { var freshChapter = freshMediaComposition.ChapterList[0]; - streamUrl = _streamResolver.GetStreamUrl(freshChapter, qualityPref); + mediaSource = await _mediaSourceFactory.CreateMediaSourceAsync( + freshChapter, + itemIdStr, + urn, + qualityPref, + cancellationToken).ConfigureAwait(false); - if (!string.IsNullOrEmpty(streamUrl)) + if (mediaSource != null) { chapter = freshChapter; _logger.LogInformation("URN {Urn}: Got fresh stream URL for scheduled livestream", urn); @@ -137,87 +134,19 @@ public class SRFMediaProvider : IMediaSourceProvider } } - if (string.IsNullOrEmpty(streamUrl)) + if (mediaSource == null) { _logger.LogWarning("Could not resolve stream URL for URN: {Urn}", urn); return sources; } - // Authenticate the stream URL (required for all SRF streams, especially livestreams) - if (!string.IsNullOrEmpty(streamUrl)) - { - streamUrl = await _streamResolver.GetAuthenticatedStreamUrlAsync(streamUrl, cancellationToken).ConfigureAwait(false); - _logger.LogDebug("Authenticated stream URL for URN: {Urn}", urn); - } - - // Detect if this is a live stream - var isLiveStream = chapter.Type == "SCHEDULED_LIVESTREAM" || urn.Contains("livestream", StringComparison.OrdinalIgnoreCase); - _logger.LogInformation( - "Livestream detection - ChapterType: {ChapterType}, URN: {Urn}, IsLiveStream: {IsLiveStream}", - chapter.Type, - urn, - isLiveStream); - - // Register stream with proxy service - var itemIdStr = item.Id.ToString("N"); // Use hex format without dashes - _proxyService.RegisterStream(itemIdStr, streamUrl, urn, isLiveStream); - - // Get the server URL for proxy - prefer configured public URL for remote clients - var serverUrl = config != null && !string.IsNullOrWhiteSpace(config.PublicServerUrl) - ? config.PublicServerUrl.TrimEnd('/') // Use configured public URL (important for Android/remote clients) - : _appHost.GetSmartApiUrl(string.Empty); // Fall back to Jellyfin's smart URL resolution - - // Create proxy URL - item ID is all we need since proxy handles auth - var proxyUrl = $"{serverUrl}/Plugins/SRFPlay/Proxy/{itemIdStr}/master.m3u8"; - - _logger.LogInformation( - "Using proxy URL for item {ItemId}: {ProxyUrl} (PublicServerUrl configured: {IsPublicConfigured})", - itemIdStr, - proxyUrl, - config != null && !string.IsNullOrWhiteSpace(config.PublicServerUrl)); - - // Create media source using proxy URL - enables DirectPlay! - var mediaSource = new MediaSourceInfo - { - Id = itemIdStr, // Must match the ID used in proxy URL registration - Name = chapter.Title, - Path = proxyUrl, // Proxy URL instead of direct Akamai URL - Protocol = MediaProtocol.Http, - Container = "hls", - SupportsDirectStream = true, - SupportsDirectPlay = true, // ✅ Enabled! Proxy handles auth - SupportsTranscoding = false, // Prefer DirectPlay - no transcoding needed for HLS - 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 = isLiveStream, // True for live streams! - RequiresOpening = false, // Proxy handles auth - no need for OpenMediaSource - RequiresClosing = false, - SupportsProbing = true, // Enable probing so Jellyfin can verify stream compatibility - ReadAtNativeFramerate = isLiveStream, // Read at native framerate for live streams - MediaStreams = CreateMediaStreams(qualityPref) - }; - 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}, IsLiveStream={IsLiveStream}", + _logger.LogDebug( + "MediaSource created for {Title} - Id={Id}, DirectPlay={DirectPlay}, Transcoding={Transcoding}", + chapter.Title, mediaSource.Id, - mediaSource.SupportsDirectStream, mediaSource.SupportsDirectPlay, - mediaSource.SupportsProbing, - mediaSource.Container, - mediaSource.Protocol, - mediaSource.IsRemote, - isLiveStream); - _logger.LogInformation( - "MediaSource capabilities - SupportsTranscoding={Transcoding}, RequiresOpening={RequiresOpening}, RequiresClosing={RequiresClosing}, Type={Type}, IsInfiniteStream={IsInfiniteStream}", - mediaSource.SupportsTranscoding, - mediaSource.RequiresOpening, - mediaSource.RequiresClosing, - mediaSource.Type, - mediaSource.IsInfiniteStream); + mediaSource.SupportsTranscoding); } catch (Exception ex) { @@ -247,51 +176,4 @@ public class SRFMediaProvider : IMediaSourceProvider _logger.LogWarning("OpenMediaSource unexpectedly called with openToken: {OpenToken}", openToken); throw new NotSupportedException("OpenMediaSource not supported - streams use direct proxy access"); } - - /// - /// Creates MediaStream metadata based on quality preference. - /// These are approximate values - HLS adaptive streaming will use actual stream qualities. - /// - private static List CreateMediaStreams(QualityPreference quality) - { - // Set resolution/bitrate based on quality preference - // These are typical values for SRF streams - actual HLS will adapt - var (width, height, videoBitrate) = quality switch - { - QualityPreference.SD => (1280, 720, 2500000), - QualityPreference.HD => (1920, 1080, 5000000), - _ => (1280, 720, 3000000) - }; - - return new List - { - new MediaBrowser.Model.Entities.MediaStream - { - Type = MediaStreamType.Video, - Codec = "h264", - Profile = "high", - Level = 40, - Width = width, - Height = height, - BitRate = videoBitrate, - BitDepth = 8, - IsInterlaced = false, - IsDefault = true, - Index = 0, - IsAVC = true, - PixelFormat = "yuv420p" - }, - new MediaBrowser.Model.Entities.MediaStream - { - Type = MediaStreamType.Audio, - Codec = "aac", - Profile = "LC", - Channels = 2, - SampleRate = 48000, - BitRate = 128000, - IsDefault = true, - Index = 1 - } - }; - } } diff --git a/Jellyfin.Plugin.SRFPlay/ServiceRegistrator.cs b/Jellyfin.Plugin.SRFPlay/ServiceRegistrator.cs index 771d47e..fad5557 100644 --- a/Jellyfin.Plugin.SRFPlay/ServiceRegistrator.cs +++ b/Jellyfin.Plugin.SRFPlay/ServiceRegistrator.cs @@ -28,6 +28,7 @@ public class ServiceRegistrator : IPluginServiceRegistrator serviceCollection.AddSingleton(); serviceCollection.AddSingleton(); serviceCollection.AddSingleton(); + serviceCollection.AddSingleton(); serviceCollection.AddSingleton(); serviceCollection.AddSingleton(); serviceCollection.AddSingleton(); diff --git a/Jellyfin.Plugin.SRFPlay/Services/Interfaces/IMediaSourceFactory.cs b/Jellyfin.Plugin.SRFPlay/Services/Interfaces/IMediaSourceFactory.cs new file mode 100644 index 0000000..4d93908 --- /dev/null +++ b/Jellyfin.Plugin.SRFPlay/Services/Interfaces/IMediaSourceFactory.cs @@ -0,0 +1,51 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Plugin.SRFPlay.Api.Models; +using Jellyfin.Plugin.SRFPlay.Configuration; +using MediaBrowser.Model.Dto; +using MediaBrowser.Model.Entities; + +namespace Jellyfin.Plugin.SRFPlay.Services.Interfaces; + +/// +/// Factory for creating MediaSourceInfo objects with consistent configuration. +/// +public interface IMediaSourceFactory +{ + /// + /// Creates a MediaSourceInfo for a chapter with proper stream authentication and proxy registration. + /// + /// The chapter containing stream resources. + /// The unique item ID for proxy registration. + /// The URN of the content. + /// The preferred quality. + /// The cancellation token. + /// A configured MediaSourceInfo, or null if stream URL cannot be resolved. + Task CreateMediaSourceAsync( + Chapter chapter, + string itemId, + string urn, + QualityPreference qualityPreference, + CancellationToken cancellationToken = default); + + /// + /// Builds a proxy URL for an item. + /// + /// The item ID. + /// The full proxy URL including server address. + string BuildProxyUrl(string itemId); + + /// + /// Gets the server base URL (configured public URL or smart URL). + /// + /// The server base URL without trailing slash. + string GetServerBaseUrl(); + + /// + /// Creates MediaStream metadata based on quality preference. + /// + /// The quality preference. + /// List of MediaStream objects for video and audio. + IReadOnlyList CreateMediaStreams(QualityPreference quality); +} diff --git a/Jellyfin.Plugin.SRFPlay/Services/Interfaces/IStreamProxyService.cs b/Jellyfin.Plugin.SRFPlay/Services/Interfaces/IStreamProxyService.cs index aeda3ec..f1db98a 100644 --- a/Jellyfin.Plugin.SRFPlay/Services/Interfaces/IStreamProxyService.cs +++ b/Jellyfin.Plugin.SRFPlay/Services/Interfaces/IStreamProxyService.cs @@ -9,7 +9,7 @@ namespace Jellyfin.Plugin.SRFPlay.Services.Interfaces; public interface IStreamProxyService { /// - /// Registers a stream for proxying. + /// Registers a stream for proxying with an already-authenticated URL. /// /// The item ID. /// The authenticated stream URL. @@ -17,6 +17,16 @@ public interface IStreamProxyService /// Whether this is a livestream (livestreams always fetch fresh URLs). void RegisterStream(string itemId, string authenticatedUrl, string? urn = null, bool isLiveStream = false); + /// + /// Registers a stream for deferred authentication (authenticates on first playback request). + /// Use this when browsing to avoid wasting 30-second tokens before the user clicks play. + /// + /// The item ID. + /// The unauthenticated stream URL. + /// The SRF URN for this content. + /// Whether this is a livestream. + void RegisterStreamDeferred(string itemId, string unauthenticatedUrl, string? urn = null, bool isLiveStream = false); + /// /// Gets stream metadata for an item (URN and isLiveStream flag). /// diff --git a/Jellyfin.Plugin.SRFPlay/Services/MediaSourceFactory.cs b/Jellyfin.Plugin.SRFPlay/Services/MediaSourceFactory.cs new file mode 100644 index 0000000..cc93b2e --- /dev/null +++ b/Jellyfin.Plugin.SRFPlay/Services/MediaSourceFactory.cs @@ -0,0 +1,173 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Plugin.SRFPlay.Api.Models; +using Jellyfin.Plugin.SRFPlay.Configuration; +using Jellyfin.Plugin.SRFPlay.Constants; +using Jellyfin.Plugin.SRFPlay.Services.Interfaces; +using MediaBrowser.Controller; +using MediaBrowser.Model.Dto; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.MediaInfo; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.Plugin.SRFPlay.Services; + +/// +/// Factory for creating MediaSourceInfo objects with consistent configuration. +/// Consolidates duplicated logic from SRFMediaProvider and SRFPlayChannel. +/// +public class MediaSourceFactory : IMediaSourceFactory +{ + private readonly ILogger _logger; + private readonly IStreamUrlResolver _streamResolver; + private readonly IStreamProxyService _proxyService; + private readonly IServerApplicationHost _appHost; + + /// + /// Initializes a new instance of the class. + /// + /// The logger. + /// The stream URL resolver. + /// The stream proxy service. + /// The server application host. + public MediaSourceFactory( + ILogger logger, + IStreamUrlResolver streamResolver, + IStreamProxyService proxyService, + IServerApplicationHost appHost) + { + _logger = logger; + _streamResolver = streamResolver; + _proxyService = proxyService; + _appHost = appHost; + } + + /// + public Task CreateMediaSourceAsync( + Chapter chapter, + string itemId, + string urn, + QualityPreference qualityPreference, + CancellationToken cancellationToken = default) + { + // Get stream URL based on quality preference (unauthenticated) + var streamUrl = _streamResolver.GetStreamUrl(chapter, qualityPreference); + + if (string.IsNullOrEmpty(streamUrl)) + { + _logger.LogWarning("Could not resolve stream URL for chapter: {ChapterId}", chapter.Id); + return Task.FromResult(null); + } + + // Detect if this is a live stream + var isLiveStream = chapter.Type == "SCHEDULED_LIVESTREAM" || + urn.Contains("livestream", StringComparison.OrdinalIgnoreCase); + + // Register stream with UNAUTHENTICATED URL - proxy will authenticate on-demand + // This avoids wasting 30-second tokens during category browsing + _proxyService.RegisterStreamDeferred(itemId, streamUrl, urn, isLiveStream); + + // Build proxy URL + var proxyUrl = BuildProxyUrl(itemId); + + _logger.LogDebug( + "Created media source for {Title} - ItemId: {ItemId}, IsLiveStream: {IsLiveStream}", + chapter.Title, + 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 + 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, + SupportsDirectStream = true, + SupportsDirectPlay = true, + SupportsTranscoding = false, + IsRemote = true, + Type = MediaSourceType.Default, + RunTimeTicks = chapter.Duration > 0 ? TimeSpan.FromMilliseconds(chapter.Duration).Ticks : null, + VideoType = VideoType.VideoFile, + IsInfiniteStream = isLiveStream, + RequiresOpening = false, + RequiresClosing = false, + 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) + AnalyzeDurationMs = isLiveStream ? 1000 : 3000, + // Ignore DTS timestamps for live streams to avoid sync issues + IgnoreDts = isLiveStream, + // Ignore index for live streams + IgnoreIndex = isLiveStream, + }; + + return Task.FromResult(mediaSource); + } + + /// + public string BuildProxyUrl(string itemId) + { + return $"{GetServerBaseUrl()}{ApiEndpoints.ProxyMasterManifestPath.Replace("{0}", itemId, StringComparison.Ordinal)}"; + } + + /// + public string GetServerBaseUrl() + { + var config = Plugin.Instance?.Configuration; + return config != null && !string.IsNullOrWhiteSpace(config.PublicServerUrl) + ? config.PublicServerUrl.TrimEnd('/') + : _appHost.GetSmartApiUrl(string.Empty); + } + + /// + public IReadOnlyList CreateMediaStreams(QualityPreference quality) + { + // Set resolution/bitrate based on quality preference + var (width, height, videoBitrate) = quality switch + { + QualityPreference.SD => (1280, 720, 2500000), + QualityPreference.HD => (1920, 1080, 5000000), + _ => (1280, 720, 3000000) + }; + + return new List + { + new MediaStream + { + Type = MediaStreamType.Video, + Codec = "h264", + Profile = "high", + Level = 40, + Width = width, + Height = height, + BitRate = videoBitrate, + BitDepth = 8, + IsInterlaced = false, + IsDefault = true, + Index = 0, + IsAVC = true, + PixelFormat = "yuv420p" + }, + new MediaStream + { + Type = MediaStreamType.Audio, + Codec = "aac", + Profile = "LC", + Channels = 2, + SampleRate = 48000, + BitRate = 128000, + IsDefault = true, + Index = 1 + } + }; + } +} diff --git a/Jellyfin.Plugin.SRFPlay/Services/StreamProxyService.cs b/Jellyfin.Plugin.SRFPlay/Services/StreamProxyService.cs index 1e8e455..d5a5bca 100644 --- a/Jellyfin.Plugin.SRFPlay/Services/StreamProxyService.cs +++ b/Jellyfin.Plugin.SRFPlay/Services/StreamProxyService.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Concurrent; +using System.Collections.Generic; using System.Linq; using System.Net.Http; using System.Text.RegularExpressions; @@ -65,7 +66,8 @@ public class StreamProxyService : IStreamProxyService, IDisposable RegisteredAt = DateTime.UtcNow, TokenExpiresAt = tokenExpiry, Urn = urn, - IsLiveStream = isLiveStream + IsLiveStream = isLiveStream, + LastLivestreamFetchAt = isLiveStream ? DateTime.UtcNow : null }; // Register with the provided item ID @@ -106,6 +108,55 @@ public class StreamProxyService : IStreamProxyService, IDisposable } } + /// + /// Registers a stream for deferred authentication (authenticates on first playback request). + /// + /// The item ID. + /// The unauthenticated stream URL. + /// The SRF URN for this content. + /// Whether this is a livestream. + public void RegisterStreamDeferred(string itemId, string unauthenticatedUrl, string? urn = null, bool isLiveStream = false) + { + var streamInfo = new StreamInfo + { + AuthenticatedUrl = string.Empty, // Will be populated on first access + UnauthenticatedUrl = unauthenticatedUrl, + RegisteredAt = DateTime.UtcNow, + TokenExpiresAt = null, + Urn = urn, + IsLiveStream = isLiveStream, + LastLivestreamFetchAt = null, + 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); + } + } + } + + _logger.LogDebug( + "Registered deferred stream for item {ItemId} (URN: {Urn}, will authenticate on first access)", + itemId, + urn ?? "null"); + } + /// /// Gets stream metadata for an item (URN and isLiveStream flag). /// Used when propagating stream registration to transcoding sessions. @@ -148,7 +199,36 @@ public class StreamProxyService : IStreamProxyService, IDisposable // Try direct lookup first if (_streamMappings.TryGetValue(itemId, out var streamInfo)) { - _logger.LogInformation("✅ Found stream by direct lookup for itemId: {ItemId}", itemId); + // Log detailed StreamInfo state to diagnose stale alias issues + var tokenTimeLeft = streamInfo.TokenExpiresAt.HasValue + ? (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}", + itemId, + streamInfo.NeedsAuthentication, + streamInfo.IsLiveStream, + string.IsNullOrEmpty(streamInfo.Urn) ? "(empty)" : "set", + tokenTimeLeft, + !string.IsNullOrEmpty(streamInfo.AuthenticatedUrl)); + + // Check for stale alias: only look for fresher stream if current token is EXPIRED or EXPIRING SOON + // Don't replace a valid token (>5s left) with a new deferred registration + if (!streamInfo.NeedsAuthentication && tokenTimeLeft < 5) + { + var freshStream = FindFreshestStream(); + if (freshStream != null && freshStream.Value.Value.NeedsAuthentication) + { + _logger.LogWarning( + "Token expiring soon ({TokenLeft:F0}s), switching to fresher deferred stream {ItemId} -> {FreshKey}", + tokenTimeLeft, + itemId, + freshStream.Value.Key); + _streamMappings.AddOrUpdate(itemId, freshStream.Value.Value, (key, old) => freshStream.Value.Value); + return ValidateAndReturnStream(itemId, freshStream.Value.Value); + } + } + return ValidateAndReturnStream(itemId, streamInfo); } @@ -194,10 +274,14 @@ public class StreamProxyService : IStreamProxyService, IDisposable if (activeStreams.Count == 1) { - _logger.LogWarning( - "No exact match for {RequestedId}, but found single active stream {RegisteredId} - using as fallback", + _logger.LogInformation( + "Transcoding session detected: Aliasing {TranscodingId} -> {OriginalId} (single active stream)", itemId, activeStreams[0].Key); + + // 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); } @@ -212,11 +296,15 @@ public class StreamProxyService : IStreamProxyService, IDisposable // This indicates it's likely the stream currently being set up for transcoding if (age.TotalSeconds < 30) { - _logger.LogWarning( - "No exact match for {RequestedId}, but using most recently registered stream {RegisteredId} (registered {Seconds}s ago) as fallback", + _logger.LogInformation( + "Transcoding session detected: Aliasing {TranscodingId} -> {OriginalId} (registered {Seconds:F1}s ago)", itemId, mostRecent.Key, age.TotalSeconds); + + // 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); } } @@ -234,13 +322,52 @@ public class StreamProxyService : IStreamProxyService, IDisposable /// private string? ValidateAndReturnStream(string itemId, StreamInfo streamInfo) { - // For livestreams, always fetch fresh URL from API to avoid stale CDN paths - if (streamInfo.IsLiveStream && !string.IsNullOrEmpty(streamInfo.Urn)) + // Handle deferred authentication (first playback after browsing) + if (streamInfo.NeedsAuthentication) { _logger.LogInformation( - "Livestream detected for item {ItemId} (URN: {Urn}) - fetching fresh stream URL from API", + "First playback for item {ItemId} - authenticating stream on-demand", + itemId); + + var authenticatedUrl = AuthenticateOnDemand(itemId, streamInfo); + if (authenticatedUrl != null) + { + return authenticatedUrl; + } + + _logger.LogWarning("Failed to authenticate stream on-demand for item {ItemId}", itemId); + return null; + } + + // For livestreams, use smart caching to avoid hammering the API + // Only fetch fresh if token is expiring soon or hasn't been fetched recently + if (streamInfo.IsLiveStream && !string.IsNullOrEmpty(streamInfo.Urn)) + { + var now = DateTime.UtcNow; + var tokenTimeLeft = streamInfo.TokenExpiresAt.HasValue + ? (streamInfo.TokenExpiresAt.Value - now).TotalSeconds + : 30; // Assume 30s if no expiry + + var timeSinceLastFetch = streamInfo.LastLivestreamFetchAt.HasValue + ? (now - streamInfo.LastLivestreamFetchAt.Value).TotalSeconds + : double.MaxValue; + + // Use cached URL if: token has >10s left AND we fetched within last 15 seconds + if (tokenTimeLeft > 10 && timeSinceLastFetch < 15) + { + _logger.LogDebug( + "Livestream {ItemId}: Using cached URL (token expires in {TokenTimeLeft:F0}s, last fetch {TimeSinceFetch:F0}s ago)", + itemId, + tokenTimeLeft, + timeSinceLastFetch); + return streamInfo.AuthenticatedUrl; + } + + _logger.LogInformation( + "Livestream {ItemId}: Fetching fresh URL (token expires in {TokenTimeLeft:F0}s, last fetch {TimeSinceFetch:F0}s ago)", itemId, - streamInfo.Urn); + tokenTimeLeft, + timeSinceLastFetch); var freshUrl = FetchFreshStreamUrl(itemId, streamInfo); if (freshUrl != null) @@ -339,6 +466,7 @@ public class StreamProxyService : IStreamProxyService, IDisposable streamInfo.AuthenticatedUrl = authenticatedUrl; streamInfo.UnauthenticatedUrl = StripAuthenticationFromUrl(authenticatedUrl); streamInfo.TokenExpiresAt = newTokenExpiry; + streamInfo.LastLivestreamFetchAt = DateTime.UtcNow; _logger.LogInformation( "Fetched fresh livestream URL for item {ItemId} (URN: {Urn}, new expiry: {Expiry})", @@ -397,6 +525,54 @@ public class StreamProxyService : IStreamProxyService, IDisposable } } + /// + /// Authenticates a stream on-demand (first playback after browsing). + /// + private string? AuthenticateOnDemand(string itemId, StreamInfo streamInfo) + { + if (string.IsNullOrEmpty(streamInfo.UnauthenticatedUrl)) + { + _logger.LogWarning("Cannot authenticate on-demand for {ItemId} - no unauthenticated URL stored", itemId); + return null; + } + + try + { + // Authenticate the stream URL + var authenticatedUrl = _streamResolver.GetAuthenticatedStreamUrlAsync( + streamInfo.UnauthenticatedUrl, + CancellationToken.None).GetAwaiter().GetResult(); + + if (string.IsNullOrEmpty(authenticatedUrl)) + { + return null; + } + + // Update the stream info - no longer needs authentication + var tokenExpiry = ExtractTokenExpiry(authenticatedUrl); + streamInfo.AuthenticatedUrl = authenticatedUrl; + streamInfo.TokenExpiresAt = tokenExpiry; + streamInfo.NeedsAuthentication = false; + + if (streamInfo.IsLiveStream) + { + streamInfo.LastLivestreamFetchAt = DateTime.UtcNow; + } + + _logger.LogInformation( + "Authenticated stream on-demand for item {ItemId} (expires at {ExpiresAt} UTC)", + itemId, + tokenExpiry); + + return authenticatedUrl; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error authenticating stream on-demand for item {ItemId}", itemId); + return null; + } + } + /// /// Strips authentication parameters from a URL to get the base unauthenticated URL. /// @@ -446,6 +622,41 @@ public class StreamProxyService : IStreamProxyService, IDisposable return null; } + /// + /// Finds the freshest (most recently registered) stream that needs authentication or has a valid token. + /// + /// The freshest stream entry, or null if none found. + private KeyValuePair? FindFreshestStream() + { + var now = DateTime.UtcNow; + + // Find streams that either need authentication (fresh deferred registration) + // or have tokens that aren't expired yet + var candidates = _streamMappings.Where(kvp => + { + if (kvp.Value.NeedsAuthentication) + { + return true; // Fresh deferred registration + } + + if (!kvp.Value.TokenExpiresAt.HasValue) + { + return true; // No expiry + } + + // Token not expired yet + return now < kvp.Value.TokenExpiresAt.Value; + }).ToList(); + + if (candidates.Count == 0) + { + return null; + } + + // Prefer the most recently registered stream + return candidates.OrderByDescending(kvp => kvp.Value.RegisteredAt).First(); + } + /// /// Fetches and rewrites an HLS manifest to use proxy URLs. /// @@ -466,13 +677,15 @@ public class StreamProxyService : IStreamProxyService, IDisposable try { - _logger.LogDebug("Fetching manifest from: {Url}", authenticatedUrl); + _logger.LogInformation("Fetching manifest from: {Url}", authenticatedUrl); var manifestContent = await _httpClient.GetStringAsync(authenticatedUrl, cancellationToken).ConfigureAwait(false); + _logger.LogInformation("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.LogDebug("Successfully rewrote manifest for item {ItemId}", itemId); + _logger.LogInformation("Rewritten manifest for item {ItemId} ({Length} bytes):\n{Content}", itemId, rewrittenContent.Length, rewrittenContent); return rewrittenContent; } catch (Exception ex) @@ -512,7 +725,12 @@ public class StreamProxyService : IStreamProxyService, IDisposable // Build full segment URL var segmentUrl = $"{baseUrl}/{segmentPath}{queryParams}"; - _logger.LogDebug("Fetching segment: {SegmentUrl}", segmentUrl); + _logger.LogInformation( + "Fetching segment - BaseUri: {BaseUri}, BaseUrl: {BaseUrl}, SegmentPath: {SegmentPath}, FullUrl: {FullUrl}", + authenticatedUrl, + baseUrl, + segmentPath, + segmentUrl); var segmentData = await _httpClient.GetByteArrayAsync(segmentUrl, cancellationToken).ConfigureAwait(false); _logger.LogDebug("Successfully fetched segment {SegmentPath} ({Size} bytes)", segmentPath, segmentData.Length); @@ -547,24 +765,51 @@ public class StreamProxyService : IStreamProxyService, IDisposable _logger.LogDebug("Extracted query parameters from proxy URL: {QueryParams}", queryParams); } - // 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 => + // Helper function to rewrite a URL to proxy + string RewriteUrl(string url) { - 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)) + // Try to parse as absolute URL + if (Uri.TryCreate(url, UriKind.Absolute, out var absoluteUri)) { - // Rewrite absolute URLs to proxy - var relativePath = url.Replace(baseUrl + "/", string.Empty, StringComparison.Ordinal); - return $"\n{proxyBaseUrl}/{relativePath}{queryParams}"; + // Check if it's from the same CDN host + if (!absoluteUri.Host.Equals(baseUri.Host, StringComparison.OrdinalIgnoreCase)) + { + // External URL (e.g., subtitles from different domain) - leave as-is + _logger.LogDebug("Leaving external URL unchanged: {Url}", url); + return url; + } + + // Same host - extract just the filename (last path segment) + var segments = absoluteUri.AbsolutePath.Split('/'); + var filename = segments[^1]; + return $"{proxyBaseUrl}/{filename}{queryParams}"; } - // Relative URL - rewrite to proxy - return $"\n{proxyBaseUrl}/{url}{queryParams}"; + // Relative URL - extract just the path without query params + var path = url; + var queryIndex = path.IndexOf('?', StringComparison.Ordinal); + if (queryIndex >= 0) + { + path = path[..queryIndex]; + } + + return $"{proxyBaseUrl}/{path}{queryParams}"; + } + + // Pattern 1: Standalone URL lines (non-# lines ending with media extensions) + var pattern1 = @"(?:^|\n)([^#\n][^\n]*\.(?:m3u8|ts|mp4|m4s|aac)[^\n]*)"; + var rewritten = Regex.Replace(manifestContent, pattern1, match => + { + var url = match.Groups[1].Value.Trim(); + return $"\n{RewriteUrl(url)}"; + }); + + // Pattern 2: URI="..." attributes in HLS tags (e.g., #EXT-X-MEDIA, #EXT-X-I-FRAME-STREAM-INF) + var pattern2 = @"URI=""([^""]+)"""; + rewritten = Regex.Replace(rewritten, pattern2, match => + { + var url = match.Groups[1].Value; + return $"URI=\"{RewriteUrl(url)}\""; }); return rewritten; @@ -697,5 +942,17 @@ public class StreamProxyService : IStreamProxyService, IDisposable /// Livestreams always fetch fresh URLs from the API to avoid stale CDN paths. /// public bool IsLiveStream { get; set; } + + /// + /// Gets or sets when this livestream URL was last fetched from the API. + /// Used to prevent rapid-fire API calls from clients like Android TV. + /// + public DateTime? LastLivestreamFetchAt { get; set; } + + /// + /// Gets or sets a value indicating whether this stream needs authentication on first access. + /// True when registered via RegisterStreamDeferred (authentication deferred until playback). + /// + public bool NeedsAuthentication { get; set; } } } diff --git a/Jellyfin.Plugin.SRFPlay/Services/StreamUrlResolver.cs b/Jellyfin.Plugin.SRFPlay/Services/StreamUrlResolver.cs index bbdc4f9..dc75844 100644 --- a/Jellyfin.Plugin.SRFPlay/Services/StreamUrlResolver.cs +++ b/Jellyfin.Plugin.SRFPlay/Services/StreamUrlResolver.cs @@ -6,6 +6,7 @@ using System.Threading; using System.Threading.Tasks; using Jellyfin.Plugin.SRFPlay.Api.Models; using Jellyfin.Plugin.SRFPlay.Configuration; +using Jellyfin.Plugin.SRFPlay.Constants; using Jellyfin.Plugin.SRFPlay.Services.Interfaces; using Microsoft.Extensions.Logging; @@ -242,7 +243,7 @@ public class StreamUrlResolver : IStreamUrlResolver, IDisposable // Build ACL path: /{segment1}/{segment2}/* var aclPath = $"/{pathSegments[0]}/{pathSegments[1]}/*"; - var tokenUrl = $"https://tp.srgssr.ch/akahd/token?acl={Uri.EscapeDataString(aclPath)}"; + var tokenUrl = $"{ApiEndpoints.AkamaiTokenEndpoint}?acl={Uri.EscapeDataString(aclPath)}"; _logger.LogDebug("Fetching auth token from: {TokenUrl}", tokenUrl);