Compare commits

...
6 Commits
Author SHA1 Message Date
dtourolle ed4cc0990c mixed refactor
🏗️ Build Plugin / build (push) Successful in 2m47s
🧪 Test Plugin / test (push) Successful in 1m22s
🚀 Release Plugin / build-and-release (push) Successful in 2m39s
2025-12-06 20:18:43 +01:00
dtourolle 4f9ebe2bce enable query and provide stream assumed stream info if missing.
🏗️ Build Plugin / build (push) Successful in 2m38s
🧪 Test Plugin / test (push) Successful in 1m14s
2025-12-06 18:08:34 +01:00
dtourolle 14b6c33542 Proxy images to fix broken meta-data 2025-12-06 17:34:17 +01:00
dtourolle a0e7663323 refactor to unify data fetching and define abstract API for re-use
🏗️ Build Plugin / build (push) Successful in 2m35s
🧪 Test Plugin / test (push) Successful in 1m14s
2025-12-06 17:29:05 +01:00
dtourolle 0fea57a4f9 Dynamically fetch livestream info, resolves bug where stale data caused playback to fail.
🏗️ Build Plugin / build (push) Successful in 3m47s
🧪 Test Plugin / test (push) Successful in 1m43s
2025-12-06 16:35:36 +01:00
dtourolle 89c41842a7 Update readme 2025-11-23 14:07:25 +01:00
31 changed files with 1861 additions and 991 deletions
@@ -0,0 +1,13 @@
namespace Jellyfin.Plugin.SRFPlay.Api;
/// <summary>
/// Factory interface for creating SRF API clients.
/// </summary>
public interface ISRFApiClientFactory
{
/// <summary>
/// Creates a new instance of the SRF API client.
/// </summary>
/// <returns>A new SRFApiClient instance.</returns>
SRFApiClient CreateClient();
}
+4 -5
View File
@@ -8,6 +8,7 @@ using System.Threading.Tasks;
using Jellyfin.Plugin.SRFPlay.Api.Models;
using Jellyfin.Plugin.SRFPlay.Api.Models.PlayV3;
using Jellyfin.Plugin.SRFPlay.Configuration;
using Jellyfin.Plugin.SRFPlay.Constants;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.SRFPlay.Api;
@@ -17,9 +18,7 @@ namespace Jellyfin.Plugin.SRFPlay.Api;
/// </summary>
public class SRFApiClient : IDisposable
{
private const string BaseUrl = "https://il.srgssr.ch/integrationlayer/2.0";
private const string PlayV3BaseUrlTemplate = "https://www.{0}.ch/play/v3/api/{0}/production/";
private static readonly System.Text.CompositeFormat PlayV3UrlFormat = System.Text.CompositeFormat.Parse(PlayV3BaseUrlTemplate);
private static readonly System.Text.CompositeFormat PlayV3UrlFormat = System.Text.CompositeFormat.Parse(ApiEndpoints.PlayV3BaseUrlTemplate);
private readonly HttpClient _httpClient;
private readonly HttpClient _playV3HttpClient;
private readonly ILogger _logger;
@@ -47,7 +46,7 @@ public class SRFApiClient : IDisposable
_logger.LogInformation("SRFApiClient initializing without proxy");
}
_httpClient = CreateHttpClient(BaseUrl);
_httpClient = CreateHttpClient(ApiEndpoints.IntegrationLayerBaseUrl);
_playV3HttpClient = CreateHttpClient(null);
_jsonOptions = new JsonSerializerOptions
@@ -137,7 +136,7 @@ public class SRFApiClient : IDisposable
try
{
var url = $"/mediaComposition/byUrn/{urn}.json";
var fullUrl = $"{BaseUrl}{url}";
var fullUrl = $"{ApiEndpoints.IntegrationLayerBaseUrl}{url}";
_logger.LogInformation("Fetching media composition for URN: {Urn} from {Url}", urn, fullUrl);
// HttpClient consistently fails with 404, use curl directly
@@ -0,0 +1,26 @@
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.SRFPlay.Api;
/// <summary>
/// Factory for creating SRF API clients.
/// </summary>
public class SRFApiClientFactory : ISRFApiClientFactory
{
private readonly ILoggerFactory _loggerFactory;
/// <summary>
/// Initializes a new instance of the <see cref="SRFApiClientFactory"/> class.
/// </summary>
/// <param name="loggerFactory">The logger factory.</param>
public SRFApiClientFactory(ILoggerFactory loggerFactory)
{
_loggerFactory = loggerFactory;
}
/// <inheritdoc />
public SRFApiClient CreateClient()
{
return new SRFApiClient(_loggerFactory);
}
}
+297 -323
View File
@@ -6,7 +6,8 @@ using System.Security.Cryptography;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Plugin.SRFPlay.Services;
using Jellyfin.Plugin.SRFPlay.Constants;
using Jellyfin.Plugin.SRFPlay.Services.Interfaces;
using MediaBrowser.Controller;
using MediaBrowser.Controller.Channels;
using MediaBrowser.Controller.Providers;
@@ -24,11 +25,10 @@ public class SRFPlayChannel : IChannel, IHasCacheKey
{
private readonly ILogger<SRFPlayChannel> _logger;
private readonly ILoggerFactory _loggerFactory;
private readonly ContentRefreshService _contentRefreshService;
private readonly StreamUrlResolver _streamResolver;
private readonly StreamProxyService _proxyService;
private readonly CategoryService? _categoryService;
private readonly IServerApplicationHost _appHost;
private readonly IContentRefreshService _contentRefreshService;
private readonly IStreamUrlResolver _streamResolver;
private readonly IMediaSourceFactory _mediaSourceFactory;
private readonly ICategoryService? _categoryService;
/// <summary>
/// Initializes a new instance of the <see cref="SRFPlayChannel"/> class.
@@ -36,23 +36,20 @@ public class SRFPlayChannel : IChannel, IHasCacheKey
/// <param name="loggerFactory">The logger factory.</param>
/// <param name="contentRefreshService">The content refresh service.</param>
/// <param name="streamResolver">The stream resolver.</param>
/// <param name="proxyService">The stream proxy service.</param>
/// <param name="appHost">The server application host.</param>
/// <param name="mediaSourceFactory">The media source factory.</param>
/// <param name="categoryService">The category service (optional).</param>
public SRFPlayChannel(
ILoggerFactory loggerFactory,
ContentRefreshService contentRefreshService,
StreamUrlResolver streamResolver,
StreamProxyService proxyService,
IServerApplicationHost appHost,
CategoryService? categoryService = null)
IContentRefreshService contentRefreshService,
IStreamUrlResolver streamResolver,
IMediaSourceFactory mediaSourceFactory,
ICategoryService? categoryService = null)
{
_loggerFactory = loggerFactory;
_logger = loggerFactory.CreateLogger<SRFPlayChannel>();
_contentRefreshService = contentRefreshService;
_streamResolver = streamResolver;
_proxyService = proxyService;
_appHost = appHost;
_mediaSourceFactory = mediaSourceFactory;
_categoryService = categoryService;
if (_categoryService == null)
@@ -73,7 +70,7 @@ public class SRFPlayChannel : IChannel, IHasCacheKey
public string DataVersion => "2.0"; // Back to authenticating at channel refresh with auto-refresh for fresh tokens
/// <inheritdoc />
public string HomePageUrl => "https://www.srf.ch/play";
public string HomePageUrl => ApiEndpoints.SrfPlayHomepage;
/// <inheritdoc />
public ChannelParentalRating ParentalRating => ChannelParentalRating.GeneralAudience;
@@ -129,252 +126,261 @@ public class SRFPlayChannel : IChannel, IHasCacheKey
{
_logger.LogInformation("=== GetChannelItems called! FolderId: {FolderId} ===", query.FolderId);
try
{
var items = await GetFolderItemsAsync(query.FolderId, cancellationToken).ConfigureAwait(false);
_logger.LogInformation("Returning {Count} channel items for folder {FolderId}", items.Count, query.FolderId);
return new ChannelItemResult
{
Items = items,
TotalRecordCount = items.Count
};
}
catch (Exception ex)
{
_logger.LogError(ex, "Error getting channel items for folder {FolderId}", query.FolderId);
return new ChannelItemResult { Items = new List<ChannelItemInfo>(), TotalRecordCount = 0 };
}
}
private async Task<List<ChannelItemInfo>> GetFolderItemsAsync(string? folderId, CancellationToken cancellationToken)
{
// Root level - show folder list
if (string.IsNullOrEmpty(folderId))
{
return await GetRootFoldersAsync(cancellationToken).ConfigureAwait(false);
}
// Handle known folder types
return folderId switch
{
"latest" => await GetLatestVideosAsync(cancellationToken).ConfigureAwait(false),
"trending" => await GetTrendingVideosAsync(cancellationToken).ConfigureAwait(false),
"live_sports" => await GetLiveSportsAsync(cancellationToken).ConfigureAwait(false),
_ when folderId.StartsWith("category_", StringComparison.Ordinal) => await GetCategoryVideosAsync(folderId, cancellationToken).ConfigureAwait(false),
_ => new List<ChannelItemInfo>()
};
}
private async Task<List<ChannelItemInfo>> GetRootFoldersAsync(CancellationToken cancellationToken)
{
var items = new List<ChannelItemInfo>
{
CreateFolder("latest", "Latest Videos"),
CreateFolder("trending", "Trending Videos"),
CreateFolder("live_sports", "Live Sports & Events")
};
// Add category folders if enabled
var config = Plugin.Instance?.Configuration;
if (config?.EnableCategoryFolders == true && _categoryService != null)
{
try
{
var businessUnit = config.BusinessUnit.ToString().ToLowerInvariant();
var topics = await _categoryService.GetTopicsAsync(businessUnit, cancellationToken).ConfigureAwait(false);
foreach (var topic in topics.Where(t => !string.IsNullOrEmpty(t.Id)))
{
if (config.EnabledTopics != null && config.EnabledTopics.Count > 0 && !config.EnabledTopics.Contains(topic.Id!))
{
continue;
}
items.Add(CreateFolder($"category_{topic.Id}", topic.Title ?? topic.Id!, topic.Lead));
}
_logger.LogInformation("Added {Count} category folders", topics.Count);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to load category folders - continuing without categories");
}
}
return items;
}
private static ChannelItemInfo CreateFolder(string id, string name, string? overview = null)
{
return new ChannelItemInfo
{
Id = id,
Name = name,
Type = ChannelItemType.Folder,
FolderType = ChannelFolderType.Container,
ImageUrl = null,
Overview = overview
};
}
private async Task<List<ChannelItemInfo>> GetLatestVideosAsync(CancellationToken cancellationToken)
{
var urns = await _contentRefreshService.RefreshLatestContentAsync(cancellationToken).ConfigureAwait(false);
return await ConvertUrnsToChannelItems(urns, cancellationToken).ConfigureAwait(false);
}
private async Task<List<ChannelItemInfo>> GetTrendingVideosAsync(CancellationToken cancellationToken)
{
var urns = await _contentRefreshService.RefreshTrendingContentAsync(cancellationToken).ConfigureAwait(false);
return await ConvertUrnsToChannelItems(urns, cancellationToken).ConfigureAwait(false);
}
private async Task<List<ChannelItemInfo>> GetLiveSportsAsync(CancellationToken cancellationToken)
{
var items = new List<ChannelItemInfo>();
var config = Plugin.Instance?.Configuration;
try
{
// Root level - show categories
if (string.IsNullOrEmpty(query.FolderId))
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)
{
items.Add(new ChannelItemInfo
return items;
}
// Filter for upcoming/current events (within next 7 days)
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 &&
(p.ValidTo == null || p.ValidTo.Value.ToUniversalTime() > now))
.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)
{
Id = "latest",
Name = "Latest Videos",
Type = ChannelItemType.Folder,
FolderType = ChannelFolderType.Container,
ImageUrl = 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");
}
items.Add(new ChannelItemInfo
return items;
}
private async Task<List<ChannelItemInfo>> GetCategoryVideosAsync(string folderId, CancellationToken cancellationToken)
{
var items = new List<ChannelItemInfo>();
if (_categoryService == null)
{
_logger.LogWarning("CategoryService not available - cannot display category folder");
return items;
}
try
{
var config = Plugin.Instance?.Configuration;
var topicId = folderId.Substring("category_".Length);
var businessUnit = config?.BusinessUnit.ToString().ToLowerInvariant() ?? "srf";
var shows = await _categoryService.GetShowsByTopicAsync(topicId, businessUnit, 20, cancellationToken).ConfigureAwait(false);
var urns = new List<string>();
using var apiClient = new Api.SRFApiClient(_loggerFactory);
foreach (var show in shows)
{
if (show.Id == null || cancellationToken.IsCancellationRequested)
{
Id = "trending",
Name = "Trending Videos",
Type = ChannelItemType.Folder,
FolderType = ChannelFolderType.Container,
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)
{
try
{
var businessUnit = config.BusinessUnit.ToString().ToLowerInvariant();
var topics = await _categoryService.GetTopicsAsync(businessUnit, cancellationToken).ConfigureAwait(false);
foreach (var topic in topics.Where(t => !string.IsNullOrEmpty(t.Id)))
{
// Filter by enabled topics if configured
if (config.EnabledTopics != null && config.EnabledTopics.Count > 0 && !config.EnabledTopics.Contains(topic.Id!))
{
continue;
}
items.Add(new ChannelItemInfo
{
Id = $"category_{topic.Id}",
Name = topic.Title ?? topic.Id!,
Type = ChannelItemType.Folder,
FolderType = ChannelFolderType.Container,
ImageUrl = null,
Overview = topic.Lead
});
}
_logger.LogInformation("Added {Count} category folders", topics.Count);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to load category folders - continuing without categories");
}
continue;
}
return new ChannelItemResult
{
Items = items,
TotalRecordCount = items.Count
};
}
// Latest videos
if (query.FolderId == "latest")
{
var urns = await _contentRefreshService.RefreshLatestContentAsync(cancellationToken).ConfigureAwait(false);
items = await ConvertUrnsToChannelItems(urns, cancellationToken).ConfigureAwait(false);
}
// Trending videos
else if (query.FolderId == "trending")
{
var urns = await _contentRefreshService.RefreshTrendingContentAsync(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)
var latestUrn = await GetLatestVideoUrnForShowAsync(apiClient, businessUnit, show, topicId, cancellationToken).ConfigureAwait(false);
if (latestUrn != 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 &&
(p.ValidTo == null || p.ValidTo.Value.ToUniversalTime() > now))
.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;
}
}
urns.Add(latestUrn);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to load live sports events");
_logger.LogWarning(ex, "Error fetching videos for show {ShowId} in category {TopicId}", show.Id, topicId);
}
}
// Category folder - show videos for this category
else if (query.FolderId?.StartsWith("category_", StringComparison.Ordinal) == true)
{
if (_categoryService == null)
{
_logger.LogWarning("CategoryService not available - cannot display category folder");
}
else
{
try
{
var topicId = query.FolderId.Substring("category_".Length);
var businessUnit = config?.BusinessUnit.ToString().ToLowerInvariant() ?? "srf";
var shows = await _categoryService.GetShowsByTopicAsync(topicId, businessUnit, 20, cancellationToken).ConfigureAwait(false);
var urns = new List<string>();
using var apiClient = new Api.SRFApiClient(_loggerFactory);
foreach (var show in shows)
{
if (show.Id == null)
{
continue;
}
try
{
var videos = await apiClient.GetVideosForShowAsync(businessUnit, show.Id, cancellationToken).ConfigureAwait(false);
if (videos != null && videos.Count > 0)
{
_logger.LogDebug("Category {TopicId}, Show {Show} ({ShowId}): Found {Count} videos", topicId, show.Title, show.Id, videos.Count);
// Filter to videos that are actually published and not expired
var now = DateTime.UtcNow;
var availableVideos = videos.Where(v =>
(v.ValidFrom == null || v.ValidFrom.Value.ToUniversalTime() <= now) &&
(v.ValidTo == null || v.ValidTo.Value.ToUniversalTime() > now)).ToList();
_logger.LogDebug("Category {TopicId}, Show {Show}: {AvailableCount} available out of {TotalCount} videos", topicId, show.Title, availableVideos.Count, videos.Count);
if (availableVideos.Count > 0)
{
// Get most recent available video from this show
var latestVideo = availableVideos.OrderByDescending(v => v.Date).FirstOrDefault();
if (latestVideo?.Urn != null)
{
urns.Add(latestVideo.Urn);
_logger.LogInformation(
"Category {TopicId}: Added video from show {Show}: {Title} (URN: {Urn}, Date: {Date}, ValidFrom: {ValidFrom}, ValidTo: {ValidTo})",
topicId,
show.Title,
latestVideo.Title,
latestVideo.Urn,
latestVideo.Date,
latestVideo.ValidFrom,
latestVideo.ValidTo);
}
else
{
_logger.LogWarning("Category {TopicId}, Show {Show}: Latest available video has null URN", topicId, show.Title);
}
}
else
{
_logger.LogDebug("Category {TopicId}, Show {Show}: No available videos (all expired or not yet published)", topicId, show.Title);
}
}
else
{
_logger.LogDebug("Category {TopicId}, Show {Show} ({ShowId}): No videos returned from API", topicId, show.Title, show.Id);
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Error fetching videos for show {ShowId} in category {TopicId}", show.Id, topicId);
}
if (cancellationToken.IsCancellationRequested)
{
break;
}
}
items = await ConvertUrnsToChannelItems(urns, cancellationToken).ConfigureAwait(false);
_logger.LogInformation("Found {Count} videos for category {TopicId}", items.Count, topicId);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to load category videos");
}
}
}
_logger.LogInformation("Returning {Count} channel items for folder {FolderId}", items.Count, query.FolderId);
items = await ConvertUrnsToChannelItems(urns, cancellationToken).ConfigureAwait(false);
_logger.LogInformation("Found {Count} videos for category {TopicId}", items.Count, topicId);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error getting channel items for folder {FolderId}", query.FolderId);
_logger.LogError(ex, "Failed to load category videos");
}
return new ChannelItemResult
return items;
}
private async Task<string?> GetLatestVideoUrnForShowAsync(
Api.SRFApiClient apiClient,
string businessUnit,
Api.Models.PlayV3Show show,
string topicId,
CancellationToken cancellationToken)
{
var videos = await apiClient.GetVideosForShowAsync(businessUnit, show.Id!, cancellationToken).ConfigureAwait(false);
if (videos == null || videos.Count == 0)
{
Items = items,
TotalRecordCount = items.Count
};
_logger.LogDebug("Category {TopicId}, Show {Show} ({ShowId}): No videos returned from API", topicId, show.Title, show.Id);
return null;
}
_logger.LogDebug("Category {TopicId}, Show {Show} ({ShowId}): Found {Count} videos", topicId, show.Title, show.Id, videos.Count);
// Filter to available videos
var now = DateTime.UtcNow;
var availableVideos = videos.Where(v =>
(v.ValidFrom == null || v.ValidFrom.Value.ToUniversalTime() <= now) &&
(v.ValidTo == null || v.ValidTo.Value.ToUniversalTime() > now)).ToList();
_logger.LogDebug("Category {TopicId}, Show {Show}: {AvailableCount} available out of {TotalCount} videos", topicId, show.Title, availableVideos.Count, videos.Count);
if (availableVideos.Count == 0)
{
_logger.LogDebug("Category {TopicId}, Show {Show}: No available videos (all expired or not yet published)", topicId, show.Title);
return null;
}
var latestVideo = availableVideos.OrderByDescending(v => v.Date).FirstOrDefault();
if (latestVideo?.Urn == null)
{
_logger.LogWarning("Category {TopicId}, Show {Show}: Latest available video has null URN", topicId, show.Title);
return null;
}
_logger.LogInformation(
"Category {TopicId}: Added video from show {Show}: {Title} (URN: {Urn}, Date: {Date})",
topicId,
show.Title,
latestVideo.Title,
latestVideo.Urn,
latestVideo.Date);
return latestVideo.Urn;
}
/// <inheritdoc />
@@ -442,65 +448,56 @@ 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);
// Use factory to create MediaSourceInfo (handles stream URL, auth, proxy registration)
var mediaSource = await _mediaSourceFactory.CreateMediaSourceAsync(
chapter,
itemId,
urn,
config.QualityPreference,
cancellationToken).ConfigureAwait(false);
// Skip scheduled livestreams that haven't started yet (no stream URL available)
if (chapter.Type == "SCHEDULED_LIVESTREAM" && string.IsNullOrEmpty(streamUrl))
// Skip items without a valid media source (no stream URL available)
if (mediaSource == null)
{
_logger.LogDebug(
"URN {Urn}: Skipping upcoming livestream '{Title}' - stream not yet available (starts at {ValidFrom})",
urn,
chapter.Title,
chapter.ValidFrom);
if (chapter.Type == "SCHEDULED_LIVESTREAM")
{
_logger.LogDebug(
"URN {Urn}: Skipping upcoming livestream '{Title}' - stream not yet available (starts at {ValidFrom})",
urn,
chapter.Title,
chapter.ValidFrom);
}
else
{
_logger.LogWarning(
"URN {Urn}: Skipping '{Title}' - no valid stream URL available",
urn,
chapter.Title);
noStreamCount++;
}
continue;
}
// Authenticate the stream URL with fresh token
if (!string.IsNullOrEmpty(streamUrl))
{
streamUrl = await _streamResolver.GetAuthenticatedStreamUrlAsync(streamUrl, cancellationToken).ConfigureAwait(false);
}
// Skip items without a valid stream URL
if (string.IsNullOrEmpty(streamUrl))
{
_logger.LogWarning(
"URN {Urn}: Skipping '{Title}' - no valid stream URL available",
urn,
chapter.Title);
noStreamCount++;
continue;
}
// Register stream with proxy service
_proxyService.RegisterStream(itemId, streamUrl);
// Get the server URL for proxy - prefer configured public URL for remote clients
var serverUrl = !string.IsNullOrWhiteSpace(config.PublicServerUrl)
? config.PublicServerUrl.TrimEnd('/') // Use configured public URL (important for Android/remote clients)
: _appHost.GetSmartApiUrl(string.Empty); // Fall back to Jellyfin's smart URL resolution
// Create proxy URL as absolute HTTP URL (required for ffmpeg)
// Use the actual server URL so remote clients can access it
var proxyUrl = $"{serverUrl}/Plugins/SRFPlay/Proxy/{itemId}/master.m3u8";
// Build overview
var overview = chapter.Description ?? chapter.Lead;
// Get image URL - prefer chapter image, fall back to show image if available
var imageUrl = chapter.ImageUrl;
if (string.IsNullOrEmpty(imageUrl) && mediaComposition.Show != null)
var originalImageUrl = chapter.ImageUrl;
if (string.IsNullOrEmpty(originalImageUrl) && mediaComposition.Show != null)
{
imageUrl = mediaComposition.Show.ImageUrl;
originalImageUrl = mediaComposition.Show.ImageUrl;
_logger.LogDebug("URN {Urn}: Using show image as fallback", urn);
}
if (string.IsNullOrEmpty(imageUrl))
if (string.IsNullOrEmpty(originalImageUrl))
{
_logger.LogWarning("URN {Urn}: No image URL available for '{Title}'", urn, chapter.Title);
}
// Proxy image URL to fix Content-Type headers from SRF CDN
var imageUrl = CreateProxiedImageUrl(originalImageUrl, _mediaSourceFactory.GetServerBaseUrl());
// Use ValidFrom for premiere date if this is a scheduled livestream, otherwise use Date
var premiereDate = chapter.Type == "SCHEDULED_LIVESTREAM" ? chapter.ValidFrom?.ToUniversalTime() : chapter.Date?.ToUniversalTime();
@@ -522,46 +519,7 @@ public class SRFPlayChannel : IChannel, IHasCacheKey
{
{ "SRF", urn }
},
MediaSources = new List<MediaSourceInfo>
{
new MediaSourceInfo
{
Id = itemId,
Name = chapter.Title,
Path = proxyUrl, // Proxy URL instead of direct Akamai URL
Protocol = MediaBrowser.Model.MediaInfo.MediaProtocol.Http,
Container = "hls",
SupportsDirectStream = true,
SupportsDirectPlay = true, // ✅ Enabled! Proxy handles auth
SupportsTranscoding = true,
IsRemote = false, // False because it's a local proxy endpoint
Type = MediaBrowser.Model.Dto.MediaSourceType.Default,
VideoType = VideoType.VideoFile,
RequiresOpening = false,
RequiresClosing = false,
SupportsProbing = false, // Disable probing for proxy URLs
ReadAtNativeFramerate = false,
MediaStreams = new List<MediaBrowser.Model.Entities.MediaStream>
{
new MediaBrowser.Model.Entities.MediaStream
{
Type = MediaBrowser.Model.Entities.MediaStreamType.Video,
Codec = "h264",
Profile = "high",
IsInterlaced = false,
IsDefault = true,
Index = 0
},
new MediaBrowser.Model.Entities.MediaStream
{
Type = MediaBrowser.Model.Entities.MediaStreamType.Audio,
Codec = "aac",
IsDefault = true,
Index = 1
}
}
}
}
MediaSources = new List<MediaSourceInfo> { mediaSource }
};
// Add series info if available
@@ -573,13 +531,11 @@ public class SRFPlayChannel : IChannel, IHasCacheKey
items.Add(item);
successCount++;
_logger.LogInformation("URN {Urn}: Successfully converted to channel item - {Title}", urn, chapter.Title);
_logger.LogInformation(
"URN {Urn}: MediaSource configured - DirectStream={DirectStream}, DirectPlay={DirectPlay}, Transcoding={Transcoding}, Container={Container}",
_logger.LogDebug(
"URN {Urn}: MediaSource created via factory - DirectPlay={DirectPlay}, Transcoding={Transcoding}",
urn,
true,
true,
true,
"hls");
mediaSource.SupportsDirectPlay,
mediaSource.SupportsTranscoding);
}
catch (Exception ex)
{
@@ -617,6 +573,24 @@ public class SRFPlayChannel : IChannel, IHasCacheKey
}
#pragma warning restore CA5351
/// <summary>
/// Creates a proxy URL for an image to fix Content-Type headers from SRF CDN.
/// </summary>
/// <param name="originalUrl">The original SRF image URL.</param>
/// <param name="serverUrl">The server base URL.</param>
/// <returns>The proxied image URL, or null if original is null.</returns>
private static string? CreateProxiedImageUrl(string? originalUrl, string serverUrl)
{
if (string.IsNullOrEmpty(originalUrl))
{
return null;
}
// Encode the original URL as base64 for safe transport
var encodedUrl = Convert.ToBase64String(Encoding.UTF8.GetBytes(originalUrl));
return $"{serverUrl}/Plugins/SRFPlay/Proxy/Image?url={encodedUrl}";
}
/// <inheritdoc />
public bool IsEnabledFor(string userId)
{
@@ -0,0 +1,73 @@
namespace Jellyfin.Plugin.SRFPlay.Constants;
/// <summary>
/// Centralized API endpoints and URL templates used throughout the plugin.
/// </summary>
public static class ApiEndpoints
{
/// <summary>
/// SRG SSR Integration Layer API base URL.
/// Used for fetching media compositions, video metadata, and stream URLs.
/// </summary>
public const string IntegrationLayerBaseUrl = "https://il.srgssr.ch/integrationlayer/2.0";
/// <summary>
/// Play V3 API URL template. Format: {0} = business unit (srf, rts, rsi, rtr, swi).
/// Used for fetching shows, topics, and latest content.
/// </summary>
public const string PlayV3BaseUrlTemplate = "https://www.{0}.ch/play/v3/api/{0}/production/";
/// <summary>
/// Akamai token authentication endpoint.
/// Used to get hdnts tokens for stream authentication.
/// </summary>
public const string AkamaiTokenEndpoint = "https://tp.srgssr.ch/akahd/token";
/// <summary>
/// SRF Play homepage URL.
/// </summary>
public const string SrfPlayHomepage = "https://www.srf.ch/play";
/// <summary>
/// Media composition endpoint template (relative to IntegrationLayerBaseUrl).
/// Format: {0} = URN.
/// </summary>
public const string MediaCompositionByUrnPath = "/mediaComposition/byUrn/{0}.json";
/// <summary>
/// Base proxy route for stream proxying (relative to server root).
/// </summary>
public const string ProxyBasePath = "/Plugins/SRFPlay/Proxy";
/// <summary>
/// HLS master manifest route template (relative to server root).
/// Format: {0} = item ID.
/// </summary>
public const string ProxyMasterManifestPath = "/Plugins/SRFPlay/Proxy/{0}/master.m3u8";
/// <summary>
/// Image proxy route template (relative to server root).
/// Format: {0} = base64-encoded original URL.
/// </summary>
public const string ImageProxyPath = "/Plugins/SRFPlay/Image/{0}";
/// <summary>
/// Play V3 shows endpoint (relative to PlayV3 base URL).
/// </summary>
public const string PlayV3ShowsPath = "shows";
/// <summary>
/// Play V3 topics endpoint (relative to PlayV3 base URL).
/// </summary>
public const string PlayV3TopicsPath = "topics";
/// <summary>
/// Play V3 livestreams endpoint (relative to PlayV3 base URL).
/// </summary>
public const string PlayV3LivestreamsPath = "livestreams";
/// <summary>
/// Play V3 scheduled livestreams endpoint (relative to PlayV3 base URL).
/// </summary>
public const string PlayV3ScheduledLivestreamsPath = "scheduled-livestreams";
}
@@ -1,8 +1,11 @@
using System;
using System.Globalization;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Plugin.SRFPlay.Services;
using Jellyfin.Plugin.SRFPlay.Services.Interfaces;
using MediaBrowser.Common.Net;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
@@ -18,17 +21,49 @@ namespace Jellyfin.Plugin.SRFPlay.Controllers;
public class StreamProxyController : ControllerBase
{
private readonly ILogger<StreamProxyController> _logger;
private readonly StreamProxyService _proxyService;
private readonly IStreamProxyService _proxyService;
private readonly IHttpClientFactory _httpClientFactory;
/// <summary>
/// Initializes a new instance of the <see cref="StreamProxyController"/> class.
/// </summary>
/// <param name="logger">The logger.</param>
/// <param name="proxyService">The proxy service.</param>
public StreamProxyController(ILogger<StreamProxyController> logger, StreamProxyService proxyService)
/// <param name="httpClientFactory">The HTTP client factory.</param>
public StreamProxyController(
ILogger<StreamProxyController> logger,
IStreamProxyService proxyService,
IHttpClientFactory httpClientFactory)
{
_logger = logger;
_proxyService = proxyService;
_httpClientFactory = httpClientFactory;
}
/// <summary>
/// Adds CORS headers to allow cross-origin requests from hls.js in browsers.
/// </summary>
private void AddCorsHeaders()
{
Response.Headers["Access-Control-Allow-Origin"] = "*";
Response.Headers["Access-Control-Allow-Methods"] = "GET, HEAD, OPTIONS";
Response.Headers["Access-Control-Allow-Headers"] = "Content-Type, Range, Accept, Origin";
Response.Headers["Access-Control-Expose-Headers"] = "Content-Length, Content-Range, Accept-Ranges";
Response.Headers["Access-Control-Max-Age"] = "86400"; // Cache preflight for 24 hours
}
/// <summary>
/// Handles CORS preflight OPTIONS requests for all proxy endpoints.
/// </summary>
/// <returns>Empty response with CORS headers.</returns>
[HttpOptions("{itemId}/master.m3u8")]
[HttpOptions("{itemId}/{manifestPath}.m3u8")]
[HttpOptions("{itemId}/{*segmentPath}")]
[AllowAnonymous]
public IActionResult HandleOptions()
{
AddCorsHeaders();
return Ok();
}
/// <summary>
@@ -45,6 +80,7 @@ public class StreamProxyController : ControllerBase
[FromRoute] string itemId,
CancellationToken cancellationToken)
{
AddCorsHeaders();
_logger.LogInformation("Proxy request for master manifest - Path ItemId: {PathItemId}, Query params: {QueryString}", itemId, Request.QueryString);
// Try to resolve the actual item ID (path ID might be a session ID during transcoding)
@@ -110,6 +146,7 @@ public class StreamProxyController : ControllerBase
[FromRoute] string manifestPath,
CancellationToken cancellationToken)
{
AddCorsHeaders();
var fullPath = $"{manifestPath}.m3u8";
_logger.LogInformation("Proxy request for variant manifest - ItemId: {ItemId}, Path: {Path}", itemId, fullPath);
@@ -159,6 +196,7 @@ public class StreamProxyController : ControllerBase
[FromRoute] string segmentPath,
CancellationToken cancellationToken)
{
AddCorsHeaders();
_logger.LogDebug("Proxy request for segment - ItemId: {ItemId}, Path: {SegmentPath}", itemId, segmentPath);
// Try to resolve the actual item ID
@@ -235,8 +273,8 @@ public class StreamProxyController : ControllerBase
return queryItemId.ToString();
}
// No query parameters - use path ID as-is (TRANSCODING SESSION ID CASE)
_logger.LogWarning("⚠️ No query parameters found, using path ID as-is: {PathItemId} (likely transcoding session ID)", pathItemId);
// No query parameters - use path ID as-is (normal case for segments and transcoding sessions)
_logger.LogDebug("No query parameters, using path ID as-is: {PathItemId}", pathItemId);
return pathItemId;
}
@@ -263,28 +301,52 @@ public class StreamProxyController : ControllerBase
queryParams = string.Empty;
}
// Helper function to rewrite a single URL
string RewriteUrl(string url)
{
if (url.Contains("://", StringComparison.Ordinal))
{
// Absolute URL - extract filename and rewrite
var uri = new Uri(url.Trim());
var segments = uri.AbsolutePath.Split('/');
var fileName = segments[^1];
return $"{baseProxyUrl}/{fileName}{queryParams}";
}
// Relative URL - rewrite to proxy
return $"{baseProxyUrl}/{url.Trim()}{queryParams}";
}
var lines = manifestContent.Split('\n');
var result = new System.Text.StringBuilder();
foreach (var line in lines)
{
if (line.StartsWith('#') || string.IsNullOrWhiteSpace(line))
if (string.IsNullOrWhiteSpace(line))
{
// Keep metadata and blank lines as-is
result.AppendLine(line);
}
else if (line.Contains("://", StringComparison.Ordinal))
else if (line.StartsWith('#'))
{
// Absolute URL - extract the path and rewrite
var uri = new Uri(line.Trim());
var segments = uri.AbsolutePath.Split('/');
var fileName = segments[^1];
result.AppendLine(CultureInfo.InvariantCulture, $"{baseProxyUrl}/{fileName}{queryParams}");
// HLS tag line - check for URI="..." attributes (e.g., #EXT-X-MAP:URI="init.mp4")
if (line.Contains("URI=\"", StringComparison.Ordinal))
{
var rewrittenLine = System.Text.RegularExpressions.Regex.Replace(
line,
@"URI=""([^""]+)""",
match => $"URI=\"{RewriteUrl(match.Groups[1].Value)}\"");
result.AppendLine(rewrittenLine);
}
else
{
// Keep other metadata lines as-is
result.AppendLine(line);
}
}
else
{
// Relative URL - rewrite to proxy
result.AppendLine(CultureInfo.InvariantCulture, $"{baseProxyUrl}/{line.Trim()}{queryParams}");
// Non-tag line with URL - rewrite it
result.AppendLine(RewriteUrl(line));
}
}
@@ -316,4 +378,117 @@ public class StreamProxyController : ControllerBase
return "application/octet-stream";
}
/// <summary>
/// Proxies image requests from SRF CDN, fixing Content-Type headers.
/// </summary>
/// <param name="url">The original image URL (base64 encoded).</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The image data with correct Content-Type.</returns>
[HttpGet("Image")]
[AllowAnonymous]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetImage(
[FromQuery] string url,
CancellationToken cancellationToken)
{
if (string.IsNullOrEmpty(url))
{
return BadRequest("URL parameter is required");
}
string decodedUrl;
try
{
// Decode base64 URL
var bytes = Convert.FromBase64String(url);
decodedUrl = System.Text.Encoding.UTF8.GetString(bytes);
}
catch (FormatException)
{
_logger.LogWarning("Invalid base64 URL parameter: {Url}", url);
return BadRequest("Invalid URL encoding");
}
_logger.LogDebug("Proxying image from: {Url}", decodedUrl);
try
{
var httpClient = _httpClientFactory.CreateClient(NamedClient.Default);
// Create request with proper headers
var request = new HttpRequestMessage(HttpMethod.Get, new Uri(decodedUrl));
request.Headers.UserAgent.ParseAdd(
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36");
request.Headers.Accept.ParseAdd("image/jpeg, image/png, image/webp, image/*;q=0.8");
var response = await httpClient.SendAsync(
request,
HttpCompletionOption.ResponseHeadersRead,
cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
_logger.LogWarning(
"Image fetch failed with status {StatusCode} for {Url}",
response.StatusCode,
decodedUrl);
return NotFound();
}
var imageData = await response.Content.ReadAsByteArrayAsync(cancellationToken)
.ConfigureAwait(false);
// Determine correct content type
var contentType = GetImageContentType(decodedUrl);
_logger.LogDebug(
"Returning proxied image ({Length} bytes, {ContentType})",
imageData.Length,
contentType);
return File(imageData, contentType);
}
catch (HttpRequestException ex)
{
_logger.LogError(ex, "Error fetching image from {Url}", decodedUrl);
return StatusCode(StatusCodes.Status502BadGateway);
}
}
/// <summary>
/// Gets the content type for an image based on URL or file extension.
/// </summary>
/// <param name="url">The image URL.</param>
/// <returns>The MIME content type.</returns>
private static string GetImageContentType(string url)
{
if (string.IsNullOrEmpty(url))
{
return "image/jpeg";
}
var uri = new Uri(url);
var path = uri.AbsolutePath.ToLowerInvariant();
if (path.EndsWith(".png", StringComparison.Ordinal))
{
return "image/png";
}
if (path.EndsWith(".gif", StringComparison.Ordinal))
{
return "image/gif";
}
if (path.EndsWith(".webp", StringComparison.Ordinal))
{
return "image/webp";
}
// Default to JPEG for SRF images (most common)
return "image/jpeg";
}
}
@@ -4,8 +4,7 @@ using System.Linq;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Plugin.SRFPlay.Api;
using Jellyfin.Plugin.SRFPlay.Services;
using Jellyfin.Plugin.SRFPlay.Services.Interfaces;
using MediaBrowser.Controller.Entities.TV;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.Entities;
@@ -20,25 +19,23 @@ namespace Jellyfin.Plugin.SRFPlay.Providers;
public class SRFEpisodeProvider : IRemoteMetadataProvider<Episode, EpisodeInfo>
{
private readonly ILogger<SRFEpisodeProvider> _logger;
private readonly ILoggerFactory _loggerFactory;
private readonly IHttpClientFactory _httpClientFactory;
private readonly MetadataCache _metadataCache;
private readonly IMediaCompositionFetcher _compositionFetcher;
/// <summary>
/// Initializes a new instance of the <see cref="SRFEpisodeProvider"/> class.
/// </summary>
/// <param name="loggerFactory">The logger factory.</param>
/// <param name="httpClientFactory">The HTTP client factory.</param>
/// <param name="metadataCache">The metadata cache.</param>
/// <param name="compositionFetcher">The media composition fetcher.</param>
public SRFEpisodeProvider(
ILoggerFactory loggerFactory,
IHttpClientFactory httpClientFactory,
MetadataCache metadataCache)
IMediaCompositionFetcher compositionFetcher)
{
_loggerFactory = loggerFactory;
_logger = loggerFactory.CreateLogger<SRFEpisodeProvider>();
_httpClientFactory = httpClientFactory;
_metadataCache = metadataCache;
_compositionFetcher = compositionFetcher;
}
/// <inheritdoc />
@@ -56,26 +53,7 @@ public class SRFEpisodeProvider : IRemoteMetadataProvider<Episode, EpisodeInfo>
{
_logger.LogDebug("Searching for episode with URN: {Urn}", urn);
var config = Plugin.Instance?.Configuration;
if (config == null)
{
return results;
}
// Try cache first
var mediaComposition = _metadataCache.GetMediaComposition(urn, config.CacheDurationMinutes);
// If not in cache, fetch from API
if (mediaComposition == null)
{
using var apiClient = new SRFApiClient(_loggerFactory);
mediaComposition = await apiClient.GetMediaCompositionByUrnAsync(urn, cancellationToken).ConfigureAwait(false);
if (mediaComposition != null)
{
_metadataCache.SetMediaComposition(urn, mediaComposition);
}
}
var mediaComposition = await _compositionFetcher.GetMediaCompositionAsync(urn, cancellationToken).ConfigureAwait(false);
if (mediaComposition?.ChapterList != null && mediaComposition.ChapterList.Count > 0)
{
@@ -118,26 +96,7 @@ public class SRFEpisodeProvider : IRemoteMetadataProvider<Episode, EpisodeInfo>
_logger.LogDebug("Fetching metadata for episode URN: {Urn}", urn);
var config = Plugin.Instance?.Configuration;
if (config == null)
{
return result;
}
// Try cache first
var mediaComposition = _metadataCache.GetMediaComposition(urn, config.CacheDurationMinutes);
// If not in cache, fetch from API
if (mediaComposition == null)
{
using var apiClient = new SRFApiClient(_loggerFactory);
mediaComposition = await apiClient.GetMediaCompositionByUrnAsync(urn, cancellationToken).ConfigureAwait(false);
if (mediaComposition != null)
{
_metadataCache.SetMediaComposition(urn, mediaComposition);
}
}
var mediaComposition = await _compositionFetcher.GetMediaCompositionAsync(urn, cancellationToken).ConfigureAwait(false);
if (mediaComposition?.ChapterList == null || mediaComposition.ChapterList.Count == 0)
{
@@ -1,10 +1,10 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Plugin.SRFPlay.Api;
using Jellyfin.Plugin.SRFPlay.Services.Interfaces;
using MediaBrowser.Common.Net;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Providers;
@@ -21,18 +21,22 @@ public class SRFImageProvider : IRemoteImageProvider, IHasOrder
{
private readonly IHttpClientFactory _httpClientFactory;
private readonly ILogger<SRFImageProvider> _logger;
private readonly ILoggerFactory _loggerFactory;
private readonly IMediaCompositionFetcher _compositionFetcher;
/// <summary>
/// Initializes a new instance of the <see cref="SRFImageProvider"/> class.
/// </summary>
/// <param name="httpClientFactory">The HTTP client factory.</param>
/// <param name="loggerFactory">The logger factory.</param>
public SRFImageProvider(IHttpClientFactory httpClientFactory, ILoggerFactory loggerFactory)
/// <param name="compositionFetcher">The media composition fetcher.</param>
public SRFImageProvider(
IHttpClientFactory httpClientFactory,
ILoggerFactory loggerFactory,
IMediaCompositionFetcher compositionFetcher)
{
_httpClientFactory = httpClientFactory;
_loggerFactory = loggerFactory;
_logger = loggerFactory.CreateLogger<SRFImageProvider>();
_compositionFetcher = compositionFetcher;
}
/// <inheritdoc />
@@ -78,8 +82,7 @@ public class SRFImageProvider : IRemoteImageProvider, IHasOrder
_logger.LogDebug("Fetching images for SRF URN: {Urn}", urn);
// Fetch media composition to get image URLs
using var apiClient = new SRFApiClient(_loggerFactory);
var mediaComposition = await apiClient.GetMediaCompositionByUrnAsync(urn, cancellationToken).ConfigureAwait(false);
var mediaComposition = await _compositionFetcher.GetMediaCompositionAsync(urn, cancellationToken).ConfigureAwait(false);
if (mediaComposition == null)
{
@@ -151,9 +154,89 @@ public class SRFImageProvider : IRemoteImageProvider, IHasOrder
}
/// <inheritdoc />
public Task<HttpResponseMessage> GetImageResponse(string url, CancellationToken cancellationToken)
public async Task<HttpResponseMessage> GetImageResponse(string url, CancellationToken cancellationToken)
{
_logger.LogDebug("Fetching image from URL: {Url}", url);
var httpClient = _httpClientFactory.CreateClient(NamedClient.Default);
return httpClient.GetAsync(new Uri(url), cancellationToken);
// Create request with proper headers - SRF CDN requires User-Agent
var request = new HttpRequestMessage(HttpMethod.Get, new Uri(url));
request.Headers.UserAgent.ParseAdd("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36");
request.Headers.Accept.ParseAdd("image/jpeg, image/png, image/webp, image/*;q=0.8");
var response = await httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
var originalContentType = response.Content.Headers.ContentType?.MediaType;
_logger.LogDebug(
"Image response status: {StatusCode}, Content-Type: {ContentType}, Content-Length: {Length}",
response.StatusCode,
originalContentType ?? "null",
response.Content.Headers.ContentLength);
// Fix Content-Type if it's not a proper image type - SRF CDN often returns wrong content type
// Jellyfin needs correct Content-Type to process images
if (response.IsSuccessStatusCode)
{
var needsContentTypeFix = string.IsNullOrEmpty(originalContentType) ||
originalContentType == "binary/octet-stream" ||
originalContentType == "application/octet-stream" ||
!originalContentType.StartsWith("image/", StringComparison.OrdinalIgnoreCase);
if (needsContentTypeFix)
{
// Determine correct content type from URL extension or default to JPEG
var contentType = GetContentTypeFromUrl(url);
if (!string.IsNullOrEmpty(contentType))
{
_logger.LogInformation(
"Fixing Content-Type from '{OriginalType}' to '{NewType}' for {Url}",
originalContentType ?? "null",
contentType,
url);
response.Content.Headers.ContentType = new MediaTypeHeaderValue(contentType);
}
}
}
return response;
}
/// <summary>
/// Determines the correct content type based on URL file extension.
/// </summary>
private static string? GetContentTypeFromUrl(string url)
{
if (string.IsNullOrEmpty(url))
{
return null;
}
// Get the file extension from the URL (ignore query string)
var uri = new Uri(url);
var path = uri.AbsolutePath.ToLowerInvariant();
if (path.EndsWith(".jpg", StringComparison.Ordinal) || path.EndsWith(".jpeg", StringComparison.Ordinal))
{
return "image/jpeg";
}
if (path.EndsWith(".png", StringComparison.Ordinal))
{
return "image/png";
}
if (path.EndsWith(".gif", StringComparison.Ordinal))
{
return "image/gif";
}
if (path.EndsWith(".webp", StringComparison.Ordinal))
{
return "image/webp";
}
// Default to JPEG for SRF images (most common)
return "image/jpeg";
}
}
@@ -1,134 +0,0 @@
using System;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Plugin.SRFPlay.Services;
using MediaBrowser.Controller.Library;
using MediaBrowser.Model.Dto;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.SRFPlay.Providers;
/// <summary>
/// Live stream wrapper for SRF Play streams to handle transcoding sessions.
/// </summary>
internal sealed class SRFLiveStream : ILiveStream
{
private readonly ILogger _logger;
private readonly StreamProxyService _proxyService;
private readonly string _originalItemId;
private MediaSourceInfo? _mediaSource;
/// <summary>
/// Initializes a new instance of the <see cref="SRFLiveStream"/> class.
/// </summary>
/// <param name="logger">The logger.</param>
/// <param name="proxyService">The stream proxy service.</param>
/// <param name="originalItemId">The original item ID.</param>
/// <param name="openToken">The open token.</param>
/// <param name="loggerFactory">The logger factory.</param>
public SRFLiveStream(
ILogger logger,
StreamProxyService proxyService,
string originalItemId,
string openToken,
ILoggerFactory loggerFactory)
{
_logger = logger;
_proxyService = proxyService;
_originalItemId = originalItemId;
OriginalStreamId = openToken;
UniqueId = openToken;
}
/// <inheritdoc />
public int ConsumerCount { get; set; }
/// <inheritdoc />
public string OriginalStreamId { get; set; }
/// <inheritdoc />
public string UniqueId { get; }
/// <inheritdoc />
public string TunerHostId => string.Empty;
/// <inheritdoc />
public bool EnableStreamSharing => false;
/// <inheritdoc />
public MediaSourceInfo MediaSource
{
get => _mediaSource ?? throw new InvalidOperationException("MediaSource not set");
set
{
_mediaSource = value;
_logger.LogInformation(
"SRFLiveStream MediaSource set - Id: {MediaSourceId}, Path: {Path}, OriginalItemId: {OriginalItemId}",
value.Id,
value.Path,
_originalItemId);
// When Jellyfin assigns a live stream ID (for transcoding), register the stream with that ID too
if (value.Id != _originalItemId)
{
_logger.LogInformation(
"Transcoding session detected - LiveStream ID {LiveStreamId} differs from original item ID {OriginalItemId}. Registering stream with both IDs.",
value.Id,
_originalItemId);
// Get the authenticated URL from the original registration
var authenticatedUrl = _proxyService.GetAuthenticatedUrl(_originalItemId);
if (authenticatedUrl != null)
{
// Register the same stream URL with the transcoding session ID
_proxyService.RegisterStream(value.Id, authenticatedUrl);
_logger.LogInformation("Registered stream for transcoding session ID: {LiveStreamId}", value.Id);
}
else
{
_logger.LogWarning("Could not find authenticated URL for original item {OriginalItemId}", _originalItemId);
}
}
}
}
/// <inheritdoc />
public Task Close()
{
_logger.LogInformation("Closing SRF live stream for item {OriginalItemId}", _originalItemId);
return Task.CompletedTask;
}
/// <inheritdoc />
public Task Open(CancellationToken cancellationToken)
{
_logger.LogInformation("Opening SRF live stream for item {OriginalItemId}", _originalItemId);
return Task.CompletedTask;
}
/// <inheritdoc />
public Stream GetStream()
{
throw new NotSupportedException("Direct stream access not supported for SRF streams");
}
/// <inheritdoc />
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}
/// <summary>
/// Releases the unmanaged resources used by the SRFLiveStream 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>
private void Dispose(bool disposing)
{
if (disposing)
{
_logger.LogDebug("Disposing SRF live stream for item {OriginalItemId}", _originalItemId);
}
}
}
@@ -1,17 +1,12 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Plugin.SRFPlay.Api;
using Jellyfin.Plugin.SRFPlay.Services;
using MediaBrowser.Controller;
using Jellyfin.Plugin.SRFPlay.Configuration;
using Jellyfin.Plugin.SRFPlay.Services.Interfaces;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Library;
using MediaBrowser.Model.Dto;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.MediaInfo;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.SRFPlay.Providers;
@@ -22,34 +17,27 @@ namespace Jellyfin.Plugin.SRFPlay.Providers;
public class SRFMediaProvider : IMediaSourceProvider
{
private readonly ILogger<SRFMediaProvider> _logger;
private readonly ILoggerFactory _loggerFactory;
private readonly MetadataCache _metadataCache;
private readonly StreamUrlResolver _streamResolver;
private readonly StreamProxyService _proxyService;
private readonly IServerApplicationHost _appHost;
private readonly Dictionary<string, string> _openTokenToItemId = new();
private readonly IMediaCompositionFetcher _compositionFetcher;
private readonly IStreamUrlResolver _streamResolver;
private readonly IMediaSourceFactory _mediaSourceFactory;
/// <summary>
/// Initializes a new instance of the <see cref="SRFMediaProvider"/> class.
/// </summary>
/// <param name="loggerFactory">The logger factory.</param>
/// <param name="metadataCache">The metadata cache.</param>
/// <param name="compositionFetcher">The media composition fetcher.</param>
/// <param name="streamResolver">The stream URL resolver.</param>
/// <param name="proxyService">The stream proxy service.</param>
/// <param name="appHost">The server application host.</param>
/// <param name="mediaSourceFactory">The media source factory.</param>
public SRFMediaProvider(
ILoggerFactory loggerFactory,
MetadataCache metadataCache,
StreamUrlResolver streamResolver,
StreamProxyService proxyService,
IServerApplicationHost appHost)
IMediaCompositionFetcher compositionFetcher,
IStreamUrlResolver streamResolver,
IMediaSourceFactory mediaSourceFactory)
{
_loggerFactory = loggerFactory;
_logger = loggerFactory.CreateLogger<SRFMediaProvider>();
_metadataCache = metadataCache;
_compositionFetcher = compositionFetcher;
_streamResolver = streamResolver;
_proxyService = proxyService;
_appHost = appHost;
_mediaSourceFactory = mediaSourceFactory;
}
/// <summary>
@@ -69,51 +57,18 @@ public class SRFMediaProvider : IMediaSourceProvider
try
{
// Log detailed information about the request
var stackTrace = new System.Diagnostics.StackTrace(true);
var callingMethod = stackTrace.GetFrame(1)?.GetMethod();
_logger.LogInformation(
"GetMediaSources called - Item: {ItemName}, Type: {ItemType}, Id: {ItemId}, CalledBy: {CallingMethod}",
item.Name,
item.GetType().Name,
item.Id,
callingMethod?.DeclaringType?.Name + "." + callingMethod?.Name);
// Check if this is an SRF item
if (!item.ProviderIds.TryGetValue("SRF", out var urn) || string.IsNullOrEmpty(urn))
{
_logger.LogDebug("Item {ItemName} is not an SRF item, returning empty sources", item.Name);
return sources;
}
_logger.LogInformation("Getting media sources for URN: {Urn}, Item: {ItemName}", urn, item.Name);
var config = Plugin.Instance?.Configuration;
if (config == null)
{
return sources;
}
_logger.LogDebug("GetMediaSources for URN: {Urn}, Item: {ItemName}", urn, item.Name);
// 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;
var cacheDuration = urn.Contains("scheduled_livestream", StringComparison.OrdinalIgnoreCase) ? 5 : (int?)null;
// Try cache first
var mediaComposition = _metadataCache.GetMediaComposition(urn, cacheDuration);
// If not in cache, fetch from API
if (mediaComposition == null)
{
using var apiClient = new SRFApiClient(_loggerFactory);
mediaComposition = await apiClient.GetMediaCompositionByUrnAsync(urn, cancellationToken).ConfigureAwait(false);
if (mediaComposition != null)
{
_metadataCache.SetMediaComposition(urn, mediaComposition);
}
}
var mediaComposition = await _compositionFetcher.GetMediaCompositionAsync(urn, cancellationToken, cacheDuration).ConfigureAwait(false);
if (mediaComposition?.ChapterList == null || mediaComposition.ChapterList.Count == 0)
{
@@ -138,133 +93,60 @@ public class SRFMediaProvider : IMediaSourceProvider
return sources;
}
// Get stream URL based on quality preference
var streamUrl = _streamResolver.GetStreamUrl(chapter, config.QualityPreference);
// Get quality preference from config
var config = Plugin.Instance?.Configuration;
var qualityPref = config?.QualityPreference ?? QualityPreference.HD;
// For scheduled livestreams, always fetch fresh data to ensure stream URL is current
if (chapter.Type == "SCHEDULED_LIVESTREAM" && string.IsNullOrEmpty(streamUrl))
// Use item ID in hex format without dashes
var itemIdStr = item.Id.ToString("N");
// Use factory to create MediaSourceInfo
var mediaSource = await _mediaSourceFactory.CreateMediaSourceAsync(
chapter,
itemIdStr,
urn,
qualityPref,
cancellationToken).ConfigureAwait(false);
// For scheduled livestreams, retry with fresh data if no stream URL
if (mediaSource == null && chapter.Type == "SCHEDULED_LIVESTREAM")
{
_logger.LogDebug("URN {Urn}: Scheduled livestream has no stream URL, fetching fresh data", urn);
using var freshApiClient = new SRFApiClient(_loggerFactory);
var freshMediaComposition = await freshApiClient.GetMediaCompositionByUrnAsync(urn, cancellationToken).ConfigureAwait(false);
// Force fresh fetch with short cache duration
var freshMediaComposition = await _compositionFetcher.GetMediaCompositionAsync(urn, cancellationToken, 1).ConfigureAwait(false);
if (freshMediaComposition?.ChapterList != null && freshMediaComposition.ChapterList.Count > 0)
{
var freshChapter = freshMediaComposition.ChapterList[0];
streamUrl = _streamResolver.GetStreamUrl(freshChapter, config.QualityPreference);
mediaSource = await _mediaSourceFactory.CreateMediaSourceAsync(
freshChapter,
itemIdStr,
urn,
qualityPref,
cancellationToken).ConfigureAwait(false);
if (!string.IsNullOrEmpty(streamUrl))
if (mediaSource != null)
{
// Update cache with fresh data
_metadataCache.SetMediaComposition(urn, freshMediaComposition);
chapter = freshChapter;
_logger.LogInformation("URN {Urn}: Got fresh stream URL for scheduled livestream", urn);
}
}
}
if (string.IsNullOrEmpty(streamUrl))
if (mediaSource == null)
{
_logger.LogWarning("Could not resolve stream URL for URN: {Urn}", urn);
return sources;
}
// Authenticate the stream URL (required for all SRF streams, especially livestreams)
if (!string.IsNullOrEmpty(streamUrl))
{
streamUrl = await _streamResolver.GetAuthenticatedStreamUrlAsync(streamUrl, cancellationToken).ConfigureAwait(false);
_logger.LogDebug("Authenticated stream URL for URN: {Urn}", urn);
}
// Register stream with proxy service
var itemIdStr = item.Id.ToString("N"); // Use hex format without dashes
_proxyService.RegisterStream(itemIdStr, streamUrl);
// Get the server URL for proxy - prefer configured public URL for remote clients
var serverUrl = !string.IsNullOrWhiteSpace(config.PublicServerUrl)
? config.PublicServerUrl.TrimEnd('/') // Use configured public URL (important for Android/remote clients)
: _appHost.GetSmartApiUrl(string.Empty); // Fall back to Jellyfin's smart URL resolution
// Detect if this is a live stream
var isLiveStream = chapter.Type == "SCHEDULED_LIVESTREAM" || urn.Contains("livestream", StringComparison.OrdinalIgnoreCase);
// Generate an open token for this media source (used to track transcoding sessions)
var openToken = Guid.NewGuid().ToString("N");
_openTokenToItemId[openToken] = itemIdStr;
_logger.LogDebug("Created open token {OpenToken} for item {ItemId}", openToken, itemIdStr);
// Create proxy URL using token instead of item ID in path
// This prevents Jellyfin from rewriting the URL during transcoding
var proxyUrl = $"{serverUrl}/Plugins/SRFPlay/Proxy/{itemIdStr}/master.m3u8?token={openToken}";
_logger.LogInformation(
"Using proxy URL for item {ItemId}: {ProxyUrl} (PublicServerUrl configured: {IsPublicConfigured})",
itemIdStr,
proxyUrl,
!string.IsNullOrWhiteSpace(config.PublicServerUrl));
// Create media source using proxy URL - enables DirectPlay!
var mediaSource = new MediaSourceInfo
{
Id = itemIdStr, // Must match the ID used in proxy URL registration
Name = chapter.Title,
Path = proxyUrl, // Proxy URL instead of direct Akamai URL
Protocol = MediaProtocol.Http,
Container = "hls",
SupportsDirectStream = true,
SupportsDirectPlay = true, // ✅ Enabled! Proxy handles auth
SupportsTranscoding = true,
IsRemote = false, // False because it's a local proxy endpoint
Type = MediaSourceType.Default,
RunTimeTicks = chapter.Duration > 0 ? TimeSpan.FromMilliseconds(chapter.Duration).Ticks : null,
VideoType = VideoType.VideoFile,
IsInfiniteStream = isLiveStream, // True for live streams!
RequiresOpening = true, // Enable to handle transcoding sessions
RequiresClosing = false,
SupportsProbing = false, // Disable probing for proxy URLs
ReadAtNativeFramerate = isLiveStream, // Read at native framerate for live streams
OpenToken = openToken, // Token to identify this media source
MediaStreams = new List<MediaBrowser.Model.Entities.MediaStream>
{
new MediaBrowser.Model.Entities.MediaStream
{
Type = MediaStreamType.Video,
Codec = "h264",
Profile = "high",
IsInterlaced = false,
IsDefault = true,
Index = 0
},
new MediaBrowser.Model.Entities.MediaStream
{
Type = MediaStreamType.Audio,
Codec = "aac",
IsDefault = true,
Index = 1
}
}
};
sources.Add(mediaSource);
_logger.LogInformation("Resolved stream URL for {Title}: {Url}", chapter.Title, streamUrl);
_logger.LogInformation(
"MediaSource created - Id={Id}, DirectStream={DirectStream}, DirectPlay={DirectPlay}, Probing={Probing}, Container={Container}, Protocol={Protocol}, IsRemote={IsRemote}, IsLiveStream={IsLiveStream}",
_logger.LogDebug(
"MediaSource created for {Title} - Id={Id}, DirectPlay={DirectPlay}, Transcoding={Transcoding}",
chapter.Title,
mediaSource.Id,
mediaSource.SupportsDirectStream,
mediaSource.SupportsDirectPlay,
mediaSource.SupportsProbing,
mediaSource.Container,
mediaSource.Protocol,
mediaSource.IsRemote,
isLiveStream);
_logger.LogInformation(
"MediaSource capabilities - SupportsTranscoding={Transcoding}, RequiresOpening={RequiresOpening}, RequiresClosing={RequiresClosing}, Type={Type}, IsInfiniteStream={IsInfiniteStream}",
mediaSource.SupportsTranscoding,
mediaSource.RequiresOpening,
mediaSource.RequiresClosing,
mediaSource.Type,
mediaSource.IsInfiniteStream);
mediaSource.SupportsTranscoding);
}
catch (Exception ex)
{
@@ -288,27 +170,10 @@ public class SRFMediaProvider : IMediaSourceProvider
}
/// <inheritdoc />
public async Task<ILiveStream> OpenMediaSource(string openToken, List<ILiveStream> currentLiveStreams, CancellationToken cancellationToken)
public Task<ILiveStream> OpenMediaSource(string openToken, List<ILiveStream> currentLiveStreams, CancellationToken cancellationToken)
{
_logger.LogInformation("OpenMediaSource called with openToken: {OpenToken}", openToken);
// Look up the original item ID from the open token
if (!_openTokenToItemId.TryGetValue(openToken, out var originalItemId))
{
_logger.LogError("Open token {OpenToken} not found in registry", openToken);
throw new InvalidOperationException($"Open token {openToken} not found");
}
_logger.LogInformation("Open token {OpenToken} maps to original item ID: {ItemId}", openToken, originalItemId);
// Create a live stream wrapper
var liveStream = new SRFLiveStream(
_logger,
_proxyService,
originalItemId,
openToken,
_loggerFactory);
return await Task.FromResult<ILiveStream>(liveStream).ConfigureAwait(false);
// Not used - RequiresOpening is false, proxy handles authentication directly
_logger.LogWarning("OpenMediaSource unexpectedly called with openToken: {OpenToken}", openToken);
throw new NotSupportedException("OpenMediaSource not supported - streams use direct proxy access");
}
}
@@ -4,8 +4,7 @@ using System.Linq;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Plugin.SRFPlay.Api;
using Jellyfin.Plugin.SRFPlay.Services;
using Jellyfin.Plugin.SRFPlay.Services.Interfaces;
using MediaBrowser.Controller.Entities.TV;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.Entities;
@@ -20,27 +19,23 @@ namespace Jellyfin.Plugin.SRFPlay.Providers;
public class SRFSeriesProvider : IRemoteMetadataProvider<Series, SeriesInfo>
{
private readonly ILogger<SRFSeriesProvider> _logger;
private readonly ILoggerFactory _loggerFactory;
private readonly IHttpClientFactory _httpClientFactory;
private readonly MetadataCache _metadataCache;
private readonly IMediaCompositionFetcher _compositionFetcher;
/// <summary>
/// Initializes a new instance of the <see cref="SRFSeriesProvider"/> class.
/// </summary>
/// <param name="logger">The logger instance.</param>
/// <param name="loggerFactory">The logger factory.</param>
/// <param name="httpClientFactory">The HTTP client factory.</param>
/// <param name="metadataCache">The metadata cache.</param>
/// <param name="compositionFetcher">The media composition fetcher.</param>
public SRFSeriesProvider(
ILogger<SRFSeriesProvider> logger,
ILoggerFactory loggerFactory,
IHttpClientFactory httpClientFactory,
MetadataCache metadataCache)
IMediaCompositionFetcher compositionFetcher)
{
_logger = logger;
_loggerFactory = loggerFactory;
_logger = loggerFactory.CreateLogger<SRFSeriesProvider>();
_httpClientFactory = httpClientFactory;
_metadataCache = metadataCache;
_compositionFetcher = compositionFetcher;
}
/// <inheritdoc />
@@ -58,26 +53,7 @@ public class SRFSeriesProvider : IRemoteMetadataProvider<Series, SeriesInfo>
{
_logger.LogDebug("Searching for series with URN: {Urn}", urn);
var config = Plugin.Instance?.Configuration;
if (config == null)
{
return results;
}
// Try cache first
var mediaComposition = _metadataCache.GetMediaComposition(urn, config.CacheDurationMinutes);
// If not in cache, fetch from API
if (mediaComposition == null)
{
using var apiClient = new SRFApiClient(_loggerFactory);
mediaComposition = await apiClient.GetMediaCompositionByUrnAsync(urn, cancellationToken).ConfigureAwait(false);
if (mediaComposition != null)
{
_metadataCache.SetMediaComposition(urn, mediaComposition);
}
}
var mediaComposition = await _compositionFetcher.GetMediaCompositionAsync(urn, cancellationToken).ConfigureAwait(false);
if (mediaComposition?.Show != null)
{
@@ -125,26 +101,7 @@ public class SRFSeriesProvider : IRemoteMetadataProvider<Series, SeriesInfo>
_logger.LogDebug("Fetching metadata for series URN: {Urn}", urn);
var config = Plugin.Instance?.Configuration;
if (config == null)
{
return result;
}
// Try cache first
var mediaComposition = _metadataCache.GetMediaComposition(urn, config.CacheDurationMinutes);
// If not in cache, fetch from API
if (mediaComposition == null)
{
using var apiClient = new SRFApiClient(_loggerFactory);
mediaComposition = await apiClient.GetMediaCompositionByUrnAsync(urn, cancellationToken).ConfigureAwait(false);
if (mediaComposition != null)
{
_metadataCache.SetMediaComposition(urn, mediaComposition);
}
}
var mediaComposition = await _compositionFetcher.GetMediaCompositionAsync(urn, cancellationToken).ConfigureAwait(false);
if (mediaComposition?.Show == null)
{
@@ -2,7 +2,7 @@ using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Plugin.SRFPlay.Services;
using Jellyfin.Plugin.SRFPlay.Services.Interfaces;
using MediaBrowser.Model.Tasks;
using Microsoft.Extensions.Logging;
@@ -14,7 +14,7 @@ namespace Jellyfin.Plugin.SRFPlay.ScheduledTasks;
public class ContentRefreshTask : IScheduledTask
{
private readonly ILogger<ContentRefreshTask> _logger;
private readonly ContentRefreshService _contentRefreshService;
private readonly IContentRefreshService _contentRefreshService;
/// <summary>
/// Initializes a new instance of the <see cref="ContentRefreshTask"/> class.
@@ -23,7 +23,7 @@ public class ContentRefreshTask : IScheduledTask
/// <param name="contentRefreshService">The content refresh service.</param>
public ContentRefreshTask(
ILogger<ContentRefreshTask> logger,
ContentRefreshService contentRefreshService)
IContentRefreshService contentRefreshService)
{
_logger = logger;
_contentRefreshService = contentRefreshService;
@@ -2,7 +2,7 @@ using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Plugin.SRFPlay.Services;
using Jellyfin.Plugin.SRFPlay.Services.Interfaces;
using MediaBrowser.Model.Tasks;
using Microsoft.Extensions.Logging;
@@ -14,7 +14,7 @@ namespace Jellyfin.Plugin.SRFPlay.ScheduledTasks;
public class ExpirationCheckTask : IScheduledTask
{
private readonly ILogger<ExpirationCheckTask> _logger;
private readonly ContentExpirationService _expirationService;
private readonly IContentExpirationService _expirationService;
/// <summary>
/// Initializes a new instance of the <see cref="ExpirationCheckTask"/> class.
@@ -23,7 +23,7 @@ public class ExpirationCheckTask : IScheduledTask
/// <param name="expirationService">The content expiration service.</param>
public ExpirationCheckTask(
ILogger<ExpirationCheckTask> logger,
ContentExpirationService expirationService)
IContentExpirationService expirationService)
{
_logger = logger;
_expirationService = expirationService;
+14 -8
View File
@@ -1,13 +1,14 @@
using Jellyfin.Plugin.SRFPlay.Api;
using Jellyfin.Plugin.SRFPlay.Channels;
using Jellyfin.Plugin.SRFPlay.Providers;
using Jellyfin.Plugin.SRFPlay.ScheduledTasks;
using Jellyfin.Plugin.SRFPlay.Services;
using Jellyfin.Plugin.SRFPlay.Services.Interfaces;
using MediaBrowser.Controller;
using MediaBrowser.Controller.Channels;
using MediaBrowser.Controller.Plugins;
using MediaBrowser.Model.Tasks;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.SRFPlay;
@@ -19,13 +20,18 @@ public class ServiceRegistrator : IPluginServiceRegistrator
/// <inheritdoc />
public void RegisterServices(IServiceCollection serviceCollection, IServerApplicationHost applicationHost)
{
// Register services as singletons
serviceCollection.AddSingleton<MetadataCache>();
serviceCollection.AddSingleton<StreamUrlResolver>();
serviceCollection.AddSingleton<ContentExpirationService>();
serviceCollection.AddSingleton<ContentRefreshService>();
serviceCollection.AddSingleton<CategoryService>();
serviceCollection.AddSingleton<StreamProxyService>(); // Stream proxy service
// Register API client factory
serviceCollection.AddSingleton<ISRFApiClientFactory, SRFApiClientFactory>();
// Register core services with interfaces
serviceCollection.AddSingleton<IMetadataCache, MetadataCache>();
serviceCollection.AddSingleton<IStreamUrlResolver, StreamUrlResolver>();
serviceCollection.AddSingleton<IMediaCompositionFetcher, MediaCompositionFetcher>();
serviceCollection.AddSingleton<IStreamProxyService, StreamProxyService>();
serviceCollection.AddSingleton<IMediaSourceFactory, MediaSourceFactory>();
serviceCollection.AddSingleton<IContentExpirationService, ContentExpirationService>();
serviceCollection.AddSingleton<IContentRefreshService, ContentRefreshService>();
serviceCollection.AddSingleton<ICategoryService, CategoryService>();
// Register metadata providers
serviceCollection.AddSingleton<SRFSeriesProvider>();
@@ -6,6 +6,7 @@ using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Plugin.SRFPlay.Api;
using Jellyfin.Plugin.SRFPlay.Api.Models;
using Jellyfin.Plugin.SRFPlay.Services.Interfaces;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.SRFPlay.Services;
@@ -13,7 +14,7 @@ namespace Jellyfin.Plugin.SRFPlay.Services;
/// <summary>
/// Service for managing topic/category data and filtering.
/// </summary>
public class CategoryService
public class CategoryService : ICategoryService
{
private readonly ILogger _logger;
private readonly ILoggerFactory _loggerFactory;
@@ -3,10 +3,9 @@ using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Plugin.SRFPlay.Api;
using Jellyfin.Plugin.SRFPlay.Services.Interfaces;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Library;
using MediaBrowser.Model.IO;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.SRFPlay.Services;
@@ -14,13 +13,12 @@ namespace Jellyfin.Plugin.SRFPlay.Services;
/// <summary>
/// Service for managing content expiration.
/// </summary>
public class ContentExpirationService
public class ContentExpirationService : IContentExpirationService
{
private readonly ILogger<ContentExpirationService> _logger;
private readonly ILoggerFactory _loggerFactory;
private readonly ILibraryManager _libraryManager;
private readonly StreamUrlResolver _streamResolver;
private readonly MetadataCache _metadataCache;
private readonly IStreamUrlResolver _streamResolver;
private readonly IMediaCompositionFetcher _compositionFetcher;
/// <summary>
/// Initializes a new instance of the <see cref="ContentExpirationService"/> class.
@@ -28,18 +26,17 @@ public class ContentExpirationService
/// <param name="loggerFactory">The logger factory.</param>
/// <param name="libraryManager">The library manager.</param>
/// <param name="streamResolver">The stream URL resolver.</param>
/// <param name="metadataCache">The metadata cache.</param>
/// <param name="compositionFetcher">The media composition fetcher.</param>
public ContentExpirationService(
ILoggerFactory loggerFactory,
ILibraryManager libraryManager,
StreamUrlResolver streamResolver,
MetadataCache metadataCache)
IStreamUrlResolver streamResolver,
IMediaCompositionFetcher compositionFetcher)
{
_loggerFactory = loggerFactory;
_logger = loggerFactory.CreateLogger<ContentExpirationService>();
_libraryManager = libraryManager;
_streamResolver = streamResolver;
_metadataCache = metadataCache;
_compositionFetcher = compositionFetcher;
}
/// <summary>
@@ -119,26 +116,7 @@ public class ContentExpirationService
return false;
}
var config = Plugin.Instance?.Configuration;
if (config == null)
{
return false;
}
// Try cache first
var mediaComposition = _metadataCache.GetMediaComposition(urn, config.CacheDurationMinutes);
// If not in cache, fetch from API
if (mediaComposition == null)
{
using var apiClient = new SRFApiClient(_loggerFactory);
mediaComposition = await apiClient.GetMediaCompositionByUrnAsync(urn, cancellationToken).ConfigureAwait(false);
if (mediaComposition != null)
{
_metadataCache.SetMediaComposition(urn, mediaComposition);
}
}
var mediaComposition = await _compositionFetcher.GetMediaCompositionAsync(urn, cancellationToken).ConfigureAwait(false);
if (mediaComposition?.ChapterList == null || mediaComposition.ChapterList.Count == 0)
{
@@ -196,24 +174,7 @@ public class ContentExpirationService
continue;
}
var config = Plugin.Instance?.Configuration;
if (config == null)
{
continue;
}
var mediaComposition = _metadataCache.GetMediaComposition(urn, config.CacheDurationMinutes);
if (mediaComposition == null)
{
using var apiClient = new SRFApiClient(_loggerFactory);
mediaComposition = await apiClient.GetMediaCompositionByUrnAsync(urn, cancellationToken).ConfigureAwait(false);
if (mediaComposition != null)
{
_metadataCache.SetMediaComposition(urn, mediaComposition);
}
}
var mediaComposition = await _compositionFetcher.GetMediaCompositionAsync(urn, cancellationToken).ConfigureAwait(false);
if (mediaComposition?.ChapterList != null && mediaComposition.ChapterList.Count > 0)
{
@@ -4,7 +4,7 @@ using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Plugin.SRFPlay.Api;
using Jellyfin.Plugin.SRFPlay.Api.Models;
using Jellyfin.Plugin.SRFPlay.Services.Interfaces;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.SRFPlay.Services;
@@ -12,24 +12,22 @@ namespace Jellyfin.Plugin.SRFPlay.Services;
/// <summary>
/// Service for refreshing content from SRF API.
/// </summary>
public class ContentRefreshService
public class ContentRefreshService : IContentRefreshService
{
private readonly ILogger<ContentRefreshService> _logger;
private readonly ILoggerFactory _loggerFactory;
private readonly MetadataCache _metadataCache;
private readonly ISRFApiClientFactory _apiClientFactory;
/// <summary>
/// Initializes a new instance of the <see cref="ContentRefreshService"/> class.
/// </summary>
/// <param name="loggerFactory">The logger factory.</param>
/// <param name="metadataCache">The metadata cache.</param>
/// <param name="apiClientFactory">The API client factory.</param>
public ContentRefreshService(
ILoggerFactory loggerFactory,
MetadataCache metadataCache)
ISRFApiClientFactory apiClientFactory)
{
_loggerFactory = loggerFactory;
_logger = loggerFactory.CreateLogger<ContentRefreshService>();
_metadataCache = metadataCache;
_apiClientFactory = apiClientFactory;
}
/// <summary>
@@ -52,7 +50,7 @@ public class ContentRefreshService
_logger.LogInformation("Refreshing latest content for business unit: {BusinessUnit}", config.BusinessUnit);
using var apiClient = new SRFApiClient(_loggerFactory);
using var apiClient = _apiClientFactory.CreateClient();
var businessUnit = config.BusinessUnit.ToString().ToLowerInvariant();
// Get all shows from Play v3 API
@@ -167,7 +165,7 @@ public class ContentRefreshService
_logger.LogInformation("Refreshing trending content for business unit: {BusinessUnit}", config.BusinessUnit);
using var apiClient = new SRFApiClient(_loggerFactory);
using var apiClient = _apiClientFactory.CreateClient();
var businessUnit = config.BusinessUnit.ToString().ToLowerInvariant();
// Get all shows from Play v3 API
@@ -0,0 +1,70 @@
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Plugin.SRFPlay.Api.Models;
namespace Jellyfin.Plugin.SRFPlay.Services.Interfaces;
/// <summary>
/// Interface for managing topic/category data and filtering.
/// </summary>
public interface ICategoryService
{
/// <summary>
/// Gets all topics for a business unit.
/// </summary>
/// <param name="businessUnit">The business unit.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>List of topics.</returns>
Task<List<PlayV3Topic>> GetTopicsAsync(string businessUnit, CancellationToken cancellationToken = default);
/// <summary>
/// Gets a topic by ID.
/// </summary>
/// <param name="topicId">The topic ID.</param>
/// <param name="businessUnit">The business unit.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>The topic, or null if not found.</returns>
Task<PlayV3Topic?> GetTopicByIdAsync(string topicId, string businessUnit, CancellationToken cancellationToken = default);
/// <summary>
/// Filters shows by topic ID.
/// </summary>
/// <param name="shows">The shows to filter.</param>
/// <param name="topicId">The topic ID to filter by.</param>
/// <returns>Filtered list of shows.</returns>
IReadOnlyList<PlayV3Show> FilterShowsByTopic(IReadOnlyList<PlayV3Show> shows, string topicId);
/// <summary>
/// Groups shows by their topics.
/// </summary>
/// <param name="shows">The shows to group.</param>
/// <returns>Dictionary mapping topic IDs to shows.</returns>
IReadOnlyDictionary<string, List<PlayV3Show>> GroupShowsByTopics(IReadOnlyList<PlayV3Show> shows);
/// <summary>
/// Gets shows for a specific topic, sorted by number of episodes.
/// </summary>
/// <param name="topicId">The topic ID.</param>
/// <param name="businessUnit">The business unit.</param>
/// <param name="maxResults">Maximum number of results to return.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>List of shows for the topic.</returns>
Task<List<PlayV3Show>> GetShowsByTopicAsync(
string topicId,
string businessUnit,
int maxResults = 50,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets video count for each topic.
/// </summary>
/// <param name="shows">The shows to analyze.</param>
/// <returns>Dictionary mapping topic IDs to video counts.</returns>
IReadOnlyDictionary<string, int> GetVideoCountByTopic(IReadOnlyList<PlayV3Show> shows);
/// <summary>
/// Clears the topics cache.
/// </summary>
void ClearCache();
}
@@ -0,0 +1,24 @@
using System.Threading;
using System.Threading.Tasks;
namespace Jellyfin.Plugin.SRFPlay.Services.Interfaces;
/// <summary>
/// Interface for managing content expiration.
/// </summary>
public interface IContentExpirationService
{
/// <summary>
/// Checks for expired content and removes it from the library.
/// </summary>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>The number of items removed.</returns>
Task<int> CheckAndRemoveExpiredContentAsync(CancellationToken cancellationToken);
/// <summary>
/// Gets statistics about content expiration.
/// </summary>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>Tuple with total count, expired count, and items expiring soon.</returns>
Task<(int Total, int Expired, int ExpiringSoon)> GetExpirationStatisticsAsync(CancellationToken cancellationToken);
}
@@ -0,0 +1,39 @@
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
namespace Jellyfin.Plugin.SRFPlay.Services.Interfaces;
/// <summary>
/// Interface for refreshing content from SRF API.
/// </summary>
public interface IContentRefreshService
{
/// <summary>
/// Refreshes latest content from SRF API using Play v3.
/// </summary>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>List of URNs for new content.</returns>
Task<List<string>> RefreshLatestContentAsync(CancellationToken cancellationToken);
/// <summary>
/// Refreshes trending content from SRF API using Play v3.
/// </summary>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>List of URNs for trending content.</returns>
Task<List<string>> RefreshTrendingContentAsync(CancellationToken cancellationToken);
/// <summary>
/// Refreshes all content (latest and trending).
/// </summary>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>Tuple with counts of latest and trending items.</returns>
Task<(int LatestCount, int TrendingCount)> RefreshAllContentAsync(CancellationToken cancellationToken);
/// <summary>
/// Gets content recommendations (combines latest and trending).
/// </summary>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>List of recommended URNs.</returns>
Task<List<string>> GetRecommendedContentAsync(CancellationToken cancellationToken);
}
@@ -0,0 +1,23 @@
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Plugin.SRFPlay.Api.Models;
namespace Jellyfin.Plugin.SRFPlay.Services.Interfaces;
/// <summary>
/// Interface for fetching media composition with caching support.
/// </summary>
public interface IMediaCompositionFetcher
{
/// <summary>
/// Gets media composition by URN, using cache if available.
/// </summary>
/// <param name="urn">The URN to fetch.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <param name="cacheDurationOverride">Optional override for cache duration (e.g., 5 min for livestreams).</param>
/// <returns>The media composition, or null if not found.</returns>
Task<MediaComposition?> GetMediaCompositionAsync(
string urn,
CancellationToken cancellationToken,
int? cacheDurationOverride = null);
}
@@ -0,0 +1,51 @@
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Plugin.SRFPlay.Api.Models;
using Jellyfin.Plugin.SRFPlay.Configuration;
using MediaBrowser.Model.Dto;
using MediaBrowser.Model.Entities;
namespace Jellyfin.Plugin.SRFPlay.Services.Interfaces;
/// <summary>
/// Factory for creating MediaSourceInfo objects with consistent configuration.
/// </summary>
public interface IMediaSourceFactory
{
/// <summary>
/// Creates a MediaSourceInfo for a chapter with proper stream authentication and proxy registration.
/// </summary>
/// <param name="chapter">The chapter containing stream resources.</param>
/// <param name="itemId">The unique item ID for proxy registration.</param>
/// <param name="urn">The URN of the content.</param>
/// <param name="qualityPreference">The preferred quality.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>A configured MediaSourceInfo, or null if stream URL cannot be resolved.</returns>
Task<MediaSourceInfo?> CreateMediaSourceAsync(
Chapter chapter,
string itemId,
string urn,
QualityPreference qualityPreference,
CancellationToken cancellationToken = default);
/// <summary>
/// Builds a proxy URL for an item.
/// </summary>
/// <param name="itemId">The item ID.</param>
/// <returns>The full proxy URL including server address.</returns>
string BuildProxyUrl(string itemId);
/// <summary>
/// Gets the server base URL (configured public URL or smart URL).
/// </summary>
/// <returns>The server base URL without trailing slash.</returns>
string GetServerBaseUrl();
/// <summary>
/// Creates MediaStream metadata based on quality preference.
/// </summary>
/// <param name="quality">The quality preference.</param>
/// <returns>List of MediaStream objects for video and audio.</returns>
IReadOnlyList<MediaStream> CreateMediaStreams(QualityPreference quality);
}
@@ -0,0 +1,41 @@
using Jellyfin.Plugin.SRFPlay.Api.Models;
namespace Jellyfin.Plugin.SRFPlay.Services.Interfaces;
/// <summary>
/// Interface for caching metadata from SRF API.
/// </summary>
public interface IMetadataCache
{
/// <summary>
/// Gets cached media composition by URN.
/// </summary>
/// <param name="urn">The URN.</param>
/// <param name="cacheDurationMinutes">The cache duration in minutes.</param>
/// <returns>The cached media composition, or null if not found or expired.</returns>
MediaComposition? GetMediaComposition(string urn, int cacheDurationMinutes);
/// <summary>
/// Sets media composition in cache.
/// </summary>
/// <param name="urn">The URN.</param>
/// <param name="mediaComposition">The media composition to cache.</param>
void SetMediaComposition(string urn, MediaComposition mediaComposition);
/// <summary>
/// Removes media composition from cache.
/// </summary>
/// <param name="urn">The URN.</param>
void RemoveMediaComposition(string urn);
/// <summary>
/// Clears all cached data.
/// </summary>
void Clear();
/// <summary>
/// Gets the cache statistics.
/// </summary>
/// <returns>A tuple with cache count and size estimate.</returns>
(int Count, long SizeEstimate) GetStatistics();
}
@@ -0,0 +1,66 @@
using System.Threading;
using System.Threading.Tasks;
namespace Jellyfin.Plugin.SRFPlay.Services.Interfaces;
/// <summary>
/// Interface for proxying SRF Play streams and managing authentication.
/// </summary>
public interface IStreamProxyService
{
/// <summary>
/// Registers a stream for proxying with an already-authenticated URL.
/// </summary>
/// <param name="itemId">The item ID.</param>
/// <param name="authenticatedUrl">The authenticated stream URL.</param>
/// <param name="urn">The SRF URN for this content (used for re-fetching fresh URLs).</param>
/// <param name="isLiveStream">Whether this is a livestream (livestreams always fetch fresh URLs).</param>
void RegisterStream(string itemId, string authenticatedUrl, string? urn = null, bool isLiveStream = false);
/// <summary>
/// Registers a stream for deferred authentication (authenticates on first playback request).
/// Use this when browsing to avoid wasting 30-second tokens before the user clicks play.
/// </summary>
/// <param name="itemId">The item ID.</param>
/// <param name="unauthenticatedUrl">The unauthenticated stream URL.</param>
/// <param name="urn">The SRF URN for this content.</param>
/// <param name="isLiveStream">Whether this is a livestream.</param>
void RegisterStreamDeferred(string itemId, string unauthenticatedUrl, string? urn = null, bool isLiveStream = false);
/// <summary>
/// Gets stream metadata for an item (URN and isLiveStream flag).
/// </summary>
/// <param name="itemId">The item ID.</param>
/// <returns>A tuple of (URN, IsLiveStream), or null if not found.</returns>
(string? Urn, bool IsLiveStream)? GetStreamMetadata(string itemId);
/// <summary>
/// Gets the authenticated URL for an item.
/// </summary>
/// <param name="itemId">The item ID.</param>
/// <returns>The authenticated URL, or null if not found or expired.</returns>
string? GetAuthenticatedUrl(string itemId);
/// <summary>
/// Fetches and rewrites an HLS manifest to use proxy URLs.
/// </summary>
/// <param name="itemId">The item ID.</param>
/// <param name="baseProxyUrl">The base proxy URL.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The rewritten manifest content.</returns>
Task<string?> GetRewrittenManifestAsync(string itemId, string baseProxyUrl, CancellationToken cancellationToken = default);
/// <summary>
/// Fetches a segment from the original source.
/// </summary>
/// <param name="itemId">The item ID.</param>
/// <param name="segmentPath">The segment path.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The segment content as bytes.</returns>
Task<byte[]?> GetSegmentAsync(string itemId, string segmentPath, CancellationToken cancellationToken = default);
/// <summary>
/// Cleans up old and expired stream mappings.
/// </summary>
void CleanupOldMappings();
}
@@ -0,0 +1,42 @@
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Plugin.SRFPlay.Api.Models;
using Jellyfin.Plugin.SRFPlay.Configuration;
namespace Jellyfin.Plugin.SRFPlay.Services.Interfaces;
/// <summary>
/// Interface for resolving stream URLs from media composition resources.
/// </summary>
public interface IStreamUrlResolver
{
/// <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>
string? GetStreamUrl(Chapter chapter, QualityPreference qualityPreference);
/// <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>
bool HasPlayableContent(Chapter chapter);
/// <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>
bool IsContentExpired(Chapter chapter);
/// <summary>
/// Authenticates a stream URL by fetching an Akamai token.
/// </summary>
/// <param name="streamUrl">The unauthenticated stream URL.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The authenticated stream URL with token.</returns>
Task<string> GetAuthenticatedStreamUrlAsync(string streamUrl, CancellationToken cancellationToken = default);
}
@@ -0,0 +1,82 @@
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Plugin.SRFPlay.Api;
using Jellyfin.Plugin.SRFPlay.Api.Models;
using Jellyfin.Plugin.SRFPlay.Services.Interfaces;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.SRFPlay.Services;
/// <summary>
/// Service for fetching media composition with caching support.
/// This consolidates the cache-check → API-fetch → cache-store pattern.
/// </summary>
public class MediaCompositionFetcher : IMediaCompositionFetcher
{
private readonly ILogger<MediaCompositionFetcher> _logger;
private readonly IMetadataCache _metadataCache;
private readonly ISRFApiClientFactory _apiClientFactory;
/// <summary>
/// Initializes a new instance of the <see cref="MediaCompositionFetcher"/> class.
/// </summary>
/// <param name="logger">The logger.</param>
/// <param name="metadataCache">The metadata cache.</param>
/// <param name="apiClientFactory">The API client factory.</param>
public MediaCompositionFetcher(
ILogger<MediaCompositionFetcher> logger,
IMetadataCache metadataCache,
ISRFApiClientFactory apiClientFactory)
{
_logger = logger;
_metadataCache = metadataCache;
_apiClientFactory = apiClientFactory;
}
/// <inheritdoc />
public async Task<MediaComposition?> GetMediaCompositionAsync(
string urn,
CancellationToken cancellationToken,
int? cacheDurationOverride = null)
{
if (string.IsNullOrEmpty(urn))
{
_logger.LogDebug("GetMediaCompositionAsync called with null/empty URN");
return null;
}
var config = Plugin.Instance?.Configuration;
if (config == null)
{
_logger.LogWarning("Plugin configuration is null, cannot fetch media composition");
return null;
}
var cacheDuration = cacheDurationOverride ?? config.CacheDurationMinutes;
// Try cache first
var mediaComposition = _metadataCache.GetMediaComposition(urn, cacheDuration);
if (mediaComposition != null)
{
_logger.LogDebug("Cache hit for URN: {Urn}", urn);
return mediaComposition;
}
// Fetch from API
_logger.LogDebug("Cache miss for URN: {Urn}, fetching from API", urn);
using var apiClient = _apiClientFactory.CreateClient();
mediaComposition = await apiClient.GetMediaCompositionByUrnAsync(urn, cancellationToken).ConfigureAwait(false);
if (mediaComposition != null)
{
_metadataCache.SetMediaComposition(urn, mediaComposition);
_logger.LogDebug("Cached media composition for URN: {Urn}", urn);
}
else
{
_logger.LogWarning("Failed to fetch media composition for URN: {Urn}", urn);
}
return mediaComposition;
}
}
@@ -0,0 +1,173 @@
using System;
using System.Collections.Generic;
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.Controller;
using MediaBrowser.Model.Dto;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.MediaInfo;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.SRFPlay.Services;
/// <summary>
/// Factory for creating MediaSourceInfo objects with consistent configuration.
/// Consolidates duplicated logic from SRFMediaProvider and SRFPlayChannel.
/// </summary>
public class MediaSourceFactory : IMediaSourceFactory
{
private readonly ILogger<MediaSourceFactory> _logger;
private readonly IStreamUrlResolver _streamResolver;
private readonly IStreamProxyService _proxyService;
private readonly IServerApplicationHost _appHost;
/// <summary>
/// Initializes a new instance of the <see cref="MediaSourceFactory"/> class.
/// </summary>
/// <param name="logger">The logger.</param>
/// <param name="streamResolver">The stream URL resolver.</param>
/// <param name="proxyService">The stream proxy service.</param>
/// <param name="appHost">The server application host.</param>
public MediaSourceFactory(
ILogger<MediaSourceFactory> logger,
IStreamUrlResolver streamResolver,
IStreamProxyService proxyService,
IServerApplicationHost appHost)
{
_logger = logger;
_streamResolver = streamResolver;
_proxyService = proxyService;
_appHost = appHost;
}
/// <inheritdoc />
public Task<MediaSourceInfo?> CreateMediaSourceAsync(
Chapter chapter,
string itemId,
string urn,
QualityPreference qualityPreference,
CancellationToken cancellationToken = default)
{
// Get stream URL based on quality preference (unauthenticated)
var streamUrl = _streamResolver.GetStreamUrl(chapter, qualityPreference);
if (string.IsNullOrEmpty(streamUrl))
{
_logger.LogWarning("Could not resolve stream URL for chapter: {ChapterId}", chapter.Id);
return Task.FromResult<MediaSourceInfo?>(null);
}
// Detect if this is a live stream
var isLiveStream = chapter.Type == "SCHEDULED_LIVESTREAM" ||
urn.Contains("livestream", StringComparison.OrdinalIgnoreCase);
// Register stream with UNAUTHENTICATED URL - proxy will authenticate on-demand
// This avoids wasting 30-second tokens during category browsing
_proxyService.RegisterStreamDeferred(itemId, streamUrl, urn, isLiveStream);
// Build proxy URL
var proxyUrl = BuildProxyUrl(itemId);
_logger.LogDebug(
"Created media source for {Title} - ItemId: {ItemId}, IsLiveStream: {IsLiveStream}",
chapter.Title,
itemId,
isLiveStream);
// Create MediaSourceInfo with minimal settings to let clients determine playback
// Don't specify Container or MediaStreams - let the .m3u8 path trigger HLS detection
var mediaSource = new MediaSourceInfo
{
Id = itemId,
Name = chapter.Title,
Path = proxyUrl,
Protocol = MediaProtocol.Http,
// Empty container - let clients detect HLS from .m3u8 extension
Container = string.Empty,
SupportsDirectStream = true,
SupportsDirectPlay = true,
SupportsTranscoding = false,
IsRemote = true,
Type = MediaSourceType.Default,
RunTimeTicks = chapter.Duration > 0 ? TimeSpan.FromMilliseconds(chapter.Duration).Ticks : null,
VideoType = VideoType.VideoFile,
IsInfiniteStream = isLiveStream,
RequiresOpening = false,
RequiresClosing = false,
SupportsProbing = false,
ReadAtNativeFramerate = isLiveStream,
// Don't specify MediaStreams - let client determine codec compatibility
MediaStreams = new List<MediaStream>(),
// Reduce analyze duration for faster startup (3000ms is Jellyfin default, 1000ms for live)
AnalyzeDurationMs = isLiveStream ? 1000 : 3000,
// Ignore DTS timestamps for live streams to avoid sync issues
IgnoreDts = isLiveStream,
// Ignore index for live streams
IgnoreIndex = isLiveStream,
};
return Task.FromResult<MediaSourceInfo?>(mediaSource);
}
/// <inheritdoc />
public string BuildProxyUrl(string itemId)
{
return $"{GetServerBaseUrl()}{ApiEndpoints.ProxyMasterManifestPath.Replace("{0}", itemId, StringComparison.Ordinal)}";
}
/// <inheritdoc />
public string GetServerBaseUrl()
{
var config = Plugin.Instance?.Configuration;
return config != null && !string.IsNullOrWhiteSpace(config.PublicServerUrl)
? config.PublicServerUrl.TrimEnd('/')
: _appHost.GetSmartApiUrl(string.Empty);
}
/// <inheritdoc />
public IReadOnlyList<MediaStream> CreateMediaStreams(QualityPreference quality)
{
// Set resolution/bitrate based on quality preference
var (width, height, videoBitrate) = quality switch
{
QualityPreference.SD => (1280, 720, 2500000),
QualityPreference.HD => (1920, 1080, 5000000),
_ => (1280, 720, 3000000)
};
return new List<MediaStream>
{
new MediaStream
{
Type = MediaStreamType.Video,
Codec = "h264",
Profile = "high",
Level = 40,
Width = width,
Height = height,
BitRate = videoBitrate,
BitDepth = 8,
IsInterlaced = false,
IsDefault = true,
Index = 0,
IsAVC = true,
PixelFormat = "yuv420p"
},
new MediaStream
{
Type = MediaStreamType.Audio,
Codec = "aac",
Profile = "LC",
Channels = 2,
SampleRate = 48000,
BitRate = 128000,
IsDefault = true,
Index = 1
}
};
}
}
@@ -2,6 +2,7 @@ using System;
using System.Collections.Concurrent;
using System.Threading;
using Jellyfin.Plugin.SRFPlay.Api.Models;
using Jellyfin.Plugin.SRFPlay.Services.Interfaces;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.SRFPlay.Services;
@@ -9,7 +10,7 @@ namespace Jellyfin.Plugin.SRFPlay.Services;
/// <summary>
/// Service for caching metadata from SRF API.
/// </summary>
public sealed class MetadataCache : IDisposable
public sealed class MetadataCache : IMetadataCache, IDisposable
{
private readonly ILogger<MetadataCache> _logger;
private readonly ConcurrentDictionary<string, CacheEntry<MediaComposition>> _mediaCompositionCache;
@@ -1,11 +1,15 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using System.Web;
using Jellyfin.Plugin.SRFPlay.Api;
using Jellyfin.Plugin.SRFPlay.Configuration;
using Jellyfin.Plugin.SRFPlay.Services.Interfaces;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.SRFPlay.Services;
@@ -13,10 +17,11 @@ namespace Jellyfin.Plugin.SRFPlay.Services;
/// <summary>
/// Service for proxying SRF Play streams and managing authentication.
/// </summary>
public class StreamProxyService : IDisposable
public class StreamProxyService : IStreamProxyService, IDisposable
{
private readonly ILogger<StreamProxyService> _logger;
private readonly StreamUrlResolver _streamResolver;
private readonly IStreamUrlResolver _streamResolver;
private readonly IMediaCompositionFetcher _compositionFetcher;
private readonly HttpClient _httpClient;
private readonly ConcurrentDictionary<string, StreamInfo> _streamMappings;
private bool _disposed;
@@ -26,10 +31,15 @@ public class StreamProxyService : IDisposable
/// </summary>
/// <param name="logger">The logger.</param>
/// <param name="streamResolver">The stream URL resolver.</param>
public StreamProxyService(ILogger<StreamProxyService> logger, StreamUrlResolver streamResolver)
/// <param name="compositionFetcher">The media composition fetcher.</param>
public StreamProxyService(
ILogger<StreamProxyService> logger,
IStreamUrlResolver streamResolver,
IMediaCompositionFetcher compositionFetcher)
{
_logger = logger;
_streamResolver = streamResolver;
_compositionFetcher = compositionFetcher;
_httpClient = new HttpClient
{
Timeout = TimeSpan.FromSeconds(30)
@@ -42,7 +52,9 @@ public class StreamProxyService : IDisposable
/// </summary>
/// <param name="itemId">The item ID.</param>
/// <param name="authenticatedUrl">The authenticated stream URL.</param>
public void RegisterStream(string itemId, string authenticatedUrl)
/// <param name="urn">The SRF URN for this content (used for re-fetching fresh URLs).</param>
/// <param name="isLiveStream">Whether this is a livestream (livestreams always fetch fresh URLs).</param>
public void RegisterStream(string itemId, string authenticatedUrl, string? urn = null, bool isLiveStream = false)
{
var tokenExpiry = ExtractTokenExpiry(authenticatedUrl);
var unauthenticatedUrl = StripAuthenticationFromUrl(authenticatedUrl);
@@ -52,7 +64,10 @@ public class StreamProxyService : IDisposable
AuthenticatedUrl = authenticatedUrl,
UnauthenticatedUrl = unauthenticatedUrl,
RegisteredAt = DateTime.UtcNow,
TokenExpiresAt = tokenExpiry
TokenExpiresAt = tokenExpiry,
Urn = urn,
IsLiveStream = isLiveStream,
LastLivestreamFetchAt = isLiveStream ? DateTime.UtcNow : null
};
// Register with the provided item ID
@@ -93,6 +108,85 @@ public class StreamProxyService : IDisposable
}
}
/// <summary>
/// Registers a stream for deferred authentication (authenticates on first playback request).
/// </summary>
/// <param name="itemId">The item ID.</param>
/// <param name="unauthenticatedUrl">The unauthenticated stream URL.</param>
/// <param name="urn">The SRF URN for this content.</param>
/// <param name="isLiveStream">Whether this is a livestream.</param>
public void RegisterStreamDeferred(string itemId, string unauthenticatedUrl, string? urn = null, bool isLiveStream = false)
{
var streamInfo = new StreamInfo
{
AuthenticatedUrl = string.Empty, // Will be populated on first access
UnauthenticatedUrl = unauthenticatedUrl,
RegisteredAt = DateTime.UtcNow,
TokenExpiresAt = null,
Urn = urn,
IsLiveStream = isLiveStream,
LastLivestreamFetchAt = null,
NeedsAuthentication = true
};
// Register with the provided item ID
_streamMappings.AddOrUpdate(itemId, streamInfo, (key, old) => streamInfo);
// Also register with alternative GUID formats
if (Guid.TryParse(itemId, out var guid))
{
var formats = new[]
{
guid.ToString("N"),
guid.ToString("D"),
guid.ToString("B"),
};
foreach (var format in formats)
{
if (format != itemId)
{
_streamMappings.AddOrUpdate(format, streamInfo, (key, old) => streamInfo);
}
}
}
_logger.LogDebug(
"Registered deferred stream for item {ItemId} (URN: {Urn}, will authenticate on first access)",
itemId,
urn ?? "null");
}
/// <summary>
/// Gets stream metadata for an item (URN and isLiveStream flag).
/// Used when propagating stream registration to transcoding sessions.
/// </summary>
/// <param name="itemId">The item ID.</param>
/// <returns>A tuple of (URN, IsLiveStream), or null if not found.</returns>
public (string? Urn, bool IsLiveStream)? GetStreamMetadata(string itemId)
{
if (_streamMappings.TryGetValue(itemId, out var streamInfo))
{
return (streamInfo.Urn, streamInfo.IsLiveStream);
}
// Try GUID normalization
var normalizedId = NormalizeGuid(itemId);
if (normalizedId != null)
{
foreach (var kvp in _streamMappings)
{
var normalizedKey = NormalizeGuid(kvp.Key);
if (normalizedKey != null && normalizedKey == normalizedId)
{
return (kvp.Value.Urn, kvp.Value.IsLiveStream);
}
}
}
return null;
}
/// <summary>
/// Gets the authenticated URL for an item.
/// </summary>
@@ -105,7 +199,36 @@ public class StreamProxyService : IDisposable
// Try direct lookup first
if (_streamMappings.TryGetValue(itemId, out var streamInfo))
{
_logger.LogInformation("✅ Found stream by direct lookup for itemId: {ItemId}", itemId);
// Log detailed StreamInfo state to diagnose stale alias issues
var tokenTimeLeft = streamInfo.TokenExpiresAt.HasValue
? (streamInfo.TokenExpiresAt.Value - DateTime.UtcNow).TotalSeconds
: -1;
_logger.LogInformation(
"✅ Found stream by direct lookup for itemId: {ItemId} - NeedsAuth={NeedsAuth}, IsLive={IsLive}, Urn={Urn}, TokenLeft={TokenLeft:F0}s, AuthUrl={HasAuth}",
itemId,
streamInfo.NeedsAuthentication,
streamInfo.IsLiveStream,
string.IsNullOrEmpty(streamInfo.Urn) ? "(empty)" : "set",
tokenTimeLeft,
!string.IsNullOrEmpty(streamInfo.AuthenticatedUrl));
// Check for stale alias: only look for fresher stream if current token is EXPIRED or EXPIRING SOON
// Don't replace a valid token (>5s left) with a new deferred registration
if (!streamInfo.NeedsAuthentication && tokenTimeLeft < 5)
{
var freshStream = FindFreshestStream();
if (freshStream != null && freshStream.Value.Value.NeedsAuthentication)
{
_logger.LogWarning(
"Token expiring soon ({TokenLeft:F0}s), switching to fresher deferred stream {ItemId} -> {FreshKey}",
tokenTimeLeft,
itemId,
freshStream.Value.Key);
_streamMappings.AddOrUpdate(itemId, freshStream.Value.Value, (key, old) => freshStream.Value.Value);
return ValidateAndReturnStream(itemId, freshStream.Value.Value);
}
}
return ValidateAndReturnStream(itemId, streamInfo);
}
@@ -151,10 +274,14 @@ public class StreamProxyService : IDisposable
if (activeStreams.Count == 1)
{
_logger.LogWarning(
"No exact match for {RequestedId}, but found single active stream {RegisteredId} - using as fallback",
_logger.LogInformation(
"Transcoding session detected: Aliasing {TranscodingId} -> {OriginalId} (single active stream)",
itemId,
activeStreams[0].Key);
// Register the transcoding session ID as an alias (update if stale alias exists)
_streamMappings.AddOrUpdate(itemId, activeStreams[0].Value, (key, old) => activeStreams[0].Value);
return ValidateAndReturnStream(activeStreams[0].Key, activeStreams[0].Value);
}
@@ -169,11 +296,15 @@ public class StreamProxyService : IDisposable
// This indicates it's likely the stream currently being set up for transcoding
if (age.TotalSeconds < 30)
{
_logger.LogWarning(
"No exact match for {RequestedId}, but using most recently registered stream {RegisteredId} (registered {Seconds}s ago) as fallback",
_logger.LogInformation(
"Transcoding session detected: Aliasing {TranscodingId} -> {OriginalId} (registered {Seconds:F1}s ago)",
itemId,
mostRecent.Key,
age.TotalSeconds);
// Register the transcoding session ID as an alias (update if stale alias exists)
_streamMappings.AddOrUpdate(itemId, mostRecent.Value, (key, old) => mostRecent.Value);
return ValidateAndReturnStream(mostRecent.Key, mostRecent.Value);
}
}
@@ -191,14 +322,82 @@ public class StreamProxyService : IDisposable
/// </summary>
private string? ValidateAndReturnStream(string itemId, StreamInfo streamInfo)
{
// Check if token has expired
// Handle deferred authentication (first playback after browsing)
if (streamInfo.NeedsAuthentication)
{
_logger.LogInformation(
"First playback for item {ItemId} - authenticating stream on-demand",
itemId);
var authenticatedUrl = AuthenticateOnDemand(itemId, streamInfo);
if (authenticatedUrl != null)
{
return authenticatedUrl;
}
_logger.LogWarning("Failed to authenticate stream on-demand for item {ItemId}", itemId);
return null;
}
// For livestreams, use smart caching to avoid hammering the API
// Only fetch fresh if token is expiring soon or hasn't been fetched recently
if (streamInfo.IsLiveStream && !string.IsNullOrEmpty(streamInfo.Urn))
{
var now = DateTime.UtcNow;
var tokenTimeLeft = streamInfo.TokenExpiresAt.HasValue
? (streamInfo.TokenExpiresAt.Value - now).TotalSeconds
: 30; // Assume 30s if no expiry
var timeSinceLastFetch = streamInfo.LastLivestreamFetchAt.HasValue
? (now - streamInfo.LastLivestreamFetchAt.Value).TotalSeconds
: double.MaxValue;
// Use cached URL if: token has >10s left AND we fetched within last 15 seconds
if (tokenTimeLeft > 10 && timeSinceLastFetch < 15)
{
_logger.LogDebug(
"Livestream {ItemId}: Using cached URL (token expires in {TokenTimeLeft:F0}s, last fetch {TimeSinceFetch:F0}s ago)",
itemId,
tokenTimeLeft,
timeSinceLastFetch);
return streamInfo.AuthenticatedUrl;
}
_logger.LogInformation(
"Livestream {ItemId}: Fetching fresh URL (token expires in {TokenTimeLeft:F0}s, last fetch {TimeSinceFetch:F0}s ago)",
itemId,
tokenTimeLeft,
timeSinceLastFetch);
var freshUrl = FetchFreshStreamUrl(itemId, streamInfo);
if (freshUrl != null)
{
return freshUrl;
}
_logger.LogWarning("Failed to fetch fresh URL for livestream {ItemId}, falling back to cached URL", itemId);
// Fall through to use cached URL as fallback
}
// Check if token has expired or is about to expire
if (streamInfo.TokenExpiresAt.HasValue)
{
var now = DateTime.UtcNow;
if (now >= streamInfo.TokenExpiresAt.Value)
var timeUntilExpiry = streamInfo.TokenExpiresAt.Value - now;
// Proactive refresh: refresh if token has expired OR will expire within 5 seconds
// This prevents race conditions during rapid segment fetching in transcoding
var shouldRefresh = now >= streamInfo.TokenExpiresAt.Value || timeUntilExpiry.TotalSeconds <= 5;
if (shouldRefresh)
{
var reason = now >= streamInfo.TokenExpiresAt.Value
? "expired"
: $"expiring in {timeUntilExpiry.TotalSeconds:F1}s";
_logger.LogWarning(
"Token expired for item {ItemId} (expired at {ExpiresAt}, now is {Now}) - attempting to refresh",
"Token {Reason} for item {ItemId} (expires at {ExpiresAt}, now is {Now}) - attempting to refresh",
reason,
itemId,
streamInfo.TokenExpiresAt.Value,
now);
@@ -217,15 +416,73 @@ public class StreamProxyService : IDisposable
}
_logger.LogDebug(
"Token valid for item {ItemId} (expires at {ExpiresAt}, {TimeLeft} remaining)",
"Token valid for item {ItemId} (expires at {ExpiresAt}, {TimeLeft:F1}s remaining)",
itemId,
streamInfo.TokenExpiresAt.Value,
streamInfo.TokenExpiresAt.Value - now);
timeUntilExpiry.TotalSeconds);
}
return streamInfo.AuthenticatedUrl;
}
/// <summary>
/// Fetches a fresh stream URL from the SRF API for livestreams.
/// </summary>
private string? FetchFreshStreamUrl(string itemId, StreamInfo streamInfo)
{
if (string.IsNullOrEmpty(streamInfo.Urn))
{
return null;
}
try
{
// Use short cache duration (5 min) for livestreams
var mediaComposition = _compositionFetcher.GetMediaCompositionAsync(streamInfo.Urn, CancellationToken.None, 5)
.GetAwaiter().GetResult();
if (mediaComposition?.ChapterList == null || mediaComposition.ChapterList.Count == 0)
{
_logger.LogWarning("No chapters found when refreshing livestream URL for URN: {Urn}", streamInfo.Urn);
return null;
}
var chapter = mediaComposition.ChapterList[0];
var config = Plugin.Instance?.Configuration ?? new PluginConfiguration();
var streamUrl = _streamResolver.GetStreamUrl(chapter, config.QualityPreference);
if (string.IsNullOrEmpty(streamUrl))
{
_logger.LogWarning("No stream URL found when refreshing livestream for URN: {Urn}", streamInfo.Urn);
return null;
}
// Authenticate the fresh URL
var authenticatedUrl = _streamResolver.GetAuthenticatedStreamUrlAsync(streamUrl, CancellationToken.None)
.GetAwaiter().GetResult();
// Update the stored stream info with the fresh data
var newTokenExpiry = ExtractTokenExpiry(authenticatedUrl);
streamInfo.AuthenticatedUrl = authenticatedUrl;
streamInfo.UnauthenticatedUrl = StripAuthenticationFromUrl(authenticatedUrl);
streamInfo.TokenExpiresAt = newTokenExpiry;
streamInfo.LastLivestreamFetchAt = DateTime.UtcNow;
_logger.LogInformation(
"Fetched fresh livestream URL for item {ItemId} (URN: {Urn}, new expiry: {Expiry})",
itemId,
streamInfo.Urn,
newTokenExpiry);
return authenticatedUrl;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error fetching fresh stream URL for livestream {ItemId} (URN: {Urn})", itemId, streamInfo.Urn);
return null;
}
}
/// <summary>
/// Attempts to refresh an expired token.
/// </summary>
@@ -268,6 +525,54 @@ public class StreamProxyService : IDisposable
}
}
/// <summary>
/// Authenticates a stream on-demand (first playback after browsing).
/// </summary>
private string? AuthenticateOnDemand(string itemId, StreamInfo streamInfo)
{
if (string.IsNullOrEmpty(streamInfo.UnauthenticatedUrl))
{
_logger.LogWarning("Cannot authenticate on-demand for {ItemId} - no unauthenticated URL stored", itemId);
return null;
}
try
{
// Authenticate the stream URL
var authenticatedUrl = _streamResolver.GetAuthenticatedStreamUrlAsync(
streamInfo.UnauthenticatedUrl,
CancellationToken.None).GetAwaiter().GetResult();
if (string.IsNullOrEmpty(authenticatedUrl))
{
return null;
}
// Update the stream info - no longer needs authentication
var tokenExpiry = ExtractTokenExpiry(authenticatedUrl);
streamInfo.AuthenticatedUrl = authenticatedUrl;
streamInfo.TokenExpiresAt = tokenExpiry;
streamInfo.NeedsAuthentication = false;
if (streamInfo.IsLiveStream)
{
streamInfo.LastLivestreamFetchAt = DateTime.UtcNow;
}
_logger.LogInformation(
"Authenticated stream on-demand for item {ItemId} (expires at {ExpiresAt} UTC)",
itemId,
tokenExpiry);
return authenticatedUrl;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error authenticating stream on-demand for item {ItemId}", itemId);
return null;
}
}
/// <summary>
/// Strips authentication parameters from a URL to get the base unauthenticated URL.
/// </summary>
@@ -317,6 +622,41 @@ public class StreamProxyService : IDisposable
return null;
}
/// <summary>
/// Finds the freshest (most recently registered) stream that needs authentication or has a valid token.
/// </summary>
/// <returns>The freshest stream entry, or null if none found.</returns>
private KeyValuePair<string, StreamInfo>? FindFreshestStream()
{
var now = DateTime.UtcNow;
// Find streams that either need authentication (fresh deferred registration)
// or have tokens that aren't expired yet
var candidates = _streamMappings.Where(kvp =>
{
if (kvp.Value.NeedsAuthentication)
{
return true; // Fresh deferred registration
}
if (!kvp.Value.TokenExpiresAt.HasValue)
{
return true; // No expiry
}
// Token not expired yet
return now < kvp.Value.TokenExpiresAt.Value;
}).ToList();
if (candidates.Count == 0)
{
return null;
}
// Prefer the most recently registered stream
return candidates.OrderByDescending(kvp => kvp.Value.RegisteredAt).First();
}
/// <summary>
/// Fetches and rewrites an HLS manifest to use proxy URLs.
/// </summary>
@@ -337,13 +677,15 @@ public class StreamProxyService : IDisposable
try
{
_logger.LogDebug("Fetching manifest from: {Url}", authenticatedUrl);
_logger.LogInformation("Fetching manifest from: {Url}", authenticatedUrl);
var manifestContent = await _httpClient.GetStringAsync(authenticatedUrl, cancellationToken).ConfigureAwait(false);
_logger.LogInformation("Original manifest ({Length} bytes):\n{Content}", manifestContent.Length, manifestContent);
// Rewrite the manifest to replace Akamai URLs with proxy URLs
var rewrittenContent = RewriteManifestUrls(manifestContent, authenticatedUrl, baseProxyUrl);
_logger.LogDebug("Successfully rewrote manifest for item {ItemId}", itemId);
_logger.LogInformation("Rewritten manifest for item {ItemId} ({Length} bytes):\n{Content}", itemId, rewrittenContent.Length, rewrittenContent);
return rewrittenContent;
}
catch (Exception ex)
@@ -383,7 +725,12 @@ public class StreamProxyService : IDisposable
// Build full segment URL
var segmentUrl = $"{baseUrl}/{segmentPath}{queryParams}";
_logger.LogDebug("Fetching segment: {SegmentUrl}", segmentUrl);
_logger.LogInformation(
"Fetching segment - BaseUri: {BaseUri}, BaseUrl: {BaseUrl}, SegmentPath: {SegmentPath}, FullUrl: {FullUrl}",
authenticatedUrl,
baseUrl,
segmentPath,
segmentUrl);
var segmentData = await _httpClient.GetByteArrayAsync(segmentUrl, cancellationToken).ConfigureAwait(false);
_logger.LogDebug("Successfully fetched segment {SegmentPath} ({Size} bytes)", segmentPath, segmentData.Length);
@@ -418,24 +765,51 @@ public class StreamProxyService : IDisposable
_logger.LogDebug("Extracted query parameters from proxy URL: {QueryParams}", queryParams);
}
// Pattern to match .m3u8 and .ts/.mp4 segment references
var pattern = @"(?:^|\n)([^#\n][^\n]*\.(?:m3u8|ts|mp4|m4s|aac)[^\n]*)";
var rewritten = Regex.Replace(manifestContent, pattern, match =>
// Helper function to rewrite a URL to proxy
string RewriteUrl(string url)
{
var url = match.Groups[1].Value.Trim();
// Skip if it's already an absolute URL
if (url.StartsWith("http://", StringComparison.OrdinalIgnoreCase) ||
url.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
// Try to parse as absolute URL
if (Uri.TryCreate(url, UriKind.Absolute, out var absoluteUri))
{
// Rewrite absolute URLs to proxy
var relativePath = url.Replace(baseUrl + "/", string.Empty, StringComparison.Ordinal);
return $"\n{proxyBaseUrl}/{relativePath}{queryParams}";
// Check if it's from the same CDN host
if (!absoluteUri.Host.Equals(baseUri.Host, StringComparison.OrdinalIgnoreCase))
{
// External URL (e.g., subtitles from different domain) - leave as-is
_logger.LogDebug("Leaving external URL unchanged: {Url}", url);
return url;
}
// Same host - extract just the filename (last path segment)
var segments = absoluteUri.AbsolutePath.Split('/');
var filename = segments[^1];
return $"{proxyBaseUrl}/{filename}{queryParams}";
}
// Relative URL - rewrite to proxy
return $"\n{proxyBaseUrl}/{url}{queryParams}";
// Relative URL - extract just the path without query params
var path = url;
var queryIndex = path.IndexOf('?', StringComparison.Ordinal);
if (queryIndex >= 0)
{
path = path[..queryIndex];
}
return $"{proxyBaseUrl}/{path}{queryParams}";
}
// Pattern 1: Standalone URL lines (non-# lines ending with media extensions)
var pattern1 = @"(?:^|\n)([^#\n][^\n]*\.(?:m3u8|ts|mp4|m4s|aac)[^\n]*)";
var rewritten = Regex.Replace(manifestContent, pattern1, match =>
{
var url = match.Groups[1].Value.Trim();
return $"\n{RewriteUrl(url)}";
});
// Pattern 2: URI="..." attributes in HLS tags (e.g., #EXT-X-MEDIA, #EXT-X-I-FRAME-STREAM-INF)
var pattern2 = @"URI=""([^""]+)""";
rewritten = Regex.Replace(rewritten, pattern2, match =>
{
var url = match.Groups[1].Value;
return $"URI=\"{RewriteUrl(url)}\"";
});
return rewritten;
@@ -557,5 +931,28 @@ public class StreamProxyService : IDisposable
public DateTime RegisteredAt { get; set; }
public DateTime? TokenExpiresAt { get; set; }
/// <summary>
/// Gets or sets the SRF URN for this stream (used for re-fetching fresh URLs).
/// </summary>
public string? Urn { get; set; }
/// <summary>
/// Gets or sets a value indicating whether this is a livestream.
/// Livestreams always fetch fresh URLs from the API to avoid stale CDN paths.
/// </summary>
public bool IsLiveStream { get; set; }
/// <summary>
/// Gets or sets when this livestream URL was last fetched from the API.
/// Used to prevent rapid-fire API calls from clients like Android TV.
/// </summary>
public DateTime? LastLivestreamFetchAt { get; set; }
/// <summary>
/// Gets or sets a value indicating whether this stream needs authentication on first access.
/// True when registered via RegisterStreamDeferred (authentication deferred until playback).
/// </summary>
public bool NeedsAuthentication { get; set; }
}
}
@@ -6,6 +6,8 @@ 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 Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.SRFPlay.Services;
@@ -13,7 +15,7 @@ namespace Jellyfin.Plugin.SRFPlay.Services;
/// <summary>
/// Service for resolving stream URLs from media composition resources.
/// </summary>
public class StreamUrlResolver : IDisposable
public class StreamUrlResolver : IStreamUrlResolver, IDisposable
{
private readonly ILogger<StreamUrlResolver> _logger;
private readonly HttpClient _httpClient;
@@ -241,7 +243,7 @@ public class StreamUrlResolver : IDisposable
// Build ACL path: /{segment1}/{segment2}/*
var aclPath = $"/{pathSegments[0]}/{pathSegments[1]}/*";
var tokenUrl = $"https://tp.srgssr.ch/akahd/token?acl={Uri.EscapeDataString(aclPath)}";
var tokenUrl = $"{ApiEndpoints.AkamaiTokenEndpoint}?acl={Uri.EscapeDataString(aclPath)}";
_logger.LogDebug("Fetching auth token from: {TokenUrl}", tokenUrl);
+16 -113
View File
@@ -1,6 +1,10 @@
# Jellyfin SRF Play Plugin
A Jellyfin plugin for accessing SRF Play (Swiss Radio and Television) video-on-demand content.
A Jellyfin plugin for accessing SRF Play (Swiss Radio and Television) video-on-demand content and live sports streaming.
## Status
**Beta/Alpha** - This plugin has been tested on two Jellyfin instances and is working. Some clients may experience issues with hardware decoding, which appears to be client-specific behavior.
## Features
@@ -25,95 +29,13 @@ The main channel interface showing the content folders.
Video playback with HLS streaming support and quality selection.
## Project Status
## Testing
### ✅ Completed Components
The plugin includes comprehensive testing:
#### Phase 1: Project Setup
- ✅ Renamed from Template to SRFPlay
- ✅ Updated all namespaces and identifiers
- ✅ Configured plugin metadata (ID, name)
#### Phase 2: Core API Infrastructure
- ✅ API Models (MediaComposition, Chapter, Resource, Show, Episode)
- ✅ SRF API Client with HTTP client wrapper
- ✅ JSON deserialization support
- ✅ Error handling and logging
#### Phase 3: Configuration
- ✅ Business unit selection (SRF/RTS/RSI/RTR/SWI)
- ✅ Quality preferences (Auto/SD/HD)
- ✅ Content refresh intervals
- ✅ Expiration check settings
- ✅ Cache duration configuration
- ✅ HTML configuration page
#### Phase 4: Services
- ✅ Stream URL Resolver
- HLS stream selection
- Quality-based filtering
- DRM content filtering
- Content expiration checking
- Akamai token authentication
- Upcoming livestream detection
- ✅ Metadata Cache Service
- Efficient caching with configurable duration
- Thread-safe with ReaderWriterLockSlim
- IDisposable implementation
- Dynamic TTL for scheduled livestreams
- ✅ Content Expiration Service
- Automatic expiration checking
- Library cleanup of expired content
- Statistics and monitoring
- ✅ Content Refresh Service
- Latest and trending content discovery
- Scheduled sports livestreams
- Automatic cache population
- Recommendations system
#### Phase 5: Content Providers
- ✅ Series Provider (for show metadata)
- ✅ Episode Provider (for episode metadata)
- ✅ Image Provider (for thumbnails and artwork)
- ✅ Media Provider (for playback URLs and HLS streams)
#### Phase 6: Scheduled Tasks
- ✅ Content Refresh Task
- Periodic discovery of new content
- Configurable refresh intervals
- ✅ Expiration Check Task
- Automatic cleanup of expired content
- Configurable check intervals
#### Phase 7: Dependency Injection & Integration
- ✅ Service registration (ServiceRegistrator)
- ✅ Jellyfin provider interfaces implementation
- ✅ Plugin initialization and configuration
#### Phase 8: Live Sports Streaming
- ✅ Play v3 API integration for scheduled livestreams
- ✅ Sports events folder in channel
- ✅ Upcoming event detection and display
- ✅ Akamai token authentication for streams
- ✅ Dynamic cache refresh for live events
- ✅ Event scheduling with ValidFrom/ValidTo handling
- ✅ Automatic stream URL resolution when events go live
### ✅ Build Status
**Successfully compiling!** All code analysis warnings resolved.
### 🧪 Testing Status
- [x] Unit tests (xUnit framework)
- StreamUrlResolver tests
- MetadataCache tests
- More to be added
- [x] API spec validation tests (nightly automated runs)
- All business units (SRF, RTS, RSI, RTR, SWI)
- Response schema validation
- Performance monitoring
- [ ] Integration testing with Jellyfin instance
- [ ] End-to-end playback testing for VOD content
- [ ] Live sports streaming validation
- **Unit tests** (xUnit framework) for core services
- **API spec validation tests** for all business units (SRF, RTS, RSI, RTR, SWI)
- **Integration testing** - Tested on multiple Jellyfin instances with VOD and live sports streaming
**Run tests:**
```bash
@@ -132,13 +54,6 @@ dotnet test --collect:"XPlat Code Coverage"
See [Test Documentation](Jellyfin.Plugin.SRFPlay.Tests/README.md) for more details.
### 📝 Next Steps
1. Test live sports streaming when events are scheduled
2. Verify Akamai token authentication
3. Test with different business units (RTS, RSI, RTR)
4. Add more unit tests for remaining services
5. Performance optimization if needed
## API Information
**Base URL:** `https://il.srgssr.ch/integrationlayer/2.0/`
@@ -297,9 +212,7 @@ Currently focused on SRF but easily extensible.
## Development
### Current Status
All core functionality is implemented and compiling successfully! The plugin includes:
The plugin includes:
- Complete API integration with SRF Play (Integration Layer v2.0 and Play v3)
- **Live sports streaming** with scheduled event detection
- Channel with Latest, Trending, and Live Sports folders
@@ -310,25 +223,15 @@ All core functionality is implemented and compiling successfully! The plugin inc
- Scheduled tasks for content refresh
- Smart caching with dynamic TTL for upcoming livestreams
### Testing
### Known Issues
To test the plugin:
1. Build the plugin: `dotnet build`
2. Copy `bin/Debug/net8.0/Jellyfin.Plugin.SRFPlay.dll` to your Jellyfin plugins directory
3. Restart Jellyfin
4. Configure the plugin in Dashboard → Plugins → SRF Play
5. Add a library with the SRF Play content provider
### Known Limitations
- This is a first version that has not been tested with a live Jellyfin instance yet
- Some edge cases may need handling
- Performance optimization may be needed for large content catalogs
- Some clients may experience issues with hardware decoding (appears to be client-specific)
- Some edge cases may need additional handling
- Performance optimization may be needed for very large content catalogs
### Contributing
This plugin is in active development. Contributions welcome!
Contributions welcome!
## License