using System; using System.Collections.Generic; 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; /// /// Provides media sources (playback URLs) for SRF Play content. /// public class SRFMediaProvider : IMediaSourceProvider { private readonly ILogger _logger; private readonly IMediaCompositionFetcher _compositionFetcher; private readonly IStreamUrlResolver _streamResolver; private readonly IStreamProxyService _proxyService; private readonly IServerApplicationHost _appHost; /// /// Initializes a new instance of the class. /// /// The logger factory. /// The media composition fetcher. /// The stream URL resolver. /// The stream proxy service. /// The server application host. public SRFMediaProvider( ILoggerFactory loggerFactory, IMediaCompositionFetcher compositionFetcher, IStreamUrlResolver streamResolver, IStreamProxyService proxyService, IServerApplicationHost appHost) { _logger = loggerFactory.CreateLogger(); _compositionFetcher = compositionFetcher; _streamResolver = streamResolver; _proxyService = proxyService; _appHost = appHost; } /// /// Gets the provider name. /// public string Name => "SRF Play"; /// /// Gets media sources for the specified item. /// /// The item. /// The cancellation token. /// List of media sources. 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)) { _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); // 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; var mediaComposition = await _compositionFetcher.GetMediaCompositionAsync(urn, cancellationToken, cacheDuration).ConfigureAwait(false); if (mediaComposition?.ChapterList == null || mediaComposition.ChapterList.Count == 0) { _logger.LogWarning("No chapters found for URN: {Urn}", urn); return sources; } // Get the first chapter (main video) var chapter = mediaComposition.ChapterList[0]; // Check if content is expired if (_streamResolver.IsContentExpired(chapter)) { _logger.LogWarning("Content expired for URN: {Urn}, ValidTo: {ValidTo}", urn, chapter.ValidTo); return sources; } // Check if content has playable streams if (!_streamResolver.HasPlayableContent(chapter)) { _logger.LogWarning("No playable content found for URN: {Urn}", urn); return sources; } // Get stream URL based on quality preference 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)) { _logger.LogDebug("URN {Urn}: Scheduled livestream has no stream URL, fetching fresh data", urn); // Force fresh fetch with short cache duration var freshMediaComposition = await _compositionFetcher.GetMediaCompositionAsync(urn, cancellationToken, 1).ConfigureAwait(false); if (freshMediaComposition?.ChapterList != null && freshMediaComposition.ChapterList.Count > 0) { var freshChapter = freshMediaComposition.ChapterList[0]; streamUrl = _streamResolver.GetStreamUrl(freshChapter, qualityPref); if (!string.IsNullOrEmpty(streamUrl)) { chapter = freshChapter; _logger.LogInformation("URN {Urn}: Got fresh stream URL for scheduled livestream", urn); } } } if (string.IsNullOrEmpty(streamUrl)) { _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}", 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); } catch (Exception ex) { _logger.LogError(ex, "Error getting media sources for item: {Name}", item.Name); } return sources; } /// /// Gets direct stream provider by unique ID. /// /// The unique ID. /// The cancellation token. /// 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); } /// public Task OpenMediaSource(string openToken, List currentLiveStreams, CancellationToken cancellationToken) { // Not used - RequiresOpening is false, proxy handles authentication directly _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 } }; } }