mixed refactor
All checks were successful
🏗️ Build Plugin / build (push) Successful in 2m47s
🧪 Test Plugin / test (push) Successful in 1m22s
🚀 Release Plugin / build-and-release (push) Successful in 2m39s

This commit is contained in:
Duncan Tourolle 2025-12-06 20:18:43 +01:00
parent 4f9ebe2bce
commit ed4cc0990c
12 changed files with 961 additions and 647 deletions

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

View File

@ -6,6 +6,7 @@ using System.Security.Cryptography;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Plugin.SRFPlay.Constants;
using Jellyfin.Plugin.SRFPlay.Services.Interfaces;
using MediaBrowser.Controller;
using MediaBrowser.Controller.Channels;
@ -26,9 +27,8 @@ public class SRFPlayChannel : IChannel, IHasCacheKey
private readonly ILoggerFactory _loggerFactory;
private readonly IContentRefreshService _contentRefreshService;
private readonly IStreamUrlResolver _streamResolver;
private readonly IStreamProxyService _proxyService;
private readonly IMediaSourceFactory _mediaSourceFactory;
private readonly ICategoryService? _categoryService;
private readonly IServerApplicationHost _appHost;
/// <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,
IContentRefreshService contentRefreshService,
IStreamUrlResolver streamResolver,
IStreamProxyService proxyService,
IServerApplicationHost appHost,
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,49 +448,37 @@ 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;
@ -502,7 +496,7 @@ public class SRFPlayChannel : IChannel, IHasCacheKey
}
// Proxy image URL to fix Content-Type headers from SRF CDN
var imageUrl = CreateProxiedImageUrl(originalImageUrl, serverUrl);
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();
@ -525,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
@ -576,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)
{

View File

@ -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";
}

View File

@ -40,6 +40,32 @@ public class StreamProxyController : ControllerBase
_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>
/// Proxies HLS master manifest requests.
/// </summary>
@ -54,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)
@ -119,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);
@ -168,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
@ -244,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;
}
@ -272,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));
}
}

View File

@ -1,139 +0,0 @@
using System;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Plugin.SRFPlay.Services.Interfaces;
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 IStreamProxyService _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>
public SRFLiveStream(
ILogger logger,
IStreamProxyService proxyService,
string originalItemId,
string openToken)
{
_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 and metadata from the original registration
var authenticatedUrl = _proxyService.GetAuthenticatedUrl(_originalItemId);
var metadata = _proxyService.GetStreamMetadata(_originalItemId);
if (authenticatedUrl != null)
{
// Register the same stream URL with the transcoding session ID, preserving metadata
var urn = metadata?.Urn;
var isLiveStream = metadata?.IsLiveStream ?? false;
_proxyService.RegisterStream(value.Id, authenticatedUrl, urn, isLiveStream);
_logger.LogInformation(
"Registered stream for transcoding session ID: {LiveStreamId} (URN: {Urn}, IsLiveStream: {IsLiveStream})",
value.Id,
urn ?? "null",
isLiveStream);
}
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);
}
}
}

View File

@ -4,12 +4,9 @@ using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Plugin.SRFPlay.Configuration;
using Jellyfin.Plugin.SRFPlay.Services.Interfaces;
using MediaBrowser.Controller;
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,8 +19,7 @@ public class SRFMediaProvider : IMediaSourceProvider
private readonly ILogger<SRFMediaProvider> _logger;
private readonly IMediaCompositionFetcher _compositionFetcher;
private readonly IStreamUrlResolver _streamResolver;
private readonly IStreamProxyService _proxyService;
private readonly IServerApplicationHost _appHost;
private readonly IMediaSourceFactory _mediaSourceFactory;
/// <summary>
/// Initializes a new instance of the <see cref="SRFMediaProvider"/> class.
@ -31,20 +27,17 @@ public class SRFMediaProvider : IMediaSourceProvider
/// <param name="loggerFactory">The logger factory.</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,
IMediaCompositionFetcher compositionFetcher,
IStreamUrlResolver streamResolver,
IStreamProxyService proxyService,
IServerApplicationHost appHost)
IMediaSourceFactory mediaSourceFactory)
{
_logger = loggerFactory.CreateLogger<SRFMediaProvider>();
_compositionFetcher = compositionFetcher;
_streamResolver = streamResolver;
_proxyService = proxyService;
_appHost = appHost;
_mediaSourceFactory = mediaSourceFactory;
}
/// <summary>
@ -64,24 +57,13 @@ 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);
_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
var cacheDuration = urn.Contains("scheduled_livestream", StringComparison.OrdinalIgnoreCase) ? 5 : (int?)null;
@ -111,13 +93,23 @@ public class SRFMediaProvider : IMediaSourceProvider
return sources;
}
// Get stream URL based on quality preference
// Get quality preference from config
var config = Plugin.Instance?.Configuration;
var qualityPref = config?.QualityPreference ?? QualityPreference.HD;
var streamUrl = _streamResolver.GetStreamUrl(chapter, qualityPref);
// 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);
@ -127,9 +119,14 @@ public class SRFMediaProvider : IMediaSourceProvider
if (freshMediaComposition?.ChapterList != null && freshMediaComposition.ChapterList.Count > 0)
{
var freshChapter = freshMediaComposition.ChapterList[0];
streamUrl = _streamResolver.GetStreamUrl(freshChapter, qualityPref);
mediaSource = await _mediaSourceFactory.CreateMediaSourceAsync(
freshChapter,
itemIdStr,
urn,
qualityPref,
cancellationToken).ConfigureAwait(false);
if (!string.IsNullOrEmpty(streamUrl))
if (mediaSource != null)
{
chapter = freshChapter;
_logger.LogInformation("URN {Urn}: Got fresh stream URL for scheduled livestream", urn);
@ -137,87 +134,19 @@ public class SRFMediaProvider : IMediaSourceProvider
}
}
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);
}
// Detect if this is a live stream
var isLiveStream = chapter.Type == "SCHEDULED_LIVESTREAM" || urn.Contains("livestream", StringComparison.OrdinalIgnoreCase);
_logger.LogInformation(
"Livestream detection - ChapterType: {ChapterType}, URN: {Urn}, IsLiveStream: {IsLiveStream}",
chapter.Type,
urn,
isLiveStream);
// Register stream with proxy service
var itemIdStr = item.Id.ToString("N"); // Use hex format without dashes
_proxyService.RegisterStream(itemIdStr, streamUrl, urn, isLiveStream);
// Get the server URL for proxy - prefer configured public URL for remote clients
var serverUrl = config != null && !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 - item ID is all we need since proxy handles auth
var proxyUrl = $"{serverUrl}/Plugins/SRFPlay/Proxy/{itemIdStr}/master.m3u8";
_logger.LogInformation(
"Using proxy URL for item {ItemId}: {ProxyUrl} (PublicServerUrl configured: {IsPublicConfigured})",
itemIdStr,
proxyUrl,
config != null && !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 = false, // Prefer DirectPlay - no transcoding needed for HLS
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 = false, // Proxy handles auth - no need for OpenMediaSource
RequiresClosing = false,
SupportsProbing = true, // Enable probing so Jellyfin can verify stream compatibility
ReadAtNativeFramerate = isLiveStream, // Read at native framerate for live streams
MediaStreams = CreateMediaStreams(qualityPref)
};
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)
{
@ -247,51 +176,4 @@ public class SRFMediaProvider : IMediaSourceProvider
_logger.LogWarning("OpenMediaSource unexpectedly called with openToken: {OpenToken}", openToken);
throw new NotSupportedException("OpenMediaSource not supported - streams use direct proxy access");
}
/// <summary>
/// Creates MediaStream metadata based on quality preference.
/// These are approximate values - HLS adaptive streaming will use actual stream qualities.
/// </summary>
private static List<MediaBrowser.Model.Entities.MediaStream> CreateMediaStreams(QualityPreference quality)
{
// Set resolution/bitrate based on quality preference
// These are typical values for SRF streams - actual HLS will adapt
var (width, height, videoBitrate) = quality switch
{
QualityPreference.SD => (1280, 720, 2500000),
QualityPreference.HD => (1920, 1080, 5000000),
_ => (1280, 720, 3000000)
};
return new List<MediaBrowser.Model.Entities.MediaStream>
{
new MediaBrowser.Model.Entities.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 MediaBrowser.Model.Entities.MediaStream
{
Type = MediaStreamType.Audio,
Codec = "aac",
Profile = "LC",
Channels = 2,
SampleRate = 48000,
BitRate = 128000,
IsDefault = true,
Index = 1
}
};
}
}

View File

@ -28,6 +28,7 @@ public class ServiceRegistrator : IPluginServiceRegistrator
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>();

View File

@ -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);
}

View File

@ -9,7 +9,7 @@ namespace Jellyfin.Plugin.SRFPlay.Services.Interfaces;
public interface IStreamProxyService
{
/// <summary>
/// Registers a stream for proxying.
/// 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>
@ -17,6 +17,16 @@ public interface IStreamProxyService
/// <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>

View File

@ -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
}
};
}
}

View File

@ -1,5 +1,6 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Text.RegularExpressions;
@ -65,7 +66,8 @@ public class StreamProxyService : IStreamProxyService, IDisposable
RegisteredAt = DateTime.UtcNow,
TokenExpiresAt = tokenExpiry,
Urn = urn,
IsLiveStream = isLiveStream
IsLiveStream = isLiveStream,
LastLivestreamFetchAt = isLiveStream ? DateTime.UtcNow : null
};
// Register with the provided item ID
@ -106,6 +108,55 @@ public class StreamProxyService : IStreamProxyService, 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.
@ -148,7 +199,36 @@ public class StreamProxyService : IStreamProxyService, 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);
}
@ -194,10 +274,14 @@ public class StreamProxyService : IStreamProxyService, 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);
}
@ -212,11 +296,15 @@ public class StreamProxyService : IStreamProxyService, 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);
}
}
@ -234,13 +322,52 @@ public class StreamProxyService : IStreamProxyService, IDisposable
/// </summary>
private string? ValidateAndReturnStream(string itemId, StreamInfo streamInfo)
{
// For livestreams, always fetch fresh URL from API to avoid stale CDN paths
if (streamInfo.IsLiveStream && !string.IsNullOrEmpty(streamInfo.Urn))
// Handle deferred authentication (first playback after browsing)
if (streamInfo.NeedsAuthentication)
{
_logger.LogInformation(
"Livestream detected for item {ItemId} (URN: {Urn}) - fetching fresh stream URL from API",
"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,
streamInfo.Urn);
tokenTimeLeft,
timeSinceLastFetch);
var freshUrl = FetchFreshStreamUrl(itemId, streamInfo);
if (freshUrl != null)
@ -339,6 +466,7 @@ public class StreamProxyService : IStreamProxyService, IDisposable
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})",
@ -397,6 +525,54 @@ public class StreamProxyService : IStreamProxyService, 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>
@ -446,6 +622,41 @@ public class StreamProxyService : IStreamProxyService, 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>
@ -466,13 +677,15 @@ public class StreamProxyService : IStreamProxyService, 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)
@ -512,7 +725,12 @@ public class StreamProxyService : IStreamProxyService, 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);
@ -547,24 +765,51 @@ public class StreamProxyService : IStreamProxyService, 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;
@ -697,5 +942,17 @@ public class StreamProxyService : IStreamProxyService, IDisposable
/// 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; }
}
}

View File

@ -6,6 +6,7 @@ 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;
@ -242,7 +243,7 @@ public class StreamUrlResolver : IStreamUrlResolver, 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);