using System; using System.Linq; using System.Net.Http; using System.Text.Json; 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.Common.Net; using Microsoft.Extensions.Logging; namespace Jellyfin.Plugin.SRFPlay.Services; /// /// Service for resolving stream URLs from media composition resources. /// public class StreamUrlResolver : IStreamUrlResolver { private readonly ILogger _logger; private readonly IHttpClientFactory _httpClientFactory; /// /// Initializes a new instance of the class. /// /// The logger instance. /// The HTTP client factory. public StreamUrlResolver(ILogger logger, IHttpClientFactory httpClientFactory) { _logger = logger; _httpClientFactory = httpClientFactory; } /// /// Gets the best stream URL from a chapter based on quality preference. /// /// The chapter containing resources. /// The quality preference. /// The stream URL, or null if no suitable stream found. public string? GetStreamUrl(Chapter chapter, QualityPreference qualityPreference) { if (chapter?.ResourceList == null || chapter.ResourceList.Count == 0) { _logger.LogWarning("No resources found for chapter: {ChapterId}", chapter?.Id); return null; } _logger.LogInformation( "Processing chapter {ChapterId} with {ResourceCount} resources", chapter.Id, chapter.ResourceList.Count); // Filter out DRM-protected content var nonDrmResources = chapter.ResourceList .Where(r => r.DrmList == null || r.DrmList.ToString() == "[]") .ToList(); _logger.LogInformation( "Chapter {ChapterId}: Total resources={Total}, Non-DRM resources={NonDrm}", chapter.Id, chapter.ResourceList.Count, nonDrmResources.Count); if (nonDrmResources.Count == 0) { _logger.LogWarning("All resources for chapter {ChapterId} require DRM", chapter.Id); // Log what DRM types are present foreach (var resource in chapter.ResourceList) { _logger.LogDebug( "DRM resource: Protocol={Protocol}, Streaming={Streaming}, DRM={Drm}", resource.Protocol, resource.Streaming, resource.DrmList); } return null; } // Prefer HLS protocol var hlsResources = nonDrmResources .Where(r => string.Equals(r.Protocol, "HLS", StringComparison.OrdinalIgnoreCase) || string.Equals(r.Streaming, "HLS", StringComparison.OrdinalIgnoreCase) || r.Url.Contains(".m3u8", StringComparison.OrdinalIgnoreCase)) .ToList(); _logger.LogInformation( "Chapter {ChapterId}: HLS resources found={HlsCount}", chapter.Id, hlsResources.Count); // Log all HLS resources with their quality info to help debug quality selection foreach (var resource in hlsResources) { _logger.LogInformation( "Available HLS resource - Quality={Quality}, Protocol={Protocol}, Streaming={Streaming}, URL={Url}", resource.Quality ?? "NULL", resource.Protocol ?? "NULL", resource.Streaming ?? "NULL", resource.Url); } if (hlsResources.Count == 0) { _logger.LogWarning("No HLS resources found for chapter: {ChapterId}", chapter.Id); // Log available protocols foreach (var resource in nonDrmResources) { _logger.LogDebug( "Non-HLS resource: Protocol={Protocol}, Streaming={Streaming}, URL={Url}", resource.Protocol, resource.Streaming, resource.Url); } // Fallback to any available non-DRM resource var fallbackResource = nonDrmResources.FirstOrDefault(); if (fallbackResource != null) { _logger.LogInformation( "Using fallback resource for chapter {ChapterId}: {Url}", chapter.Id, fallbackResource.Url); } return fallbackResource?.Url; } // Select based on quality preference _logger.LogInformation( "Selecting stream with quality preference: {QualityPreference}", qualityPreference); Resource? selectedResource = qualityPreference switch { QualityPreference.HD => SelectHDResource(hlsResources) ?? SelectBestAvailableResource(hlsResources), QualityPreference.SD => SelectSDResource(hlsResources) ?? SelectBestAvailableResource(hlsResources), QualityPreference.Auto => SelectBestAvailableResource(hlsResources), _ => SelectBestAvailableResource(hlsResources) }; if (selectedResource != null) { _logger.LogDebug( "Selected stream for chapter {ChapterId}: Quality={Quality}, Protocol={Protocol}, URL={Url}", chapter.Id, selectedResource.Quality ?? "NULL", selectedResource.Protocol, selectedResource.Url); return selectedResource.Url; } _logger.LogWarning("Could not select appropriate stream for chapter: {ChapterId}", chapter.Id); return null; } /// /// Checks if a chapter has non-DRM playable content. /// /// The chapter to check. /// True if playable content is available. public bool HasPlayableContent(Chapter chapter) { if (chapter?.ResourceList == null || chapter.ResourceList.Count == 0) { return false; } return chapter.ResourceList.Any(r => r.DrmList == null || r.DrmList.ToString() == "[]"); } /// /// Checks if content is expired based on ValidTo date. /// /// The chapter to check. /// True if the content is expired. public bool IsContentExpired(Chapter chapter) { if (chapter?.ValidTo == null) { return false; } return DateTime.UtcNow > chapter.ValidTo.Value.ToUniversalTime(); } private Resource? SelectHDResource(System.Collections.Generic.List resources) { return resources.FirstOrDefault(r => string.Equals(r.Quality, "HD", StringComparison.OrdinalIgnoreCase) || string.Equals(r.Quality, "1080", StringComparison.OrdinalIgnoreCase) || string.Equals(r.Quality, "720", StringComparison.OrdinalIgnoreCase)); } private Resource? SelectSDResource(System.Collections.Generic.List resources) { return resources.FirstOrDefault(r => string.Equals(r.Quality, "SD", StringComparison.OrdinalIgnoreCase) || string.Equals(r.Quality, "480", StringComparison.OrdinalIgnoreCase) || string.Equals(r.Quality, "360", StringComparison.OrdinalIgnoreCase)); } private Resource? SelectBestAvailableResource(System.Collections.Generic.List resources) { // Try HD first var hdResource = SelectHDResource(resources); if (hdResource != null) { return hdResource; } // Fall back to SD var sdResource = SelectSDResource(resources); if (sdResource != null) { return sdResource; } // Return first available return resources.FirstOrDefault(); } /// /// Authenticates a stream URL by fetching an Akamai token. /// Based on the Kodi addon implementation. /// /// The unauthenticated stream URL. /// Cancellation token. /// The authenticated stream URL with token. public async Task GetAuthenticatedStreamUrlAsync(string streamUrl, CancellationToken cancellationToken = default) { try { // Parse the stream URL to extract path components var uri = new Uri(streamUrl); var pathSegments = uri.AbsolutePath.Split('/', StringSplitOptions.RemoveEmptyEntries); if (pathSegments.Length < 2) { _logger.LogWarning("Stream URL has insufficient path segments for authentication: {Url}", streamUrl); return streamUrl; } // Build ACL path: /{segment1}/{segment2}/* var aclPath = $"/{pathSegments[0]}/{pathSegments[1]}/*"; var tokenUrl = $"{ApiEndpoints.AkamaiTokenEndpoint}?acl={Uri.EscapeDataString(aclPath)}"; _logger.LogDebug("Fetching auth token from: {TokenUrl}", tokenUrl); using var httpClient = _httpClientFactory.CreateClient(NamedClient.Default); var response = await httpClient.GetAsync(tokenUrl, cancellationToken).ConfigureAwait(false); response.EnsureSuccessStatusCode(); var jsonContent = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); var tokenResponse = JsonSerializer.Deserialize(jsonContent); // Extract authparams from response if (tokenResponse.TryGetProperty("token", out var token) && token.TryGetProperty("authparams", out var authParams)) { var authParamsValue = authParams.GetString(); if (!string.IsNullOrEmpty(authParamsValue)) { // Append auth params to original URL var separator = streamUrl.Contains('?', StringComparison.Ordinal) ? "&" : "?"; var authenticatedUrl = $"{streamUrl}{separator}{authParamsValue}"; _logger.LogInformation("Successfully authenticated stream URL"); return authenticatedUrl; } } _logger.LogWarning("No auth params found in token response, returning original URL"); return streamUrl; } catch (Exception ex) { _logger.LogError(ex, "Failed to authenticate stream URL: {Url}", streamUrl); return streamUrl; // Return original URL as fallback } } }