Added sports livestreams
This commit is contained in:
parent
d830f8ae5f
commit
ce6c435a92
@ -93,4 +93,10 @@ public class Chapter
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
[JsonPropertyName("mediaType")]
|
[JsonPropertyName("mediaType")]
|
||||||
public string? MediaType { get; set; }
|
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;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Jellyfin.Plugin.SRFPlay.Api.Models;
|
using Jellyfin.Plugin.SRFPlay.Api.Models;
|
||||||
|
using Jellyfin.Plugin.SRFPlay.Api.Models.PlayV3;
|
||||||
using Jellyfin.Plugin.SRFPlay.Configuration;
|
using Jellyfin.Plugin.SRFPlay.Configuration;
|
||||||
using Microsoft.Extensions.Logging;
|
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/>
|
/// <inheritdoc/>
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
using System.Globalization;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Security.Cryptography;
|
using System.Security.Cryptography;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
@ -145,6 +146,15 @@ public class SRFPlayChannel : IChannel, IHasCacheKey
|
|||||||
ImageUrl = null
|
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
|
// Add category folders if enabled and CategoryService is available
|
||||||
if (config?.EnableCategoryFolders == true && _categoryService != null)
|
if (config?.EnableCategoryFolders == true && _categoryService != null)
|
||||||
{
|
{
|
||||||
@ -201,6 +211,54 @@ public class SRFPlayChannel : IChannel, IHasCacheKey
|
|||||||
items = await ConvertUrnsToChannelItems(urns, cancellationToken).ConfigureAwait(false);
|
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
|
// Category folder - show videos for this category
|
||||||
else if (query.FolderId?.StartsWith("category_", StringComparison.Ordinal) == true)
|
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
|
var enabledTopics = config?.EnabledTopics != null && config.EnabledTopics.Count > 0
|
||||||
? string.Join(",", config.EnabledTopics)
|
? string.Join(",", config.EnabledTopics)
|
||||||
: "all";
|
: "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)
|
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
|
// Generate deterministic GUID from URN
|
||||||
var itemId = UrnToGuid(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
|
var item = new ChannelItemInfo
|
||||||
{
|
{
|
||||||
Id = itemId,
|
Id = itemId,
|
||||||
Name = chapter.Title,
|
Name = chapter.Title,
|
||||||
Overview = chapter.Description ?? chapter.Lead,
|
Overview = overview,
|
||||||
ImageUrl = chapter.ImageUrl,
|
ImageUrl = imageUrl,
|
||||||
Type = ChannelItemType.Media,
|
Type = ChannelItemType.Media,
|
||||||
ContentType = ChannelMediaContentType.Episode,
|
ContentType = ChannelMediaContentType.Episode,
|
||||||
MediaType = ChannelMediaType.Video,
|
MediaType = ChannelMediaType.Video,
|
||||||
DateCreated = chapter.Date?.ToUniversalTime(),
|
DateCreated = chapter.Date?.ToUniversalTime(),
|
||||||
PremiereDate = chapter.Date?.ToUniversalTime(),
|
PremiereDate = premiereDate,
|
||||||
ProductionYear = chapter.Date?.Year,
|
ProductionYear = chapter.Date?.Year,
|
||||||
RunTimeTicks = chapter.Duration > 0 ? TimeSpan.FromMilliseconds(chapter.Duration).Ticks : null,
|
RunTimeTicks = chapter.Duration > 0 ? TimeSpan.FromMilliseconds(chapter.Duration).Ticks : null,
|
||||||
ProviderIds = new Dictionary<string, string>
|
ProviderIds = new Dictionary<string, string>
|
||||||
@ -396,7 +501,7 @@ public class SRFPlayChannel : IChannel, IHasCacheKey
|
|||||||
{
|
{
|
||||||
Id = itemId,
|
Id = itemId,
|
||||||
Name = chapter.Title,
|
Name = chapter.Title,
|
||||||
Path = _streamResolver.GetStreamUrl(chapter, config.QualityPreference),
|
Path = streamUrl,
|
||||||
Protocol = MediaBrowser.Model.MediaInfo.MediaProtocol.Http,
|
Protocol = MediaBrowser.Model.MediaInfo.MediaProtocol.Http,
|
||||||
Container = "m3u8",
|
Container = "m3u8",
|
||||||
SupportsDirectStream = true,
|
SupportsDirectStream = true,
|
||||||
|
|||||||
@ -93,6 +93,7 @@ public class SRFImageProvider : IRemoteImageProvider, IHasOrder
|
|||||||
var chapter = mediaComposition.ChapterList[0];
|
var chapter = mediaComposition.ChapterList[0];
|
||||||
if (!string.IsNullOrEmpty(chapter.ImageUrl))
|
if (!string.IsNullOrEmpty(chapter.ImageUrl))
|
||||||
{
|
{
|
||||||
|
_logger.LogDebug("URN {Urn}: Adding chapter image: {ImageUrl}", urn, chapter.ImageUrl);
|
||||||
list.Add(new RemoteImageInfo
|
list.Add(new RemoteImageInfo
|
||||||
{
|
{
|
||||||
Url = chapter.ImageUrl,
|
Url = chapter.ImageUrl,
|
||||||
@ -107,6 +108,10 @@ public class SRFImageProvider : IRemoteImageProvider, IHasOrder
|
|||||||
ProviderName = Name
|
ProviderName = Name
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_logger.LogDebug("URN {Urn}: No chapter image available", urn);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract images from show
|
// Extract images from show
|
||||||
@ -114,6 +119,7 @@ public class SRFImageProvider : IRemoteImageProvider, IHasOrder
|
|||||||
{
|
{
|
||||||
if (!string.IsNullOrEmpty(mediaComposition.Show.ImageUrl))
|
if (!string.IsNullOrEmpty(mediaComposition.Show.ImageUrl))
|
||||||
{
|
{
|
||||||
|
_logger.LogDebug("URN {Urn}: Adding show image: {ImageUrl}", urn, mediaComposition.Show.ImageUrl);
|
||||||
list.Add(new RemoteImageInfo
|
list.Add(new RemoteImageInfo
|
||||||
{
|
{
|
||||||
Url = mediaComposition.Show.ImageUrl,
|
Url = mediaComposition.Show.ImageUrl,
|
||||||
@ -124,6 +130,7 @@ public class SRFImageProvider : IRemoteImageProvider, IHasOrder
|
|||||||
|
|
||||||
if (!string.IsNullOrEmpty(mediaComposition.Show.BannerImageUrl))
|
if (!string.IsNullOrEmpty(mediaComposition.Show.BannerImageUrl))
|
||||||
{
|
{
|
||||||
|
_logger.LogDebug("URN {Urn}: Adding show banner: {BannerUrl}", urn, mediaComposition.Show.BannerImageUrl);
|
||||||
list.Add(new RemoteImageInfo
|
list.Add(new RemoteImageInfo
|
||||||
{
|
{
|
||||||
Url = mediaComposition.Show.BannerImageUrl,
|
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)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
|
|||||||
@ -72,8 +72,14 @@ public class SRFMediaProvider : IMediaSourceProvider
|
|||||||
return Task.FromResult<IEnumerable<MediaSourceInfo>>(sources);
|
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
|
// Try cache first
|
||||||
var mediaComposition = _metadataCache.GetMediaComposition(urn, config.CacheDurationMinutes);
|
var mediaComposition = _metadataCache.GetMediaComposition(urn, cacheDuration);
|
||||||
|
|
||||||
// If not in cache, fetch from API
|
// If not in cache, fetch from API
|
||||||
if (mediaComposition == null)
|
if (mediaComposition == null)
|
||||||
@ -113,12 +119,72 @@ public class SRFMediaProvider : IMediaSourceProvider
|
|||||||
// Get stream URL based on quality preference
|
// Get stream URL based on quality preference
|
||||||
var streamUrl = _streamResolver.GetStreamUrl(chapter, config.QualityPreference);
|
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);
|
_logger.LogWarning("Could not resolve stream URL for URN: {Urn}", urn);
|
||||||
return Task.FromResult<IEnumerable<MediaSourceInfo>>(sources);
|
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
|
// Create media source
|
||||||
var mediaSource = new MediaSourceInfo
|
var mediaSource = new MediaSourceInfo
|
||||||
{
|
{
|
||||||
|
|||||||
@ -1,5 +1,9 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Linq;
|
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.Api.Models;
|
||||||
using Jellyfin.Plugin.SRFPlay.Configuration;
|
using Jellyfin.Plugin.SRFPlay.Configuration;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
@ -9,9 +13,11 @@ namespace Jellyfin.Plugin.SRFPlay.Services;
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Service for resolving stream URLs from media composition resources.
|
/// Service for resolving stream URLs from media composition resources.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class StreamUrlResolver
|
public class StreamUrlResolver : IDisposable
|
||||||
{
|
{
|
||||||
private readonly ILogger<StreamUrlResolver> _logger;
|
private readonly ILogger<StreamUrlResolver> _logger;
|
||||||
|
private readonly HttpClient _httpClient;
|
||||||
|
private bool _disposed;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Initializes a new instance of the <see cref="StreamUrlResolver"/> class.
|
/// Initializes a new instance of the <see cref="StreamUrlResolver"/> class.
|
||||||
@ -20,6 +26,7 @@ public class StreamUrlResolver
|
|||||||
public StreamUrlResolver(ILogger<StreamUrlResolver> logger)
|
public StreamUrlResolver(ILogger<StreamUrlResolver> logger)
|
||||||
{
|
{
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
|
_httpClient = new HttpClient();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@ -137,6 +144,23 @@ public class StreamUrlResolver
|
|||||||
/// <returns>True if playable content is available.</returns>
|
/// <returns>True if playable content is available.</returns>
|
||||||
public bool HasPlayableContent(Chapter chapter)
|
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)
|
if (chapter?.ResourceList == null || chapter.ResourceList.Count == 0)
|
||||||
{
|
{
|
||||||
return false;
|
return false;
|
||||||
@ -195,4 +219,87 @@ public class StreamUrlResolver
|
|||||||
// Return first available
|
// Return first available
|
||||||
return resources.FirstOrDefault();
|
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