284 lines
11 KiB
C#
284 lines
11 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// Service for resolving stream URLs from media composition resources.
|
|
/// </summary>
|
|
public class StreamUrlResolver : IStreamUrlResolver
|
|
{
|
|
private readonly ILogger<StreamUrlResolver> _logger;
|
|
private readonly IHttpClientFactory _httpClientFactory;
|
|
|
|
/// <summary>
|
|
/// Initializes a new instance of the <see cref="StreamUrlResolver"/> class.
|
|
/// </summary>
|
|
/// <param name="logger">The logger instance.</param>
|
|
/// <param name="httpClientFactory">The HTTP client factory.</param>
|
|
public StreamUrlResolver(ILogger<StreamUrlResolver> logger, IHttpClientFactory httpClientFactory)
|
|
{
|
|
_logger = logger;
|
|
_httpClientFactory = httpClientFactory;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets the best stream URL from a chapter based on quality preference.
|
|
/// </summary>
|
|
/// <param name="chapter">The chapter containing resources.</param>
|
|
/// <param name="qualityPreference">The quality preference.</param>
|
|
/// <returns>The stream URL, or null if no suitable stream found.</returns>
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Checks if a chapter has non-DRM playable content.
|
|
/// </summary>
|
|
/// <param name="chapter">The chapter to check.</param>
|
|
/// <returns>True if playable content is available.</returns>
|
|
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() == "[]");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Checks if content is expired based on ValidTo date.
|
|
/// </summary>
|
|
/// <param name="chapter">The chapter to check.</param>
|
|
/// <returns>True if the content is expired.</returns>
|
|
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<Resource> 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<Resource> 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<Resource> 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();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Authenticates a stream URL by fetching an Akamai token.
|
|
/// Based on the Kodi addon implementation.
|
|
/// </summary>
|
|
/// <param name="streamUrl">The unauthenticated stream URL.</param>
|
|
/// <param name="cancellationToken">Cancellation token.</param>
|
|
/// <returns>The authenticated stream URL with token.</returns>
|
|
public async Task<string> 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<JsonElement>(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
|
|
}
|
|
}
|
|
}
|