Added sports livestreams
This commit is contained in:
parent
d830f8ae5f
commit
ce6c435a92
@ -93,4 +93,10 @@ public class Chapter
|
||||
/// </summary>
|
||||
[JsonPropertyName("mediaType")]
|
||||
public string? MediaType { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the type (e.g., SCHEDULED_LIVESTREAM).
|
||||
/// </summary>
|
||||
[JsonPropertyName("type")]
|
||||
public string? Type { get; set; }
|
||||
}
|
||||
|
||||
107
Jellyfin.Plugin.SRFPlay/Api/Models/PlayV3/PlayV3TvProgram.cs
Normal file
107
Jellyfin.Plugin.SRFPlay/Api/Models/PlayV3/PlayV3TvProgram.cs
Normal file
@ -0,0 +1,107 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Jellyfin.Plugin.SRFPlay.Api.Models.PlayV3;
|
||||
|
||||
/// <summary>
|
||||
/// TV Program entry in the guide.
|
||||
/// </summary>
|
||||
public class PlayV3TvProgram
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the URN identifier.
|
||||
/// </summary>
|
||||
[JsonPropertyName("urn")]
|
||||
public string? Urn { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the title.
|
||||
/// </summary>
|
||||
[JsonPropertyName("title")]
|
||||
public string? Title { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the lead/description.
|
||||
/// </summary>
|
||||
[JsonPropertyName("lead")]
|
||||
public string? Lead { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the description.
|
||||
/// </summary>
|
||||
[JsonPropertyName("description")]
|
||||
public string? Description { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the image URL.
|
||||
/// </summary>
|
||||
[JsonPropertyName("imageUrl")]
|
||||
public string? ImageUrl { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the content type (e.g., SCHEDULED_LIVESTREAM).
|
||||
/// </summary>
|
||||
[JsonPropertyName("type")]
|
||||
public string? Type { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the media type.
|
||||
/// </summary>
|
||||
[JsonPropertyName("mediaType")]
|
||||
public string? MediaType { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the vendor (e.g., SRF, RTS).
|
||||
/// </summary>
|
||||
[JsonPropertyName("vendor")]
|
||||
public string? Vendor { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the start time.
|
||||
/// </summary>
|
||||
[JsonPropertyName("date")]
|
||||
public DateTime? Date { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the valid from time (for scheduled livestreams).
|
||||
/// </summary>
|
||||
[JsonPropertyName("validFrom")]
|
||||
public DateTime? ValidFrom { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the valid to time (for scheduled livestreams).
|
||||
/// </summary>
|
||||
[JsonPropertyName("validTo")]
|
||||
public DateTime? ValidTo { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the duration in milliseconds.
|
||||
/// </summary>
|
||||
[JsonPropertyName("duration")]
|
||||
public long? Duration { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the channel ID.
|
||||
/// </summary>
|
||||
[JsonPropertyName("channelId")]
|
||||
public string? ChannelId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the channel title.
|
||||
/// </summary>
|
||||
[JsonPropertyName("channelTitle")]
|
||||
public string? ChannelTitle { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets whether this is blocked (DRM).
|
||||
/// </summary>
|
||||
[JsonPropertyName("blocked")]
|
||||
public bool? Blocked { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets whether this is geoblocked.
|
||||
/// </summary>
|
||||
[JsonPropertyName("geoblocked")]
|
||||
public bool? Geoblocked { get; set; }
|
||||
}
|
||||
@ -0,0 +1,16 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Jellyfin.Plugin.SRFPlay.Api.Models.PlayV3;
|
||||
|
||||
/// <summary>
|
||||
/// TV Program Guide response from Play v3 API.
|
||||
/// </summary>
|
||||
public class PlayV3TvProgramGuideResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the list of TV program entries.
|
||||
/// </summary>
|
||||
[JsonPropertyName("data")]
|
||||
public IReadOnlyList<PlayV3TvProgram>? Data { get; init; }
|
||||
}
|
||||
@ -6,6 +6,7 @@ using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Plugin.SRFPlay.Api.Models;
|
||||
using Jellyfin.Plugin.SRFPlay.Api.Models.PlayV3;
|
||||
using Jellyfin.Plugin.SRFPlay.Configuration;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
@ -485,6 +486,58 @@ public class SRFApiClient : IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets scheduled livestreams for sports or news events from the Play v3 API.
|
||||
/// </summary>
|
||||
/// <param name="businessUnit">The business unit (e.g., srf, rts).</param>
|
||||
/// <param name="eventType">The event type (SPORT or NEWS).</param>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <returns>List of scheduled livestream entries.</returns>
|
||||
public async Task<System.Collections.Generic.IReadOnlyList<PlayV3TvProgram>?> GetScheduledLivestreamsAsync(
|
||||
string businessUnit,
|
||||
string eventType = "SPORT",
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var baseUrl = string.Format(CultureInfo.InvariantCulture, PlayV3UrlFormat, businessUnit);
|
||||
var url = $"{baseUrl}livestreams?eventType={eventType.ToUpperInvariant()}";
|
||||
_logger.LogInformation("Fetching scheduled livestreams for eventType={EventType} from business unit: {BusinessUnit}", eventType, businessUnit);
|
||||
|
||||
var response = await _playV3HttpClient.GetAsync(url, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var errorContent = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
_logger.LogError("API returned error {StatusCode}: {Error}", response.StatusCode, errorContent);
|
||||
return null;
|
||||
}
|
||||
|
||||
var content = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// The response structure is: { "data": { "scheduledLivestreams": [...] } }
|
||||
var jsonDoc = JsonSerializer.Deserialize<JsonElement>(content, _jsonOptions);
|
||||
if (jsonDoc.TryGetProperty("data", out var dataElement) &&
|
||||
dataElement.TryGetProperty("scheduledLivestreams", out var livestreamsElement))
|
||||
{
|
||||
var livestreams = JsonSerializer.Deserialize<System.Collections.Generic.List<PlayV3TvProgram>>(
|
||||
livestreamsElement.GetRawText(),
|
||||
_jsonOptions);
|
||||
|
||||
_logger.LogInformation("Successfully fetched {Count} scheduled livestreams for eventType={EventType}", livestreams?.Count ?? 0, eventType);
|
||||
return livestreams;
|
||||
}
|
||||
|
||||
_logger.LogWarning("No scheduledLivestreams found in response");
|
||||
return null;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error fetching scheduled livestreams for eventType={EventType} from business unit: {BusinessUnit}", eventType, businessUnit);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void Dispose()
|
||||
{
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
@ -145,6 +146,15 @@ public class SRFPlayChannel : IChannel, IHasCacheKey
|
||||
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)
|
||||
{
|
||||
@ -201,6 +211,54 @@ public class SRFPlayChannel : IChannel, IHasCacheKey
|
||||
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)
|
||||
.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)
|
||||
{
|
||||
@ -316,7 +374,8 @@ public class SRFPlayChannel : IChannel, IHasCacheKey
|
||||
var enabledTopics = config?.EnabledTopics != null && config.EnabledTopics.Count > 0
|
||||
? string.Join(",", config.EnabledTopics)
|
||||
: "all";
|
||||
return $"{config?.BusinessUnit}_{config?.EnableLatestContent}_{config?.EnableTrendingContent}_{config?.EnableCategoryFolders}_{enabledTopics}";
|
||||
var date = DateTime.Now.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture);
|
||||
return $"{config?.BusinessUnit}_{config?.EnableLatestContent}_{config?.EnableTrendingContent}_{config?.EnableCategoryFolders}_{enabledTopics}_{date}";
|
||||
}
|
||||
|
||||
private async Task<List<ChannelItemInfo>> ConvertUrnsToChannelItems(List<string> urns, CancellationToken cancellationToken)
|
||||
@ -373,17 +432,63 @@ public class SRFPlayChannel : IChannel, IHasCacheKey
|
||||
// Generate deterministic GUID from URN
|
||||
var itemId = UrnToGuid(urn);
|
||||
|
||||
// Get stream URL and authenticate it
|
||||
var streamUrl = _streamResolver.GetStreamUrl(chapter, config.QualityPreference);
|
||||
|
||||
// For scheduled livestreams that haven't started, streamUrl might be null
|
||||
var isUpcomingLivestream = chapter.Type == "SCHEDULED_LIVESTREAM" && string.IsNullOrEmpty(streamUrl);
|
||||
|
||||
if (!string.IsNullOrEmpty(streamUrl))
|
||||
{
|
||||
// Authenticate the stream URL (required for all SRF streams, especially livestreams)
|
||||
streamUrl = await _streamResolver.GetAuthenticatedStreamUrlAsync(streamUrl, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
else if (isUpcomingLivestream)
|
||||
{
|
||||
// Use a placeholder for upcoming events
|
||||
streamUrl = "http://placeholder.local/upcoming.m3u8";
|
||||
_logger.LogInformation(
|
||||
"URN {Urn}: Upcoming livestream '{Title}' - stream will be available at {ValidFrom}",
|
||||
urn,
|
||||
chapter.Title,
|
||||
chapter.ValidFrom);
|
||||
}
|
||||
|
||||
// Build overview with event time for upcoming livestreams
|
||||
var overview = chapter.Description ?? chapter.Lead;
|
||||
if (isUpcomingLivestream && chapter.ValidFrom != null)
|
||||
{
|
||||
var eventStart = chapter.ValidFrom.Value.ToString("dd.MM.yyyy HH:mm", CultureInfo.InvariantCulture);
|
||||
overview = $"[Upcoming Event - Starts at {eventStart}]\n\n{overview}";
|
||||
}
|
||||
|
||||
// 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 an upcoming livestream, otherwise use Date
|
||||
var premiereDate = isUpcomingLivestream ? chapter.ValidFrom?.ToUniversalTime() : chapter.Date?.ToUniversalTime();
|
||||
|
||||
var item = new ChannelItemInfo
|
||||
{
|
||||
Id = itemId,
|
||||
Name = chapter.Title,
|
||||
Overview = chapter.Description ?? chapter.Lead,
|
||||
ImageUrl = chapter.ImageUrl,
|
||||
Overview = overview,
|
||||
ImageUrl = imageUrl,
|
||||
Type = ChannelItemType.Media,
|
||||
ContentType = ChannelMediaContentType.Episode,
|
||||
MediaType = ChannelMediaType.Video,
|
||||
DateCreated = chapter.Date?.ToUniversalTime(),
|
||||
PremiereDate = chapter.Date?.ToUniversalTime(),
|
||||
PremiereDate = premiereDate,
|
||||
ProductionYear = chapter.Date?.Year,
|
||||
RunTimeTicks = chapter.Duration > 0 ? TimeSpan.FromMilliseconds(chapter.Duration).Ticks : null,
|
||||
ProviderIds = new Dictionary<string, string>
|
||||
@ -396,7 +501,7 @@ public class SRFPlayChannel : IChannel, IHasCacheKey
|
||||
{
|
||||
Id = itemId,
|
||||
Name = chapter.Title,
|
||||
Path = _streamResolver.GetStreamUrl(chapter, config.QualityPreference),
|
||||
Path = streamUrl,
|
||||
Protocol = MediaBrowser.Model.MediaInfo.MediaProtocol.Http,
|
||||
Container = "m3u8",
|
||||
SupportsDirectStream = true,
|
||||
|
||||
@ -93,6 +93,7 @@ public class SRFImageProvider : IRemoteImageProvider, IHasOrder
|
||||
var chapter = mediaComposition.ChapterList[0];
|
||||
if (!string.IsNullOrEmpty(chapter.ImageUrl))
|
||||
{
|
||||
_logger.LogDebug("URN {Urn}: Adding chapter image: {ImageUrl}", urn, chapter.ImageUrl);
|
||||
list.Add(new RemoteImageInfo
|
||||
{
|
||||
Url = chapter.ImageUrl,
|
||||
@ -107,6 +108,10 @@ public class SRFImageProvider : IRemoteImageProvider, IHasOrder
|
||||
ProviderName = Name
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogDebug("URN {Urn}: No chapter image available", urn);
|
||||
}
|
||||
}
|
||||
|
||||
// Extract images from show
|
||||
@ -114,6 +119,7 @@ public class SRFImageProvider : IRemoteImageProvider, IHasOrder
|
||||
{
|
||||
if (!string.IsNullOrEmpty(mediaComposition.Show.ImageUrl))
|
||||
{
|
||||
_logger.LogDebug("URN {Urn}: Adding show image: {ImageUrl}", urn, mediaComposition.Show.ImageUrl);
|
||||
list.Add(new RemoteImageInfo
|
||||
{
|
||||
Url = mediaComposition.Show.ImageUrl,
|
||||
@ -124,6 +130,7 @@ public class SRFImageProvider : IRemoteImageProvider, IHasOrder
|
||||
|
||||
if (!string.IsNullOrEmpty(mediaComposition.Show.BannerImageUrl))
|
||||
{
|
||||
_logger.LogDebug("URN {Urn}: Adding show banner: {BannerUrl}", urn, mediaComposition.Show.BannerImageUrl);
|
||||
list.Add(new RemoteImageInfo
|
||||
{
|
||||
Url = mediaComposition.Show.BannerImageUrl,
|
||||
@ -133,7 +140,7 @@ public class SRFImageProvider : IRemoteImageProvider, IHasOrder
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogDebug("Found {Count} images for URN: {Urn}", list.Count, urn);
|
||||
_logger.LogInformation("URN {Urn}: Found {Count} images for '{ItemName}'", urn, list.Count, item.Name);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
||||
@ -72,8 +72,14 @@ public class SRFMediaProvider : IMediaSourceProvider
|
||||
return Task.FromResult<IEnumerable<MediaSourceInfo>>(sources);
|
||||
}
|
||||
|
||||
// For scheduled livestreams, use shorter cache TTL (5 minutes) so they refresh when they go live
|
||||
// For regular content, use configured cache duration
|
||||
var cacheDuration = urn.Contains("scheduled_livestream", StringComparison.OrdinalIgnoreCase)
|
||||
? 5
|
||||
: config.CacheDurationMinutes;
|
||||
|
||||
// Try cache first
|
||||
var mediaComposition = _metadataCache.GetMediaComposition(urn, config.CacheDurationMinutes);
|
||||
var mediaComposition = _metadataCache.GetMediaComposition(urn, cacheDuration);
|
||||
|
||||
// If not in cache, fetch from API
|
||||
if (mediaComposition == null)
|
||||
@ -113,12 +119,72 @@ public class SRFMediaProvider : IMediaSourceProvider
|
||||
// Get stream URL based on quality preference
|
||||
var streamUrl = _streamResolver.GetStreamUrl(chapter, config.QualityPreference);
|
||||
|
||||
if (string.IsNullOrEmpty(streamUrl))
|
||||
// Check if this is an upcoming livestream that hasn't started yet
|
||||
var isUpcomingLivestream = chapter.Type == "SCHEDULED_LIVESTREAM" && string.IsNullOrEmpty(streamUrl);
|
||||
|
||||
if (string.IsNullOrEmpty(streamUrl) && !isUpcomingLivestream)
|
||||
{
|
||||
_logger.LogWarning("Could not resolve stream URL for URN: {Urn}", urn);
|
||||
return Task.FromResult<IEnumerable<MediaSourceInfo>>(sources);
|
||||
}
|
||||
|
||||
// For upcoming livestreams, check if the event has started
|
||||
if (isUpcomingLivestream)
|
||||
{
|
||||
if (chapter.ValidFrom != null)
|
||||
{
|
||||
var eventStart = chapter.ValidFrom.Value.ToUniversalTime();
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
if (eventStart > now)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"URN {Urn}: Livestream '{Title}' hasn't started yet (starts at {ValidFrom}). User should refresh when live.",
|
||||
urn,
|
||||
chapter.Title,
|
||||
chapter.ValidFrom);
|
||||
|
||||
// Return empty sources - event not yet playable
|
||||
return Task.FromResult<IEnumerable<MediaSourceInfo>>(sources);
|
||||
}
|
||||
}
|
||||
|
||||
// Event should be live now - re-fetch media composition without cache
|
||||
using var freshApiClient = new SRFApiClient(_loggerFactory);
|
||||
var freshMediaComposition = freshApiClient.GetMediaCompositionByUrnAsync(urn, cancellationToken).GetAwaiter().GetResult();
|
||||
|
||||
if (freshMediaComposition?.ChapterList != null && freshMediaComposition.ChapterList.Count > 0)
|
||||
{
|
||||
var freshChapter = freshMediaComposition.ChapterList[0];
|
||||
streamUrl = _streamResolver.GetStreamUrl(freshChapter, config.QualityPreference);
|
||||
|
||||
if (!string.IsNullOrEmpty(streamUrl))
|
||||
{
|
||||
// Update cache with fresh data
|
||||
_metadataCache.SetMediaComposition(urn, freshMediaComposition);
|
||||
chapter = freshChapter;
|
||||
_logger.LogInformation("URN {Urn}: Livestream is now live, got fresh stream URL", urn);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning("URN {Urn}: Livestream should be live but still no stream URL available", urn);
|
||||
return Task.FromResult<IEnumerable<MediaSourceInfo>>(sources);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning("URN {Urn}: Failed to fetch fresh media composition for livestream", urn);
|
||||
return Task.FromResult<IEnumerable<MediaSourceInfo>>(sources);
|
||||
}
|
||||
}
|
||||
|
||||
// Authenticate the stream URL (required for all SRF streams, especially livestreams)
|
||||
if (!string.IsNullOrEmpty(streamUrl))
|
||||
{
|
||||
streamUrl = _streamResolver.GetAuthenticatedStreamUrlAsync(streamUrl, cancellationToken).GetAwaiter().GetResult();
|
||||
_logger.LogDebug("Authenticated stream URL for URN: {Urn}", urn);
|
||||
}
|
||||
|
||||
// Create media source
|
||||
var mediaSource = new MediaSourceInfo
|
||||
{
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user