Added sports livestreams

This commit is contained in:
2025-11-14 21:20:41 +01:00
parent d830f8ae5f
commit ce6c435a92
8 changed files with 476 additions and 9 deletions
@@ -1,5 +1,9 @@
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 Microsoft.Extensions.Logging;
@@ -9,9 +13,11 @@ namespace Jellyfin.Plugin.SRFPlay.Services;
/// <summary>
/// Service for resolving stream URLs from media composition resources.
/// </summary>
public class StreamUrlResolver
public class StreamUrlResolver : IDisposable
{
private readonly ILogger<StreamUrlResolver> _logger;
private readonly HttpClient _httpClient;
private bool _disposed;
/// <summary>
/// Initializes a new instance of the <see cref="StreamUrlResolver"/> class.
@@ -20,6 +26,7 @@ public class StreamUrlResolver
public StreamUrlResolver(ILogger<StreamUrlResolver> logger)
{
_logger = logger;
_httpClient = new HttpClient();
}
/// <summary>
@@ -137,6 +144,23 @@ public class StreamUrlResolver
/// <returns>True if playable content is available.</returns>
public bool HasPlayableContent(Chapter chapter)
{
// For scheduled livestreams that haven't started yet, resources won't exist
// but we still want to show them. The stream will become available when the event starts.
if (chapter?.Type == "SCHEDULED_LIVESTREAM")
{
var now = DateTime.UtcNow;
var hasStarted = chapter.ValidFrom == null || chapter.ValidFrom.Value.ToUniversalTime() <= now;
if (!hasStarted)
{
_logger.LogInformation(
"Scheduled livestream '{Title}' hasn't started yet (starts at {ValidFrom}), will be playable when live",
chapter.Title,
chapter.ValidFrom);
return true; // Show it, stream will be available when event starts
}
}
if (chapter?.ResourceList == null || chapter.ResourceList.Count == 0)
{
return false;
@@ -195,4 +219,87 @@ public class StreamUrlResolver
// 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 = $"https://tp.srgssr.ch/akahd/token?acl={Uri.EscapeDataString(aclPath)}";
_logger.LogDebug("Fetching auth token from: {TokenUrl}", tokenUrl);
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
}
}
/// <inheritdoc/>
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}
/// <summary>
/// Releases the unmanaged resources and optionally releases the managed resources.
/// </summary>
/// <param name="disposing">True to release both managed and unmanaged resources; false to release only unmanaged resources.</param>
protected virtual void Dispose(bool disposing)
{
if (!_disposed)
{
if (disposing)
{
_httpClient?.Dispose();
}
_disposed = true;
}
}
}