From ce6c435a92f9f96d3aad7ce14e529c916b7cf63b Mon Sep 17 00:00:00 2001 From: Duncan Tourolle Date: Fri, 14 Nov 2025 21:20:41 +0100 Subject: [PATCH] Added sports livestreams --- Jellyfin.Plugin.SRFPlay/Api/Models/Chapter.cs | 6 + .../Api/Models/PlayV3/PlayV3TvProgram.cs | 107 ++++++++++++++++ .../PlayV3/PlayV3TvProgramGuideResponse.cs | 16 +++ Jellyfin.Plugin.SRFPlay/Api/SRFApiClient.cs | 53 ++++++++ .../Channels/SRFPlayChannel.cs | 115 +++++++++++++++++- .../Providers/SRFImageProvider.cs | 9 +- .../Providers/SRFMediaProvider.cs | 70 ++++++++++- .../Services/StreamUrlResolver.cs | 109 ++++++++++++++++- 8 files changed, 476 insertions(+), 9 deletions(-) create mode 100644 Jellyfin.Plugin.SRFPlay/Api/Models/PlayV3/PlayV3TvProgram.cs create mode 100644 Jellyfin.Plugin.SRFPlay/Api/Models/PlayV3/PlayV3TvProgramGuideResponse.cs diff --git a/Jellyfin.Plugin.SRFPlay/Api/Models/Chapter.cs b/Jellyfin.Plugin.SRFPlay/Api/Models/Chapter.cs index bb4b695..3420dac 100644 --- a/Jellyfin.Plugin.SRFPlay/Api/Models/Chapter.cs +++ b/Jellyfin.Plugin.SRFPlay/Api/Models/Chapter.cs @@ -93,4 +93,10 @@ public class Chapter /// [JsonPropertyName("mediaType")] public string? MediaType { get; set; } + + /// + /// Gets or sets the type (e.g., SCHEDULED_LIVESTREAM). + /// + [JsonPropertyName("type")] + public string? Type { get; set; } } diff --git a/Jellyfin.Plugin.SRFPlay/Api/Models/PlayV3/PlayV3TvProgram.cs b/Jellyfin.Plugin.SRFPlay/Api/Models/PlayV3/PlayV3TvProgram.cs new file mode 100644 index 0000000..4a6ccea --- /dev/null +++ b/Jellyfin.Plugin.SRFPlay/Api/Models/PlayV3/PlayV3TvProgram.cs @@ -0,0 +1,107 @@ +using System; +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace Jellyfin.Plugin.SRFPlay.Api.Models.PlayV3; + +/// +/// TV Program entry in the guide. +/// +public class PlayV3TvProgram +{ + /// + /// Gets or sets the URN identifier. + /// + [JsonPropertyName("urn")] + public string? Urn { get; set; } + + /// + /// Gets or sets the title. + /// + [JsonPropertyName("title")] + public string? Title { get; set; } + + /// + /// Gets or sets the lead/description. + /// + [JsonPropertyName("lead")] + public string? Lead { get; set; } + + /// + /// Gets or sets the description. + /// + [JsonPropertyName("description")] + public string? Description { get; set; } + + /// + /// Gets or sets the image URL. + /// + [JsonPropertyName("imageUrl")] + public string? ImageUrl { get; set; } + + /// + /// Gets or sets the content type (e.g., SCHEDULED_LIVESTREAM). + /// + [JsonPropertyName("type")] + public string? Type { get; set; } + + /// + /// Gets or sets the media type. + /// + [JsonPropertyName("mediaType")] + public string? MediaType { get; set; } + + /// + /// Gets or sets the vendor (e.g., SRF, RTS). + /// + [JsonPropertyName("vendor")] + public string? Vendor { get; set; } + + /// + /// Gets or sets the start time. + /// + [JsonPropertyName("date")] + public DateTime? Date { get; set; } + + /// + /// Gets or sets the valid from time (for scheduled livestreams). + /// + [JsonPropertyName("validFrom")] + public DateTime? ValidFrom { get; set; } + + /// + /// Gets or sets the valid to time (for scheduled livestreams). + /// + [JsonPropertyName("validTo")] + public DateTime? ValidTo { get; set; } + + /// + /// Gets or sets the duration in milliseconds. + /// + [JsonPropertyName("duration")] + public long? Duration { get; set; } + + /// + /// Gets or sets the channel ID. + /// + [JsonPropertyName("channelId")] + public string? ChannelId { get; set; } + + /// + /// Gets or sets the channel title. + /// + [JsonPropertyName("channelTitle")] + public string? ChannelTitle { get; set; } + + /// + /// Gets or sets whether this is blocked (DRM). + /// + [JsonPropertyName("blocked")] + public bool? Blocked { get; set; } + + /// + /// Gets or sets whether this is geoblocked. + /// + [JsonPropertyName("geoblocked")] + public bool? Geoblocked { get; set; } +} diff --git a/Jellyfin.Plugin.SRFPlay/Api/Models/PlayV3/PlayV3TvProgramGuideResponse.cs b/Jellyfin.Plugin.SRFPlay/Api/Models/PlayV3/PlayV3TvProgramGuideResponse.cs new file mode 100644 index 0000000..b860bdf --- /dev/null +++ b/Jellyfin.Plugin.SRFPlay/Api/Models/PlayV3/PlayV3TvProgramGuideResponse.cs @@ -0,0 +1,16 @@ +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace Jellyfin.Plugin.SRFPlay.Api.Models.PlayV3; + +/// +/// TV Program Guide response from Play v3 API. +/// +public class PlayV3TvProgramGuideResponse +{ + /// + /// Gets the list of TV program entries. + /// + [JsonPropertyName("data")] + public IReadOnlyList? Data { get; init; } +} diff --git a/Jellyfin.Plugin.SRFPlay/Api/SRFApiClient.cs b/Jellyfin.Plugin.SRFPlay/Api/SRFApiClient.cs index 5adafb4..c638542 100644 --- a/Jellyfin.Plugin.SRFPlay/Api/SRFApiClient.cs +++ b/Jellyfin.Plugin.SRFPlay/Api/SRFApiClient.cs @@ -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 } } + /// + /// Gets scheduled livestreams for sports or news events from the Play v3 API. + /// + /// The business unit (e.g., srf, rts). + /// The event type (SPORT or NEWS). + /// The cancellation token. + /// List of scheduled livestream entries. + public async Task?> 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(content, _jsonOptions); + if (jsonDoc.TryGetProperty("data", out var dataElement) && + dataElement.TryGetProperty("scheduledLivestreams", out var livestreamsElement)) + { + var livestreams = JsonSerializer.Deserialize>( + 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; + } + } + /// public void Dispose() { diff --git a/Jellyfin.Plugin.SRFPlay/Channels/SRFPlayChannel.cs b/Jellyfin.Plugin.SRFPlay/Channels/SRFPlayChannel.cs index 31f6c85..47c95f3 100644 --- a/Jellyfin.Plugin.SRFPlay/Channels/SRFPlayChannel.cs +++ b/Jellyfin.Plugin.SRFPlay/Channels/SRFPlayChannel.cs @@ -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> ConvertUrnsToChannelItems(List 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 @@ -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, diff --git a/Jellyfin.Plugin.SRFPlay/Providers/SRFImageProvider.cs b/Jellyfin.Plugin.SRFPlay/Providers/SRFImageProvider.cs index 6c0c930..e6b32e5 100644 --- a/Jellyfin.Plugin.SRFPlay/Providers/SRFImageProvider.cs +++ b/Jellyfin.Plugin.SRFPlay/Providers/SRFImageProvider.cs @@ -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) { diff --git a/Jellyfin.Plugin.SRFPlay/Providers/SRFMediaProvider.cs b/Jellyfin.Plugin.SRFPlay/Providers/SRFMediaProvider.cs index 542058d..8d45270 100644 --- a/Jellyfin.Plugin.SRFPlay/Providers/SRFMediaProvider.cs +++ b/Jellyfin.Plugin.SRFPlay/Providers/SRFMediaProvider.cs @@ -72,8 +72,14 @@ public class SRFMediaProvider : IMediaSourceProvider return Task.FromResult>(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>(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>(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>(sources); + } + } + else + { + _logger.LogWarning("URN {Urn}: Failed to fetch fresh media composition for livestream", urn); + return Task.FromResult>(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 { diff --git a/Jellyfin.Plugin.SRFPlay/Services/StreamUrlResolver.cs b/Jellyfin.Plugin.SRFPlay/Services/StreamUrlResolver.cs index ca9e7b0..ffc9da3 100644 --- a/Jellyfin.Plugin.SRFPlay/Services/StreamUrlResolver.cs +++ b/Jellyfin.Plugin.SRFPlay/Services/StreamUrlResolver.cs @@ -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; /// /// Service for resolving stream URLs from media composition resources. /// -public class StreamUrlResolver +public class StreamUrlResolver : IDisposable { private readonly ILogger _logger; + private readonly HttpClient _httpClient; + private bool _disposed; /// /// Initializes a new instance of the class. @@ -20,6 +26,7 @@ public class StreamUrlResolver public StreamUrlResolver(ILogger logger) { _logger = logger; + _httpClient = new HttpClient(); } /// @@ -137,6 +144,23 @@ public class StreamUrlResolver /// True if playable content is available. 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(); } + + /// + /// 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 = $"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(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 + } + } + + /// + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + /// + /// Releases the unmanaged resources and optionally releases the managed resources. + /// + /// True to release both managed and unmanaged resources; false to release only unmanaged resources. + protected virtual void Dispose(bool disposing) + { + if (!_disposed) + { + if (disposing) + { + _httpClient?.Dispose(); + } + + _disposed = true; + } + } }