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

This commit is contained in:
2025-12-06 20:18:43 +01:00
parent 4f9ebe2bce
commit ed4cc0990c
12 changed files with 961 additions and 647 deletions
@@ -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);
}
@@ -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>
@@ -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
}
};
}
}
@@ -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; }
}
}
@@ -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);