using System; using System.Collections.Generic; using System.Globalization; using System.Linq; using System.Security.Cryptography; using System.Text; using System.Threading; using System.Threading.Tasks; using Jellyfin.Plugin.SRFPlay.Services; using MediaBrowser.Controller; using MediaBrowser.Controller.Channels; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Channels; using MediaBrowser.Model.Dto; using MediaBrowser.Model.Entities; using Microsoft.Extensions.Logging; namespace Jellyfin.Plugin.SRFPlay.Channels; /// /// SRF Play channel for browsing and playing content. /// public class SRFPlayChannel : IChannel, IHasCacheKey { private readonly ILogger _logger; private readonly ILoggerFactory _loggerFactory; private readonly ContentRefreshService _contentRefreshService; private readonly StreamUrlResolver _streamResolver; private readonly StreamProxyService _proxyService; private readonly CategoryService? _categoryService; private readonly IServerApplicationHost _appHost; /// /// Initializes a new instance of the class. /// /// The logger factory. /// The content refresh service. /// The stream resolver. /// The stream proxy service. /// The server application host. /// The category service (optional). public SRFPlayChannel( ILoggerFactory loggerFactory, ContentRefreshService contentRefreshService, StreamUrlResolver streamResolver, StreamProxyService proxyService, IServerApplicationHost appHost, CategoryService? categoryService = null) { _loggerFactory = loggerFactory; _logger = loggerFactory.CreateLogger(); _contentRefreshService = contentRefreshService; _streamResolver = streamResolver; _proxyService = proxyService; _appHost = appHost; _categoryService = categoryService; if (_categoryService == null) { _logger.LogWarning("CategoryService not available - category folders will be disabled"); } _logger.LogInformation("=== SRFPlayChannel constructor called! Channel is being instantiated ==="); } /// public string Name => "SRF Play"; /// public string Description => "Swiss Radio and Television video-on-demand content"; /// 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 ChannelParentalRating ParentalRating => ChannelParentalRating.GeneralAudience; /// public InternalChannelFeatures GetChannelFeatures() { _logger.LogInformation("=== GetChannelFeatures called for SRF Play channel ==="); return new InternalChannelFeatures { ContentTypes = new List { ChannelMediaContentType.Episode, ChannelMediaContentType.Movie }, MediaTypes = new List { ChannelMediaType.Video }, SupportsSortOrderToggle = false, DefaultSortFields = new List { ChannelItemSortField.DateCreated, ChannelItemSortField.Name }, MaxPageSize = 50 }; } /// public Task GetChannelImage(ImageType type, CancellationToken cancellationToken) { // Could provide a channel logo here return Task.FromResult(new DynamicImageResponse { HasImage = false }); } /// public IEnumerable GetSupportedChannelImages() { return new List { ImageType.Primary, ImageType.Thumb }; } /// public async Task GetChannelItems(InternalChannelItemQuery query, CancellationToken cancellationToken) { _logger.LogInformation("=== GetChannelItems called! FolderId: {FolderId} ===", query.FolderId); var items = new List(); var config = Plugin.Instance?.Configuration; try { // Root level - show categories if (string.IsNullOrEmpty(query.FolderId)) { items.Add(new ChannelItemInfo { Id = "latest", Name = "Latest Videos", Type = ChannelItemType.Folder, FolderType = ChannelFolderType.Container, ImageUrl = null }); items.Add(new ChannelItemInfo { 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"); } } 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) { // 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; } } } } catch (Exception ex) { _logger.LogError(ex, "Failed to load live sports events"); } } // 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); } catch (Exception ex) { _logger.LogError(ex, "Error getting channel items for folder {FolderId}", query.FolderId); } return new ChannelItemResult { Items = items, TotalRecordCount = items.Count }; } /// public string? GetCacheKey(string? userId) { var config = Plugin.Instance?.Configuration; var enabledTopics = config?.EnabledTopics != null && config.EnabledTopics.Count > 0 ? string.Join(",", config.EnabledTopics) : "all"; var date = DateTime.Now.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture); return $"{config?.BusinessUnit}_{config?.EnableLatestContent}_{config?.EnableTrendingContent}_{config?.EnableCategoryFolders}_{enabledTopics}_{date}"; } private async Task> ConvertUrnsToChannelItems(List urns, CancellationToken cancellationToken) { var items = new List(); var config = Plugin.Instance?.Configuration; if (config == null) { _logger.LogWarning("Plugin configuration is null"); return items; } _logger.LogInformation("Converting {Count} URNs to channel items", urns.Count); using var apiClient = new Api.SRFApiClient(_loggerFactory); int successCount = 0; int failedCount = 0; int expiredCount = 0; int noStreamCount = 0; foreach (var urn in urns.Take(50)) // Limit to 50 items per request { try { _logger.LogDebug("Processing URN: {Urn}", urn); var mediaComposition = await apiClient.GetMediaCompositionByUrnAsync(urn, cancellationToken).ConfigureAwait(false); if (mediaComposition?.ChapterList == null || mediaComposition.ChapterList.Count == 0) { _logger.LogWarning("URN {Urn}: No media composition or chapters found", urn); failedCount++; continue; } var chapter = mediaComposition.ChapterList[0]; // Check if content is expired if (_streamResolver.IsContentExpired(chapter)) { _logger.LogDebug("URN {Urn}: Content expired (ValidTo: {ValidTo})", urn, chapter.ValidTo); expiredCount++; continue; } // Check if content has playable streams if (!_streamResolver.HasPlayableContent(chapter)) { _logger.LogWarning("URN {Urn}: No playable content (likely DRM protected)", urn); noStreamCount++; continue; } // Generate deterministic GUID from URN var itemId = UrnToGuid(urn); // 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) if (chapter.Type == "SCHEDULED_LIVESTREAM" && string.IsNullOrEmpty(streamUrl)) { _logger.LogDebug( "URN {Urn}: Skipping upcoming livestream '{Title}' - stream not yet available (starts at {ValidFrom})", urn, chapter.Title, chapter.ValidFrom); 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; // Get image URL - prefer chapter image, fall back to show image if available var imageUrl = chapter.ImageUrl; if (string.IsNullOrEmpty(imageUrl) && mediaComposition.Show != null) { imageUrl = mediaComposition.Show.ImageUrl; _logger.LogDebug("URN {Urn}: Using show image as fallback", urn); } if (string.IsNullOrEmpty(imageUrl)) { _logger.LogWarning("URN {Urn}: No image URL available for '{Title}'", urn, chapter.Title); } // 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 authenticated URL - tokens refresh automatically via scheduled channel scans var item = new ChannelItemInfo { Id = itemId, Name = chapter.Title, Overview = overview, ImageUrl = imageUrl, Type = ChannelItemType.Media, ContentType = ChannelMediaContentType.Episode, MediaType = ChannelMediaType.Video, DateCreated = chapter.Date?.ToUniversalTime(), PremiereDate = premiereDate, ProductionYear = chapter.Date?.Year, RunTimeTicks = chapter.Duration > 0 ? TimeSpan.FromMilliseconds(chapter.Duration).Ticks : null, ProviderIds = new Dictionary { { "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 } } } } }; // Add series info if available if (mediaComposition.Show != null) { item.SeriesName = mediaComposition.Show.Title; } 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) { _logger.LogError(ex, "Error converting URN {Urn} to channel item", urn); failedCount++; } } _logger.LogInformation( "Conversion complete: {Success} successful, {Failed} failed, {Expired} expired, {NoStream} no stream", successCount, failedCount, expiredCount, noStreamCount); return items; } /// /// Generates a deterministic GUID from a URN. /// This ensures the same URN always produces the same GUID. /// MD5 is used for non-cryptographic purposes only (generating IDs). /// /// The URN to convert. /// A deterministic GUID. #pragma warning disable CA5351 // MD5 is used for non-cryptographic purposes (ID generation) private static string UrnToGuid(string urn) { // Use MD5 to generate a deterministic hash from the URN var hash = MD5.HashData(Encoding.UTF8.GetBytes(urn)); // Convert the first 16 bytes to a GUID var guid = new Guid(hash); return guid.ToString(); } #pragma warning restore CA5351 /// public bool IsEnabledFor(string userId) { return true; } }