using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Threading; using System.Threading.Tasks; using Jellyfin.Plugin.SRFPlay.Api; using Jellyfin.Plugin.SRFPlay.Services; 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 ILoggerFactory _loggerFactory; private readonly MetadataCache _metadataCache; private readonly StreamUrlResolver _streamResolver; private readonly StreamProxyService _proxyService; private readonly IServerApplicationHost _appHost; private readonly Dictionary _openTokenToItemId = new(); /// /// Initializes a new instance of the class. /// /// The logger factory. /// The metadata cache. /// The stream URL resolver. /// The stream proxy service. /// The server application host. public SRFMediaProvider( ILoggerFactory loggerFactory, MetadataCache metadataCache, StreamUrlResolver streamResolver, StreamProxyService proxyService, IServerApplicationHost appHost) { _loggerFactory = loggerFactory; _logger = loggerFactory.CreateLogger(); _metadataCache = metadataCache; _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); var config = Plugin.Instance?.Configuration; if (config == null) { return sources; } // For scheduled livestreams, use shorter cache TTL (5 minutes) so they refresh when they go live // For regular content, use configured cache duration var cacheDuration = urn.Contains("scheduled_livestream", StringComparison.OrdinalIgnoreCase) ? 5 : config.CacheDurationMinutes; // Try cache first var mediaComposition = _metadataCache.GetMediaComposition(urn, cacheDuration); // If not in cache, fetch from API if (mediaComposition == null) { using var apiClient = new SRFApiClient(_loggerFactory); mediaComposition = await apiClient.GetMediaCompositionByUrnAsync(urn, cancellationToken).ConfigureAwait(false); if (mediaComposition != null) { _metadataCache.SetMediaComposition(urn, mediaComposition); } } 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 streamUrl = _streamResolver.GetStreamUrl(chapter, config.QualityPreference); // 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); using var freshApiClient = new SRFApiClient(_loggerFactory); var freshMediaComposition = await freshApiClient.GetMediaCompositionByUrnAsync(urn, cancellationToken).ConfigureAwait(false); if (freshMediaComposition?.ChapterList != null && freshMediaComposition.ChapterList.Count > 0) { var freshChapter = freshMediaComposition.ChapterList[0]; streamUrl = _streamResolver.GetStreamUrl(freshChapter, config.QualityPreference); if (!string.IsNullOrEmpty(streamUrl)) { // Update cache with fresh data _metadataCache.SetMediaComposition(urn, freshMediaComposition); 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); } // Register stream with proxy service var itemIdStr = item.Id.ToString("N"); // Use hex format without dashes _proxyService.RegisterStream(itemIdStr, 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 // Detect if this is a live stream var isLiveStream = chapter.Type == "SCHEDULED_LIVESTREAM" || urn.Contains("livestream", StringComparison.OrdinalIgnoreCase); // Generate an open token for this media source (used to track transcoding sessions) var openToken = Guid.NewGuid().ToString("N"); _openTokenToItemId[openToken] = itemIdStr; _logger.LogDebug("Created open token {OpenToken} for item {ItemId}", openToken, itemIdStr); // Create proxy URL using token instead of item ID in path // This prevents Jellyfin from rewriting the URL during transcoding var proxyUrl = $"{serverUrl}/Plugins/SRFPlay/Proxy/{itemIdStr}/master.m3u8?token={openToken}"; _logger.LogInformation( "Using proxy URL for item {ItemId}: {ProxyUrl} (PublicServerUrl configured: {IsPublicConfigured})", itemIdStr, proxyUrl, !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 = 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 = isLiveStream, // True for live streams! RequiresOpening = true, // Enable to handle transcoding sessions RequiresClosing = false, SupportsProbing = false, // Disable probing for proxy URLs ReadAtNativeFramerate = isLiveStream, // Read at native framerate for live streams OpenToken = openToken, // Token to identify this media source MediaStreams = new List { 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}, 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 async Task OpenMediaSource(string openToken, List currentLiveStreams, CancellationToken cancellationToken) { _logger.LogInformation("OpenMediaSource called with openToken: {OpenToken}", openToken); // Look up the original item ID from the open token if (!_openTokenToItemId.TryGetValue(openToken, out var originalItemId)) { _logger.LogError("Open token {OpenToken} not found in registry", openToken); throw new InvalidOperationException($"Open token {openToken} not found"); } _logger.LogInformation("Open token {OpenToken} maps to original item ID: {ItemId}", openToken, originalItemId); // Create a live stream wrapper var liveStream = new SRFLiveStream( _logger, _proxyService, originalItemId, openToken, _loggerFactory); return await Task.FromResult(liveStream).ConfigureAwait(false); } }