Duncan Tourolle 7c76402a4a
Some checks failed
🏗️ Build Plugin / build (push) Failing after 9s
🧪 Test Plugin / test (push) Successful in 1m28s
🚀 Release Plugin / build-and-release (push) Failing after 5s
Clean up dead code, consolidate duplication, fix redundancies
Remove 9 dead methods, 6 unused constants, and redundant
ReaderWriterLockSlim from MetadataCache. Consolidate repeated
patterns into HasChapters, IsPlayable, and ToLowerString helpers.
Extract shared API methods in SRFApiClient. Move variant manifest
rewriting from controller to StreamProxyService. Make Auto quality
distinct from HD. Update README architecture section.
2026-02-28 11:34:45 +01:00

307 lines
12 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.IsPlayable)
.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 => hlsResources.FirstOrDefault(),
_ => hlsResources.FirstOrDefault()
};
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)
{
_logger.LogWarning(
"Chapter {ChapterId}: ResourceList is null or empty. ResourceList count: {Count}, ResourceList type: {Type}",
chapter?.Id ?? "null",
chapter?.ResourceList?.Count ?? -1,
chapter?.ResourceList?.GetType().Name ?? "null");
return false;
}
_logger.LogInformation(
"Chapter {ChapterId}: Found {ResourceCount} resources",
chapter.Id,
chapter.ResourceList.Count);
foreach (var resource in chapter.ResourceList)
{
var urlPreview = resource.Url == null ? "null" : resource.Url.AsSpan(0, Math.Min(60, resource.Url.Length)).ToString() + "...";
_logger.LogDebug(
"Resource - URL: {Url}, Quality: {Quality}, DrmList: {DrmList}, DrmList type: {DrmListType}",
urlPreview,
resource.Quality,
resource.DrmList?.ToString() ?? "null",
resource.DrmList?.GetType().Name ?? "null");
}
var hasPlayable = chapter.ResourceList.Any(r => r.IsPlayable);
_logger.LogInformation("Chapter {ChapterId}: Has playable content: {HasPlayable}", chapter.Id, hasPlayable);
return hasPlayable;
}
/// <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
}
}
}