Compare commits

..
10 Commits
Author SHA1 Message Date
dtourolle 89a911b9c4 working livestreams!
🏗️ Build Plugin / build (push) Successful in 3m27s
🧪 Test Plugin / test (push) Successful in 1m38s
🚀 Release Plugin / build-and-release (push) Successful in 3m29s
2025-11-22 14:14:43 +01:00
dtourolle b8ac466c90 Fix issue with Jellyfin using local IP adress not server public URL, now pulic URL is set in plugin.
🏗️ Build Plugin / build (push) Successful in 3m21s
🧪 Test Plugin / test (push) Successful in 1m36s
🚀 Release Plugin / build-and-release (push) Successful in 3m9s
2025-11-21 20:17:05 +01:00
dtourolle e26f2a2ab1 passthrough not transcode
🚀 Release Plugin / build-and-release (push) Successful in 3m19s
🏗️ Build Plugin / build (push) Successful in 3m8s
🧪 Test Plugin / test (push) Successful in 1m43s
2025-11-16 20:53:23 +01:00
dtourolle cd0f680981 Improved token refresh mechanism
🏗️ Build Plugin / build (push) Successful in 3m29s
🧪 Test Plugin / test (push) Successful in 1m37s
🚀 Release Plugin / build-and-release (push) Successful in 3m9s
2025-11-16 20:15:30 +01:00
dtourolle cfe510e15c Finaly working version of livestreams
🏗️ Build Plugin / build (push) Successful in 2m30s
🧪 Test Plugin / test (push) Successful in 1m11s
🚀 Release Plugin / build-and-release (push) Successful in 2m31s
2025-11-15 22:34:21 +01:00
dtourolle 8e86db100a tokens refresh on media start
🏗️ Build Plugin / build (push) Successful in 2m19s
🧪 Test Plugin / test (push) Successful in 1m8s
🚀 Release Plugin / build-and-release (push) Successful in 2m18s
2025-11-15 17:51:14 +01:00
dtourolle d48b515898 Fix -- Use validTo time for live streams
🏗️ Build Plugin / build (push) Successful in 2m22s
🧪 Test Plugin / test (push) Successful in 1m6s
2025-11-15 16:09:46 +01:00
dtourolle 31b2402a96 more readme refinement
🏗️ Build Plugin / build (push) Successful in 2m22s
🧪 Test Plugin / test (push) Successful in 1m7s
2025-11-14 22:01:17 +01:00
dtourolle 3691b4e30e removed md unrelated to current state 2025-11-14 21:50:58 +01:00
dtourolle 87ccf80813 remove netowkring notes ans script
🏗️ Build Plugin / build (push) Successful in 2m25s
🧪 Test Plugin / test (push) Successful in 1m8s
2025-11-14 21:43:43 +01:00
17 changed files with 1318 additions and 703 deletions
-88
View File
@@ -1,88 +0,0 @@
# Compilation Fixes - COMPLETED ✅
## Status: ALL ERRORS RESOLVED
**Build Status:** ✅ SUCCESS
The plugin now compiles successfully with no errors!
## Fixed Issues
### 1. Collection Type Warnings ✅
**Issue:** CA2227 and CA1002 - Collection properties should be read-only and use appropriate collection types
**Files Fixed:**
- `Api/Models/MediaComposition.cs` - Changed `List<Chapter>` to `IReadOnlyList<Chapter>`
- `Api/Models/Chapter.cs` - Changed `List<Resource>` to `IReadOnlyList<Resource>`
**Solution:** Used `IReadOnlyList<T>` to satisfy code analysis while maintaining JSON deserialization compatibility.
### 2. MetadataCache Warnings ✅
**Issue:** Multiple issues with MetadataCache
- CA1001: Type should implement IDisposable (owns ReaderWriterLockSlim)
- MT1012: Lock acquisition should be wrapped in try blocks
- CA1852: CacheEntry class should be sealed
**File Fixed:** `Services/MetadataCache.cs`
**Solution:**
- Implemented IDisposable interface
- Wrapped all lock acquisitions in try-catch blocks
- Added ObjectDisposedException handling
- Sealed the CacheEntry inner class
- Reordered fields (readonly fields before non-readonly)
### 3. SRFMediaProvider Warnings ✅
**Issue:**
- SA1648: inheritdoc should be used with inheriting class
- CA1849: Avoid synchronous blocking
**File Fixed:** `Providers/SRFMediaProvider.cs`
**Solution:**
- Replaced `/// <inheritdoc />` with proper XML documentation summaries
- Changed from `Task.Wait()` and `.Result` to `.GetAwaiter().GetResult()` (less problematic)
### 4. ContentExpirationService Warnings ✅
**Issue:** SA1028 - Trailing whitespace
**File Fixed:** `Services/ContentExpirationService.cs`
**Solution:** Removed trailing whitespace on lines 79 and 221
### 5. CA1826 Warnings ✅
**Issue:** Use indexer instead of LINQ `.First()` for collections with indexers
**Files Fixed:**
- `Services/ContentExpirationService.cs`
- `Providers/SRFEpisodeProvider.cs`
- `Providers/SRFImageProvider.cs`
- `Providers/SRFMediaProvider.cs`
**Solution:** Replaced `.First()` calls with `[0]` indexer access for IReadOnlyList collections
## Build Output
```
Build succeeded in 1.0s
Jellyfin.Plugin.SRFPlay succeeded → Jellyfin.Plugin.SRFPlay/bin/Debug/net8.0/Jellyfin.Plugin.SRFPlay.dll
```
## Summary
All 17 initial compilation errors have been resolved:
- ✅ 4 collection property warnings
- ✅ 6 MetadataCache warnings
- ✅ 3 SRFMediaProvider warnings
- ✅ 2 ContentExpirationService whitespace warnings
- ✅ 6 CA1826 indexer warnings
- ✅ 1 field ordering warning
The plugin is now ready for testing with a Jellyfin instance!
## Next Steps
1. ✅ Plugin compiles successfully
2. ⏭️ Test with Jellyfin instance
3. ⏭️ Verify content discovery
4. ⏭️ Test playback functionality
5. ⏭️ Validate expiration handling
+6 -11
View File
@@ -140,6 +140,11 @@ public class SRFApiClient : IDisposable
var fullUrl = $"{BaseUrl}{url}"; var fullUrl = $"{BaseUrl}{url}";
_logger.LogInformation("Fetching media composition for URN: {Urn} from {Url}", urn, fullUrl); _logger.LogInformation("Fetching media composition for URN: {Urn} from {Url}", urn, fullUrl);
// HttpClient consistently fails with 404, use curl directly
// This is likely due to routing/network configuration on the Jellyfin server
return await FetchWithCurlAsync(fullUrl, cancellationToken).ConfigureAwait(false);
/* HttpClient fallback disabled - always returns 404
var response = await _httpClient.GetAsync(url, cancellationToken).ConfigureAwait(false); var response = await _httpClient.GetAsync(url, cancellationToken).ConfigureAwait(false);
// Log response headers to diagnose geo-blocking // Log response headers to diagnose geo-blocking
@@ -178,17 +183,7 @@ public class SRFApiClient : IDisposable
} }
return result; return result;
} */
catch (HttpRequestException ex)
{
_logger.LogError(
ex,
"HTTP error fetching media composition for URN: {Urn} - StatusCode: {StatusCode}, trying curl fallback",
urn,
ex.StatusCode);
var fullUrl = $"{BaseUrl}/mediaComposition/byUrn/{urn}.json";
return await FetchWithCurlAsync(fullUrl, cancellationToken).ConfigureAwait(false);
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -7,6 +7,7 @@ using System.Text;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using Jellyfin.Plugin.SRFPlay.Services; using Jellyfin.Plugin.SRFPlay.Services;
using MediaBrowser.Controller;
using MediaBrowser.Controller.Channels; using MediaBrowser.Controller.Channels;
using MediaBrowser.Controller.Providers; using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.Channels; using MediaBrowser.Model.Channels;
@@ -25,7 +26,9 @@ public class SRFPlayChannel : IChannel, IHasCacheKey
private readonly ILoggerFactory _loggerFactory; private readonly ILoggerFactory _loggerFactory;
private readonly ContentRefreshService _contentRefreshService; private readonly ContentRefreshService _contentRefreshService;
private readonly StreamUrlResolver _streamResolver; private readonly StreamUrlResolver _streamResolver;
private readonly StreamProxyService _proxyService;
private readonly CategoryService? _categoryService; private readonly CategoryService? _categoryService;
private readonly IServerApplicationHost _appHost;
/// <summary> /// <summary>
/// Initializes a new instance of the <see cref="SRFPlayChannel"/> class. /// Initializes a new instance of the <see cref="SRFPlayChannel"/> class.
@@ -33,17 +36,23 @@ public class SRFPlayChannel : IChannel, IHasCacheKey
/// <param name="loggerFactory">The logger factory.</param> /// <param name="loggerFactory">The logger factory.</param>
/// <param name="contentRefreshService">The content refresh service.</param> /// <param name="contentRefreshService">The content refresh service.</param>
/// <param name="streamResolver">The stream resolver.</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="categoryService">The category service (optional).</param> /// <param name="categoryService">The category service (optional).</param>
public SRFPlayChannel( public SRFPlayChannel(
ILoggerFactory loggerFactory, ILoggerFactory loggerFactory,
ContentRefreshService contentRefreshService, ContentRefreshService contentRefreshService,
StreamUrlResolver streamResolver, StreamUrlResolver streamResolver,
StreamProxyService proxyService,
IServerApplicationHost appHost,
CategoryService? categoryService = null) CategoryService? categoryService = null)
{ {
_loggerFactory = loggerFactory; _loggerFactory = loggerFactory;
_logger = loggerFactory.CreateLogger<SRFPlayChannel>(); _logger = loggerFactory.CreateLogger<SRFPlayChannel>();
_contentRefreshService = contentRefreshService; _contentRefreshService = contentRefreshService;
_streamResolver = streamResolver; _streamResolver = streamResolver;
_proxyService = proxyService;
_appHost = appHost;
_categoryService = categoryService; _categoryService = categoryService;
if (_categoryService == null) if (_categoryService == null)
@@ -61,7 +70,7 @@ public class SRFPlayChannel : IChannel, IHasCacheKey
public string Description => "Swiss Radio and Television video-on-demand content"; public string Description => "Swiss Radio and Television video-on-demand content";
/// <inheritdoc /> /// <inheritdoc />
public string DataVersion => "1.0"; public string DataVersion => "2.0"; // Back to authenticating at channel refresh with auto-refresh for fresh tokens
/// <inheritdoc /> /// <inheritdoc />
public string HomePageUrl => "https://www.srf.ch/play"; public string HomePageUrl => "https://www.srf.ch/play";
@@ -231,7 +240,8 @@ public class SRFPlayChannel : IChannel, IHasCacheKey
.Where(p => p.Urn != null && .Where(p => p.Urn != null &&
!string.IsNullOrEmpty(p.Title) && !string.IsNullOrEmpty(p.Title) &&
p.ValidFrom != null && p.ValidFrom != null &&
p.ValidFrom.Value.ToUniversalTime() <= weekFromNow) p.ValidFrom.Value.ToUniversalTime() <= weekFromNow &&
(p.ValidTo == null || p.ValidTo.Value.ToUniversalTime() > now))
.OrderBy(p => p.ValidFrom) .OrderBy(p => p.ValidFrom)
.ToList(); .ToList();
@@ -435,33 +445,49 @@ public class SRFPlayChannel : IChannel, IHasCacheKey
// Get stream URL and authenticate it // Get stream URL and authenticate it
var streamUrl = _streamResolver.GetStreamUrl(chapter, config.QualityPreference); var streamUrl = _streamResolver.GetStreamUrl(chapter, config.QualityPreference);
// For scheduled livestreams that haven't started, streamUrl might be null // Skip scheduled livestreams that haven't started yet (no stream URL available)
var isUpcomingLivestream = chapter.Type == "SCHEDULED_LIVESTREAM" && string.IsNullOrEmpty(streamUrl); if (chapter.Type == "SCHEDULED_LIVESTREAM" && string.IsNullOrEmpty(streamUrl))
if (!string.IsNullOrEmpty(streamUrl))
{ {
// Authenticate the stream URL (required for all SRF streams, especially livestreams) _logger.LogDebug(
streamUrl = await _streamResolver.GetAuthenticatedStreamUrlAsync(streamUrl, cancellationToken).ConfigureAwait(false); "URN {Urn}: Skipping upcoming livestream '{Title}' - stream not yet available (starts at {ValidFrom})",
}
else if (isUpcomingLivestream)
{
// Use a placeholder for upcoming events
streamUrl = "http://placeholder.local/upcoming.m3u8";
_logger.LogInformation(
"URN {Urn}: Upcoming livestream '{Title}' - stream will be available at {ValidFrom}",
urn, urn,
chapter.Title, chapter.Title,
chapter.ValidFrom); chapter.ValidFrom);
continue;
} }
// Build overview with event time for upcoming livestreams // Authenticate the stream URL with fresh token
var overview = chapter.Description ?? chapter.Lead; if (!string.IsNullOrEmpty(streamUrl))
if (isUpcomingLivestream && chapter.ValidFrom != null)
{ {
var eventStart = chapter.ValidFrom.Value.ToString("dd.MM.yyyy HH:mm", CultureInfo.InvariantCulture); streamUrl = await _streamResolver.GetAuthenticatedStreamUrlAsync(streamUrl, cancellationToken).ConfigureAwait(false);
overview = $"[Upcoming Event - Starts at {eventStart}]\n\n{overview}";
} }
// Skip items without a valid stream URL
if (string.IsNullOrEmpty(streamUrl))
{
_logger.LogWarning(
"URN {Urn}: Skipping '{Title}' - no valid stream URL available",
urn,
chapter.Title);
noStreamCount++;
continue;
}
// Register stream with proxy service
_proxyService.RegisterStream(itemId, streamUrl);
// Get the server URL for proxy - prefer configured public URL for remote clients
var serverUrl = !string.IsNullOrWhiteSpace(config.PublicServerUrl)
? config.PublicServerUrl.TrimEnd('/') // Use configured public URL (important for Android/remote clients)
: _appHost.GetSmartApiUrl(string.Empty); // Fall back to Jellyfin's smart URL resolution
// Create proxy URL as absolute HTTP URL (required for ffmpeg)
// Use the actual server URL so remote clients can access it
var proxyUrl = $"{serverUrl}/Plugins/SRFPlay/Proxy/{itemId}/master.m3u8";
// Build overview
var overview = chapter.Description ?? chapter.Lead;
// Get image URL - prefer chapter image, fall back to show image if available // Get image URL - prefer chapter image, fall back to show image if available
var imageUrl = chapter.ImageUrl; var imageUrl = chapter.ImageUrl;
if (string.IsNullOrEmpty(imageUrl) && mediaComposition.Show != null) if (string.IsNullOrEmpty(imageUrl) && mediaComposition.Show != null)
@@ -475,9 +501,10 @@ public class SRFPlayChannel : IChannel, IHasCacheKey
_logger.LogWarning("URN {Urn}: No image URL available for '{Title}'", urn, chapter.Title); _logger.LogWarning("URN {Urn}: No image URL available for '{Title}'", urn, chapter.Title);
} }
// Use ValidFrom for premiere date if this is an upcoming livestream, otherwise use Date // Use ValidFrom for premiere date if this is a scheduled livestream, otherwise use Date
var premiereDate = isUpcomingLivestream ? chapter.ValidFrom?.ToUniversalTime() : chapter.Date?.ToUniversalTime(); var premiereDate = chapter.Type == "SCHEDULED_LIVESTREAM" ? chapter.ValidFrom?.ToUniversalTime() : chapter.Date?.ToUniversalTime();
// Store authenticated URL - tokens refresh automatically via scheduled channel scans
var item = new ChannelItemInfo var item = new ChannelItemInfo
{ {
Id = itemId, Id = itemId,
@@ -501,15 +528,38 @@ public class SRFPlayChannel : IChannel, IHasCacheKey
{ {
Id = itemId, Id = itemId,
Name = chapter.Title, Name = chapter.Title,
Path = streamUrl, Path = proxyUrl, // Proxy URL instead of direct Akamai URL
Protocol = MediaBrowser.Model.MediaInfo.MediaProtocol.Http, Protocol = MediaBrowser.Model.MediaInfo.MediaProtocol.Http,
Container = "m3u8", Container = "hls",
SupportsDirectStream = true, SupportsDirectStream = true,
SupportsDirectPlay = true, SupportsDirectPlay = true, // ✅ Enabled! Proxy handles auth
SupportsTranscoding = true, SupportsTranscoding = true,
IsRemote = true, IsRemote = false, // False because it's a local proxy endpoint
Type = MediaBrowser.Model.Dto.MediaSourceType.Default, Type = MediaBrowser.Model.Dto.MediaSourceType.Default,
VideoType = VideoType.VideoFile 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
}
}
} }
} }
}; };
@@ -523,6 +573,13 @@ public class SRFPlayChannel : IChannel, IHasCacheKey
items.Add(item); items.Add(item);
successCount++; successCount++;
_logger.LogInformation("URN {Urn}: Successfully converted to channel item - {Title}", urn, chapter.Title); _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}",
urn,
true,
true,
true,
"hls");
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -142,4 +142,11 @@ public class PluginConfiguration : BasePluginConfiguration
[System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "CA2227:Collection properties should be read only", Justification = "Required for configuration serialization")] [System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "CA2227:Collection properties should be read only", Justification = "Required for configuration serialization")]
[System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "CA1002:Do not expose generic lists", Justification = "Configuration DTO")] [System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "CA1002:Do not expose generic lists", Justification = "Configuration DTO")]
public System.Collections.Generic.List<string> EnabledTopics { get; set; } public System.Collections.Generic.List<string> EnabledTopics { get; set; }
/// <summary>
/// Gets or sets the public/external server URL for remote clients (e.g., https://jellyfin.example.com:8920).
/// If not set, the plugin will use Jellyfin's GetSmartApiUrl() which may return local addresses.
/// This is important for Android and other remote clients to access streams.
/// </summary>
public string PublicServerUrl { get; set; } = string.Empty;
} }
@@ -82,6 +82,17 @@
<input id="ProxyPassword" name="ProxyPassword" type="password" is="emby-input" autocomplete="off" /> <input id="ProxyPassword" name="ProxyPassword" type="password" is="emby-input" autocomplete="off" />
<div class="fieldDescription">Password for proxy authentication (leave empty if not required)</div> <div class="fieldDescription">Password for proxy authentication (leave empty if not required)</div>
</div> </div>
<br />
<h2>Network Settings</h2>
<div class="inputContainer">
<label class="inputLabel inputLabelUnfocused" for="PublicServerUrl">Public Server URL (Optional)</label>
<input id="PublicServerUrl" name="PublicServerUrl" type="text" is="emby-input" placeholder="e.g., https://jellyfin.example.com:8920" />
<div class="fieldDescription">
The public/external URL for remote clients (Android, iOS, etc.) to access streaming proxy.
<br />If not set, the plugin will use Jellyfin's automatic URL detection which may return local addresses.
<br /><strong>Important for Android/remote playback!</strong>
</div>
</div>
<div> <div>
<button is="emby-button" type="submit" class="raised button-submit block emby-button"> <button is="emby-button" type="submit" class="raised button-submit block emby-button">
<span>Save</span> <span>Save</span>
@@ -110,6 +121,7 @@
document.querySelector('#ProxyAddress').value = config.ProxyAddress || ''; document.querySelector('#ProxyAddress').value = config.ProxyAddress || '';
document.querySelector('#ProxyUsername').value = config.ProxyUsername || ''; document.querySelector('#ProxyUsername').value = config.ProxyUsername || '';
document.querySelector('#ProxyPassword').value = config.ProxyPassword || ''; document.querySelector('#ProxyPassword').value = config.ProxyPassword || '';
document.querySelector('#PublicServerUrl').value = config.PublicServerUrl || '';
Dashboard.hideLoadingMsg(); Dashboard.hideLoadingMsg();
}); });
}); });
@@ -129,6 +141,7 @@
config.ProxyAddress = document.querySelector('#ProxyAddress').value; config.ProxyAddress = document.querySelector('#ProxyAddress').value;
config.ProxyUsername = document.querySelector('#ProxyUsername').value; config.ProxyUsername = document.querySelector('#ProxyUsername').value;
config.ProxyPassword = document.querySelector('#ProxyPassword').value; config.ProxyPassword = document.querySelector('#ProxyPassword').value;
config.PublicServerUrl = document.querySelector('#PublicServerUrl').value;
ApiClient.updatePluginConfiguration(SRFPlayConfig.pluginUniqueId, config).then(function (result) { ApiClient.updatePluginConfiguration(SRFPlayConfig.pluginUniqueId, config).then(function (result) {
Dashboard.processPluginConfigurationUpdateResult(result); Dashboard.processPluginConfigurationUpdateResult(result);
}); });
@@ -0,0 +1,319 @@
using System;
using System.Globalization;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Plugin.SRFPlay.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.SRFPlay.Controllers;
/// <summary>
/// Controller for proxying SRF Play streams.
/// </summary>
[ApiController]
[Route("Plugins/SRFPlay/Proxy")]
public class StreamProxyController : ControllerBase
{
private readonly ILogger<StreamProxyController> _logger;
private readonly StreamProxyService _proxyService;
/// <summary>
/// Initializes a new instance of the <see cref="StreamProxyController"/> class.
/// </summary>
/// <param name="logger">The logger.</param>
/// <param name="proxyService">The proxy service.</param>
public StreamProxyController(ILogger<StreamProxyController> logger, StreamProxyService proxyService)
{
_logger = logger;
_proxyService = proxyService;
}
/// <summary>
/// Proxies HLS master manifest requests.
/// </summary>
/// <param name="itemId">The item ID from URL path.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The HLS manifest with rewritten URLs.</returns>
[HttpGet("{itemId}/master.m3u8")]
[AllowAnonymous] // Allow anonymous since Jellyfin handles auth upstream
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetMasterManifest(
[FromRoute] string itemId,
CancellationToken cancellationToken)
{
_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)
var actualItemId = ResolveItemId(itemId);
try
{
// Get the correct scheme (https if configured, otherwise use request scheme)
var scheme = GetProxyScheme();
// Build the base proxy URL for this item
// Preserve query parameters (token or itemId) from the original request
string baseProxyUrl;
if (Request.Query.TryGetValue("token", out var token) && !string.IsNullOrEmpty(token))
{
baseProxyUrl = $"{scheme}://{Request.Host}/Plugins/SRFPlay/Proxy/{itemId}?token={token}";
_logger.LogDebug("Using token-based proxy URL with token: {Token}", token.ToString());
}
else if (actualItemId != itemId)
{
// Legacy: If path ID differs from resolved ID, add itemId query parameter
baseProxyUrl = $"{scheme}://{Request.Host}/Plugins/SRFPlay/Proxy/{itemId}?itemId={actualItemId}";
_logger.LogInformation("Path itemId {PathId} differs from resolved itemId {ResolvedId}, adding query parameter", itemId, actualItemId);
}
else
{
// Simple case: no query parameters needed
baseProxyUrl = $"{scheme}://{Request.Host}/Plugins/SRFPlay/Proxy/{itemId}";
_logger.LogDebug("Path itemId matches resolved itemId: {ItemId}", itemId);
}
var manifestContent = await _proxyService.GetRewrittenManifestAsync(actualItemId, baseProxyUrl, cancellationToken).ConfigureAwait(false);
if (manifestContent == null)
{
_logger.LogWarning("Manifest not found for path itemId {PathItemId}, resolved itemId {ResolvedItemId} - stream may not be registered", itemId, actualItemId);
return NotFound();
}
_logger.LogDebug("Returning master manifest for item {ItemId} ({Length} bytes)", itemId, manifestContent.Length);
return Content(manifestContent, "application/vnd.apple.mpegurl");
}
catch (Exception ex)
{
_logger.LogError(ex, "Error proxying master manifest for item {ItemId}", itemId);
return StatusCode(StatusCodes.Status500InternalServerError);
}
}
/// <summary>
/// Proxies HLS variant manifest requests.
/// </summary>
/// <param name="itemId">The item ID.</param>
/// <param name="manifestPath">The manifest path (e.g., "index_0_av").</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The HLS manifest with rewritten segment URLs.</returns>
[HttpGet("{itemId}/{manifestPath}.m3u8")]
[AllowAnonymous]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetVariantManifest(
[FromRoute] string itemId,
[FromRoute] string manifestPath,
CancellationToken cancellationToken)
{
var fullPath = $"{manifestPath}.m3u8";
_logger.LogInformation("Proxy request for variant manifest - ItemId: {ItemId}, Path: {Path}", itemId, fullPath);
// Try to resolve the actual item ID
var actualItemId = ResolveItemId(itemId);
try
{
// Fetch the variant manifest as a segment
var manifestData = await _proxyService.GetSegmentAsync(actualItemId, fullPath, cancellationToken).ConfigureAwait(false);
if (manifestData == null)
{
_logger.LogWarning("Variant manifest not found - ItemId: {ItemId}, Path: {Path}", itemId, fullPath);
return NotFound();
}
// Convert to string and rewrite URLs
var manifestContent = System.Text.Encoding.UTF8.GetString(manifestData);
var scheme = GetProxyScheme();
var baseProxyUrl = $"{scheme}://{Request.Host}/Plugins/SRFPlay/Proxy/{itemId}";
var rewrittenContent = RewriteSegmentUrls(manifestContent, baseProxyUrl);
_logger.LogDebug("Returning variant manifest for item {ItemId} ({Length} bytes)", itemId, rewrittenContent.Length);
return Content(rewrittenContent, "application/vnd.apple.mpegurl");
}
catch (Exception ex)
{
_logger.LogError(ex, "Error proxying variant manifest - ItemId: {ItemId}, Path: {Path}", itemId, fullPath);
return StatusCode(StatusCodes.Status500InternalServerError);
}
}
/// <summary>
/// Proxies HLS segment requests (.ts, .mp4, .m4s, .aac files).
/// </summary>
/// <param name="itemId">The item ID.</param>
/// <param name="segmentPath">The segment path.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The segment data.</returns>
[HttpGet("{itemId}/{*segmentPath}")]
[AllowAnonymous]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetSegment(
[FromRoute] string itemId,
[FromRoute] string segmentPath,
CancellationToken cancellationToken)
{
_logger.LogDebug("Proxy request for segment - ItemId: {ItemId}, Path: {SegmentPath}", itemId, segmentPath);
// Try to resolve the actual item ID
var actualItemId = ResolveItemId(itemId);
try
{
var segmentData = await _proxyService.GetSegmentAsync(actualItemId, segmentPath, cancellationToken).ConfigureAwait(false);
if (segmentData == null)
{
_logger.LogWarning("Segment not found - ItemId: {ItemId}, Path: {SegmentPath}", itemId, segmentPath);
return NotFound();
}
// Determine content type based on file extension
var contentType = GetContentType(segmentPath);
_logger.LogDebug("Returning segment {SegmentPath} ({Length} bytes, {ContentType})", segmentPath, segmentData.Length, contentType);
return File(segmentData, contentType);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error proxying segment - ItemId: {ItemId}, Path: {SegmentPath}", itemId, segmentPath);
return StatusCode(StatusCodes.Status500InternalServerError);
}
}
/// <summary>
/// Gets the correct scheme for proxy URLs (https if public URL is configured with https).
/// </summary>
/// <returns>The scheme to use (http or https).</returns>
private string GetProxyScheme()
{
// Check if PublicServerUrl is configured and uses HTTPS
var config = Plugin.Instance?.Configuration;
if (config != null && !string.IsNullOrWhiteSpace(config.PublicServerUrl))
{
if (config.PublicServerUrl.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
{
return "https";
}
}
// Fall back to request scheme, but prefer https if forwarded headers indicate it
if (Request.Headers.TryGetValue("X-Forwarded-Proto", out var forwardedProto))
{
return forwardedProto.ToString().ToLowerInvariant();
}
return Request.Scheme;
}
/// <summary>
/// Resolves the actual item ID from the request.
/// </summary>
/// <param name="pathItemId">The item ID from the URL path.</param>
/// <returns>The resolved item ID.</returns>
private string ResolveItemId(string pathItemId)
{
// Check for token parameter first (preferred method)
if (Request.Query.TryGetValue("token", out var token) && !string.IsNullOrEmpty(token))
{
// Try to resolve the original item ID from the token via the proxy service
// We'll need to add a method to StreamProxyService to look up by token
_logger.LogInformation("Found token parameter: {Token}, will use path ID {PathItemId} for lookup", token.ToString(), pathItemId);
return pathItemId; // Use path ID for now; token prevents Jellyfin from rewriting the URL
}
// Check if there's an itemId query parameter (legacy fallback)
if (Request.Query.TryGetValue("itemId", out var queryItemId) && !string.IsNullOrEmpty(queryItemId))
{
_logger.LogInformation("Using itemId from query parameter: {QueryItemId} (path had: {PathItemId})", queryItemId.ToString(), pathItemId);
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);
return pathItemId;
}
/// <summary>
/// Rewrites segment URLs in a manifest to point to proxy.
/// </summary>
/// <param name="manifestContent">The manifest content.</param>
/// <param name="baseProxyUrl">The base proxy URL.</param>
/// <returns>The rewritten manifest.</returns>
private string RewriteSegmentUrls(string manifestContent, string baseProxyUrl)
{
// Extract query parameters from the current request to propagate them
string queryParams;
if (Request.Query.TryGetValue("token", out var token) && !string.IsNullOrEmpty(token))
{
queryParams = $"?token={token}";
}
else if (Request.Query.TryGetValue("itemId", out var itemId) && !string.IsNullOrEmpty(itemId))
{
queryParams = $"?itemId={itemId}";
}
else
{
queryParams = string.Empty;
}
var lines = manifestContent.Split('\n');
var result = new System.Text.StringBuilder();
foreach (var line in lines)
{
if (line.StartsWith('#') || string.IsNullOrWhiteSpace(line))
{
// Keep metadata and blank lines as-is
result.AppendLine(line);
}
else if (line.Contains("://", StringComparison.Ordinal))
{
// 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}");
}
else
{
// Relative URL - rewrite to proxy
result.AppendLine(CultureInfo.InvariantCulture, $"{baseProxyUrl}/{line.Trim()}{queryParams}");
}
}
return result.ToString();
}
/// <summary>
/// Gets the content type for a segment based on file extension.
/// </summary>
/// <param name="path">The segment path.</param>
/// <returns>The MIME content type.</returns>
private static string GetContentType(string path)
{
if (path.EndsWith(".ts", StringComparison.OrdinalIgnoreCase))
{
return "video/MP2T";
}
if (path.EndsWith(".mp4", StringComparison.OrdinalIgnoreCase) ||
path.EndsWith(".m4s", StringComparison.OrdinalIgnoreCase))
{
return "video/mp4";
}
if (path.EndsWith(".aac", StringComparison.OrdinalIgnoreCase))
{
return "audio/aac";
}
return "application/octet-stream";
}
}
@@ -0,0 +1,134 @@
using System;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Plugin.SRFPlay.Services;
using MediaBrowser.Controller.Library;
using MediaBrowser.Model.Dto;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.SRFPlay.Providers;
/// <summary>
/// Live stream wrapper for SRF Play streams to handle transcoding sessions.
/// </summary>
internal sealed class SRFLiveStream : ILiveStream
{
private readonly ILogger _logger;
private readonly StreamProxyService _proxyService;
private readonly string _originalItemId;
private MediaSourceInfo? _mediaSource;
/// <summary>
/// Initializes a new instance of the <see cref="SRFLiveStream"/> class.
/// </summary>
/// <param name="logger">The logger.</param>
/// <param name="proxyService">The stream proxy service.</param>
/// <param name="originalItemId">The original item ID.</param>
/// <param name="openToken">The open token.</param>
/// <param name="loggerFactory">The logger factory.</param>
public SRFLiveStream(
ILogger logger,
StreamProxyService proxyService,
string originalItemId,
string openToken,
ILoggerFactory loggerFactory)
{
_logger = logger;
_proxyService = proxyService;
_originalItemId = originalItemId;
OriginalStreamId = openToken;
UniqueId = openToken;
}
/// <inheritdoc />
public int ConsumerCount { get; set; }
/// <inheritdoc />
public string OriginalStreamId { get; set; }
/// <inheritdoc />
public string UniqueId { get; }
/// <inheritdoc />
public string TunerHostId => string.Empty;
/// <inheritdoc />
public bool EnableStreamSharing => false;
/// <inheritdoc />
public MediaSourceInfo MediaSource
{
get => _mediaSource ?? throw new InvalidOperationException("MediaSource not set");
set
{
_mediaSource = value;
_logger.LogInformation(
"SRFLiveStream MediaSource set - Id: {MediaSourceId}, Path: {Path}, OriginalItemId: {OriginalItemId}",
value.Id,
value.Path,
_originalItemId);
// When Jellyfin assigns a live stream ID (for transcoding), register the stream with that ID too
if (value.Id != _originalItemId)
{
_logger.LogInformation(
"Transcoding session detected - LiveStream ID {LiveStreamId} differs from original item ID {OriginalItemId}. Registering stream with both IDs.",
value.Id,
_originalItemId);
// Get the authenticated URL from the original registration
var authenticatedUrl = _proxyService.GetAuthenticatedUrl(_originalItemId);
if (authenticatedUrl != null)
{
// Register the same stream URL with the transcoding session ID
_proxyService.RegisterStream(value.Id, authenticatedUrl);
_logger.LogInformation("Registered stream for transcoding session ID: {LiveStreamId}", value.Id);
}
else
{
_logger.LogWarning("Could not find authenticated URL for original item {OriginalItemId}", _originalItemId);
}
}
}
}
/// <inheritdoc />
public Task Close()
{
_logger.LogInformation("Closing SRF live stream for item {OriginalItemId}", _originalItemId);
return Task.CompletedTask;
}
/// <inheritdoc />
public Task Open(CancellationToken cancellationToken)
{
_logger.LogInformation("Opening SRF live stream for item {OriginalItemId}", _originalItemId);
return Task.CompletedTask;
}
/// <inheritdoc />
public Stream GetStream()
{
throw new NotSupportedException("Direct stream access not supported for SRF streams");
}
/// <inheritdoc />
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}
/// <summary>
/// Releases the unmanaged resources used by the SRFLiveStream and optionally releases the managed resources.
/// </summary>
/// <param name="disposing">True to release both managed and unmanaged resources; false to release only unmanaged resources.</param>
private void Dispose(bool disposing)
{
if (disposing)
{
_logger.LogDebug("Disposing SRF live stream for item {OriginalItemId}", _originalItemId);
}
}
}
@@ -1,10 +1,12 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.IO;
using System.Linq; using System.Linq;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using Jellyfin.Plugin.SRFPlay.Api; using Jellyfin.Plugin.SRFPlay.Api;
using Jellyfin.Plugin.SRFPlay.Services; using Jellyfin.Plugin.SRFPlay.Services;
using MediaBrowser.Controller;
using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Library;
using MediaBrowser.Model.Dto; using MediaBrowser.Model.Dto;
@@ -23,6 +25,9 @@ public class SRFMediaProvider : IMediaSourceProvider
private readonly ILoggerFactory _loggerFactory; private readonly ILoggerFactory _loggerFactory;
private readonly MetadataCache _metadataCache; private readonly MetadataCache _metadataCache;
private readonly StreamUrlResolver _streamResolver; private readonly StreamUrlResolver _streamResolver;
private readonly StreamProxyService _proxyService;
private readonly IServerApplicationHost _appHost;
private readonly Dictionary<string, string> _openTokenToItemId = new();
/// <summary> /// <summary>
/// Initializes a new instance of the <see cref="SRFMediaProvider"/> class. /// Initializes a new instance of the <see cref="SRFMediaProvider"/> class.
@@ -30,15 +35,21 @@ public class SRFMediaProvider : IMediaSourceProvider
/// <param name="loggerFactory">The logger factory.</param> /// <param name="loggerFactory">The logger factory.</param>
/// <param name="metadataCache">The metadata cache.</param> /// <param name="metadataCache">The metadata cache.</param>
/// <param name="streamResolver">The stream URL resolver.</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 SRFMediaProvider( public SRFMediaProvider(
ILoggerFactory loggerFactory, ILoggerFactory loggerFactory,
MetadataCache metadataCache, MetadataCache metadataCache,
StreamUrlResolver streamResolver) StreamUrlResolver streamResolver,
StreamProxyService proxyService,
IServerApplicationHost appHost)
{ {
_loggerFactory = loggerFactory; _loggerFactory = loggerFactory;
_logger = loggerFactory.CreateLogger<SRFMediaProvider>(); _logger = loggerFactory.CreateLogger<SRFMediaProvider>();
_metadataCache = metadataCache; _metadataCache = metadataCache;
_streamResolver = streamResolver; _streamResolver = streamResolver;
_proxyService = proxyService;
_appHost = appHost;
} }
/// <summary> /// <summary>
@@ -52,24 +63,35 @@ public class SRFMediaProvider : IMediaSourceProvider
/// <param name="item">The item.</param> /// <param name="item">The item.</param>
/// <param name="cancellationToken">The cancellation token.</param> /// <param name="cancellationToken">The cancellation token.</param>
/// <returns>List of media sources.</returns> /// <returns>List of media sources.</returns>
public Task<IEnumerable<MediaSourceInfo>> GetMediaSources(BaseItem item, CancellationToken cancellationToken) public async Task<IEnumerable<MediaSourceInfo>> GetMediaSources(BaseItem item, CancellationToken cancellationToken)
{ {
var sources = new List<MediaSourceInfo>(); var sources = new List<MediaSourceInfo>();
try 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 // Check if this is an SRF item
if (!item.ProviderIds.TryGetValue("SRF", out var urn) || string.IsNullOrEmpty(urn)) if (!item.ProviderIds.TryGetValue("SRF", out var urn) || string.IsNullOrEmpty(urn))
{ {
return Task.FromResult<IEnumerable<MediaSourceInfo>>(sources); _logger.LogDebug("Item {ItemName} is not an SRF item, returning empty sources", item.Name);
return sources;
} }
_logger.LogDebug("Getting media sources for URN: {Urn}", urn); _logger.LogInformation("Getting media sources for URN: {Urn}, Item: {ItemName}", urn, item.Name);
var config = Plugin.Instance?.Configuration; var config = Plugin.Instance?.Configuration;
if (config == null) if (config == null)
{ {
return Task.FromResult<IEnumerable<MediaSourceInfo>>(sources); return sources;
} }
// For scheduled livestreams, use shorter cache TTL (5 minutes) so they refresh when they go live // For scheduled livestreams, use shorter cache TTL (5 minutes) so they refresh when they go live
@@ -85,7 +107,7 @@ public class SRFMediaProvider : IMediaSourceProvider
if (mediaComposition == null) if (mediaComposition == null)
{ {
using var apiClient = new SRFApiClient(_loggerFactory); using var apiClient = new SRFApiClient(_loggerFactory);
mediaComposition = apiClient.GetMediaCompositionByUrnAsync(urn, cancellationToken).GetAwaiter().GetResult(); mediaComposition = await apiClient.GetMediaCompositionByUrnAsync(urn, cancellationToken).ConfigureAwait(false);
if (mediaComposition != null) if (mediaComposition != null)
{ {
@@ -96,7 +118,7 @@ public class SRFMediaProvider : IMediaSourceProvider
if (mediaComposition?.ChapterList == null || mediaComposition.ChapterList.Count == 0) if (mediaComposition?.ChapterList == null || mediaComposition.ChapterList.Count == 0)
{ {
_logger.LogWarning("No chapters found for URN: {Urn}", urn); _logger.LogWarning("No chapters found for URN: {Urn}", urn);
return Task.FromResult<IEnumerable<MediaSourceInfo>>(sources); return sources;
} }
// Get the first chapter (main video) // Get the first chapter (main video)
@@ -106,52 +128,26 @@ public class SRFMediaProvider : IMediaSourceProvider
if (_streamResolver.IsContentExpired(chapter)) if (_streamResolver.IsContentExpired(chapter))
{ {
_logger.LogWarning("Content expired for URN: {Urn}, ValidTo: {ValidTo}", urn, chapter.ValidTo); _logger.LogWarning("Content expired for URN: {Urn}, ValidTo: {ValidTo}", urn, chapter.ValidTo);
return Task.FromResult<IEnumerable<MediaSourceInfo>>(sources); return sources;
} }
// Check if content has playable streams // Check if content has playable streams
if (!_streamResolver.HasPlayableContent(chapter)) if (!_streamResolver.HasPlayableContent(chapter))
{ {
_logger.LogWarning("No playable content found for URN: {Urn}", urn); _logger.LogWarning("No playable content found for URN: {Urn}", urn);
return Task.FromResult<IEnumerable<MediaSourceInfo>>(sources); return sources;
} }
// Get stream URL based on quality preference // Get stream URL based on quality preference
var streamUrl = _streamResolver.GetStreamUrl(chapter, config.QualityPreference); var streamUrl = _streamResolver.GetStreamUrl(chapter, config.QualityPreference);
// Check if this is an upcoming livestream that hasn't started yet // For scheduled livestreams, always fetch fresh data to ensure stream URL is current
var isUpcomingLivestream = chapter.Type == "SCHEDULED_LIVESTREAM" && string.IsNullOrEmpty(streamUrl); if (chapter.Type == "SCHEDULED_LIVESTREAM" && string.IsNullOrEmpty(streamUrl))
if (string.IsNullOrEmpty(streamUrl) && !isUpcomingLivestream)
{ {
_logger.LogWarning("Could not resolve stream URL for URN: {Urn}", urn); _logger.LogDebug("URN {Urn}: Scheduled livestream has no stream URL, fetching fresh data", urn);
return Task.FromResult<IEnumerable<MediaSourceInfo>>(sources);
}
// For upcoming livestreams, check if the event has started
if (isUpcomingLivestream)
{
if (chapter.ValidFrom != null)
{
var eventStart = chapter.ValidFrom.Value.ToUniversalTime();
var now = DateTime.UtcNow;
if (eventStart > now)
{
_logger.LogInformation(
"URN {Urn}: Livestream '{Title}' hasn't started yet (starts at {ValidFrom}). User should refresh when live.",
urn,
chapter.Title,
chapter.ValidFrom);
// Return empty sources - event not yet playable
return Task.FromResult<IEnumerable<MediaSourceInfo>>(sources);
}
}
// Event should be live now - re-fetch media composition without cache
using var freshApiClient = new SRFApiClient(_loggerFactory); using var freshApiClient = new SRFApiClient(_loggerFactory);
var freshMediaComposition = freshApiClient.GetMediaCompositionByUrnAsync(urn, cancellationToken).GetAwaiter().GetResult(); var freshMediaComposition = await freshApiClient.GetMediaCompositionByUrnAsync(urn, cancellationToken).ConfigureAwait(false);
if (freshMediaComposition?.ChapterList != null && freshMediaComposition.ChapterList.Count > 0) if (freshMediaComposition?.ChapterList != null && freshMediaComposition.ChapterList.Count > 0)
{ {
@@ -163,70 +159,119 @@ public class SRFMediaProvider : IMediaSourceProvider
// Update cache with fresh data // Update cache with fresh data
_metadataCache.SetMediaComposition(urn, freshMediaComposition); _metadataCache.SetMediaComposition(urn, freshMediaComposition);
chapter = freshChapter; chapter = freshChapter;
_logger.LogInformation("URN {Urn}: Livestream is now live, got fresh stream URL", urn); _logger.LogInformation("URN {Urn}: Got fresh stream URL for scheduled livestream", urn);
} }
else }
}
if (string.IsNullOrEmpty(streamUrl))
{ {
_logger.LogWarning("URN {Urn}: Livestream should be live but still no stream URL available", urn); _logger.LogWarning("Could not resolve stream URL for URN: {Urn}", urn);
return Task.FromResult<IEnumerable<MediaSourceInfo>>(sources); return sources;
}
}
else
{
_logger.LogWarning("URN {Urn}: Failed to fetch fresh media composition for livestream", urn);
return Task.FromResult<IEnumerable<MediaSourceInfo>>(sources);
}
} }
// Authenticate the stream URL (required for all SRF streams, especially livestreams) // Authenticate the stream URL (required for all SRF streams, especially livestreams)
if (!string.IsNullOrEmpty(streamUrl)) if (!string.IsNullOrEmpty(streamUrl))
{ {
streamUrl = _streamResolver.GetAuthenticatedStreamUrlAsync(streamUrl, cancellationToken).GetAwaiter().GetResult(); streamUrl = await _streamResolver.GetAuthenticatedStreamUrlAsync(streamUrl, cancellationToken).ConfigureAwait(false);
_logger.LogDebug("Authenticated stream URL for URN: {Urn}", urn); _logger.LogDebug("Authenticated stream URL for URN: {Urn}", urn);
} }
// Create media source // Register stream with proxy service
var itemIdStr = item.Id.ToString("N"); // Use hex format without dashes
_proxyService.RegisterStream(itemIdStr, streamUrl);
// Get the server URL for proxy - prefer configured public URL for remote clients
var serverUrl = !string.IsNullOrWhiteSpace(config.PublicServerUrl)
? config.PublicServerUrl.TrimEnd('/') // Use configured public URL (important for Android/remote clients)
: _appHost.GetSmartApiUrl(string.Empty); // Fall back to Jellyfin's smart URL resolution
// Detect if this is a live stream
var isLiveStream = chapter.Type == "SCHEDULED_LIVESTREAM" || urn.Contains("livestream", StringComparison.OrdinalIgnoreCase);
// Generate an open token for this media source (used to track transcoding sessions)
var openToken = Guid.NewGuid().ToString("N");
_openTokenToItemId[openToken] = itemIdStr;
_logger.LogDebug("Created open token {OpenToken} for item {ItemId}", openToken, itemIdStr);
// Create proxy URL using token instead of item ID in path
// This prevents Jellyfin from rewriting the URL during transcoding
var proxyUrl = $"{serverUrl}/Plugins/SRFPlay/Proxy/{itemIdStr}/master.m3u8?token={openToken}";
_logger.LogInformation(
"Using proxy URL for item {ItemId}: {ProxyUrl} (PublicServerUrl configured: {IsPublicConfigured})",
itemIdStr,
proxyUrl,
!string.IsNullOrWhiteSpace(config.PublicServerUrl));
// Create media source using proxy URL - enables DirectPlay!
var mediaSource = new MediaSourceInfo var mediaSource = new MediaSourceInfo
{ {
Id = urn, Id = itemIdStr, // Must match the ID used in proxy URL registration
Name = chapter.Title, Name = chapter.Title,
Path = streamUrl, Path = proxyUrl, // Proxy URL instead of direct Akamai URL
Protocol = MediaProtocol.Http, Protocol = MediaProtocol.Http,
Container = "m3u8", Container = "hls",
SupportsDirectStream = true, SupportsDirectStream = true,
SupportsDirectPlay = true, SupportsDirectPlay = true, // ✅ Enabled! Proxy handles auth
SupportsTranscoding = true, SupportsTranscoding = true,
IsRemote = true, IsRemote = false, // False because it's a local proxy endpoint
Type = MediaSourceType.Default, Type = MediaSourceType.Default,
RunTimeTicks = chapter.Duration > 0 ? TimeSpan.FromMilliseconds(chapter.Duration).Ticks : null, RunTimeTicks = chapter.Duration > 0 ? TimeSpan.FromMilliseconds(chapter.Duration).Ticks : null,
VideoType = VideoType.VideoFile, VideoType = VideoType.VideoFile,
IsInfiniteStream = false, IsInfiniteStream = isLiveStream, // True for live streams!
RequiresOpening = false, RequiresOpening = true, // Enable to handle transcoding sessions
RequiresClosing = false, RequiresClosing = false,
SupportsProbing = true SupportsProbing = false, // Disable probing for proxy URLs
}; ReadAtNativeFramerate = isLiveStream, // Read at native framerate for live streams
OpenToken = openToken, // Token to identify this media source
// Add video stream info MediaStreams = new List<MediaBrowser.Model.Entities.MediaStream>
mediaSource.MediaStreams = new List<MediaStream>
{ {
new MediaStream new MediaBrowser.Model.Entities.MediaStream
{ {
Type = MediaStreamType.Video, Type = MediaStreamType.Video,
Codec = "h264", Codec = "h264",
Profile = "high",
IsInterlaced = false, IsInterlaced = false,
IsDefault = true IsDefault = true,
Index = 0
},
new MediaBrowser.Model.Entities.MediaStream
{
Type = MediaStreamType.Audio,
Codec = "aac",
IsDefault = true,
Index = 1
}
} }
}; };
sources.Add(mediaSource); sources.Add(mediaSource);
_logger.LogInformation("Resolved stream URL for {Title}: {Url}", chapter.Title, streamUrl); _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}",
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);
} }
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogError(ex, "Error getting media sources for item: {Name}", item.Name); _logger.LogError(ex, "Error getting media sources for item: {Name}", item.Name);
} }
return Task.FromResult<IEnumerable<MediaSourceInfo>>(sources); return sources;
} }
/// <summary> /// <summary>
@@ -237,14 +282,33 @@ public class SRFMediaProvider : IMediaSourceProvider
/// <returns>The direct stream provider.</returns> /// <returns>The direct stream provider.</returns>
public Task<IDirectStreamProvider?> GetDirectStreamProviderByUniqueId(string uniqueId, CancellationToken cancellationToken) public Task<IDirectStreamProvider?> GetDirectStreamProviderByUniqueId(string uniqueId, CancellationToken cancellationToken)
{ {
_logger.LogInformation("GetDirectStreamProviderByUniqueId called with uniqueId: {UniqueId}", uniqueId);
// Not needed for HTTP streams // Not needed for HTTP streams
return Task.FromResult<IDirectStreamProvider?>(null); return Task.FromResult<IDirectStreamProvider?>(null);
} }
/// <inheritdoc /> /// <inheritdoc />
public Task<ILiveStream> OpenMediaSource(string openToken, List<ILiveStream> currentLiveStreams, CancellationToken cancellationToken) public async Task<ILiveStream> OpenMediaSource(string openToken, List<ILiveStream> currentLiveStreams, CancellationToken cancellationToken)
{ {
// Not needed for static HTTP streams _logger.LogInformation("OpenMediaSource called with openToken: {OpenToken}", openToken);
throw new NotImplementedException();
// Look up the original item ID from the open token
if (!_openTokenToItemId.TryGetValue(openToken, out var originalItemId))
{
_logger.LogError("Open token {OpenToken} not found in registry", openToken);
throw new InvalidOperationException($"Open token {openToken} not found");
}
_logger.LogInformation("Open token {OpenToken} maps to original item ID: {ItemId}", openToken, originalItemId);
// Create a live stream wrapper
var liveStream = new SRFLiveStream(
_logger,
_proxyService,
originalItemId,
openToken,
_loggerFactory);
return await Task.FromResult<ILiveStream>(liveStream).ConfigureAwait(false);
} }
} }
@@ -25,6 +25,7 @@ public class ServiceRegistrator : IPluginServiceRegistrator
serviceCollection.AddSingleton<ContentExpirationService>(); serviceCollection.AddSingleton<ContentExpirationService>();
serviceCollection.AddSingleton<ContentRefreshService>(); serviceCollection.AddSingleton<ContentRefreshService>();
serviceCollection.AddSingleton<CategoryService>(); serviceCollection.AddSingleton<CategoryService>();
serviceCollection.AddSingleton<StreamProxyService>(); // Stream proxy service
// Register metadata providers // Register metadata providers
serviceCollection.AddSingleton<SRFSeriesProvider>(); serviceCollection.AddSingleton<SRFSeriesProvider>();
@@ -0,0 +1,561 @@
using System;
using System.Collections.Concurrent;
using System.Linq;
using System.Net.Http;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using System.Web;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.SRFPlay.Services;
/// <summary>
/// Service for proxying SRF Play streams and managing authentication.
/// </summary>
public class StreamProxyService : IDisposable
{
private readonly ILogger<StreamProxyService> _logger;
private readonly StreamUrlResolver _streamResolver;
private readonly HttpClient _httpClient;
private readonly ConcurrentDictionary<string, StreamInfo> _streamMappings;
private bool _disposed;
/// <summary>
/// Initializes a new instance of the <see cref="StreamProxyService"/> class.
/// </summary>
/// <param name="logger">The logger.</param>
/// <param name="streamResolver">The stream URL resolver.</param>
public StreamProxyService(ILogger<StreamProxyService> logger, StreamUrlResolver streamResolver)
{
_logger = logger;
_streamResolver = streamResolver;
_httpClient = new HttpClient
{
Timeout = TimeSpan.FromSeconds(30)
};
_streamMappings = new ConcurrentDictionary<string, StreamInfo>();
}
/// <summary>
/// Registers a stream for proxying.
/// </summary>
/// <param name="itemId">The item ID.</param>
/// <param name="authenticatedUrl">The authenticated stream URL.</param>
public void RegisterStream(string itemId, string authenticatedUrl)
{
var tokenExpiry = ExtractTokenExpiry(authenticatedUrl);
var unauthenticatedUrl = StripAuthenticationFromUrl(authenticatedUrl);
var streamInfo = new StreamInfo
{
AuthenticatedUrl = authenticatedUrl,
UnauthenticatedUrl = unauthenticatedUrl,
RegisteredAt = DateTime.UtcNow,
TokenExpiresAt = tokenExpiry
};
// Register with the provided item ID
_streamMappings.AddOrUpdate(itemId, streamInfo, (key, old) => streamInfo);
// Also register with alternative GUID formats to handle Jellyfin's ID transformations
if (Guid.TryParse(itemId, out var guid))
{
var formats = new[]
{
guid.ToString("N"), // Without dashes: 00000000000000000000000000000000
guid.ToString("D"), // With dashes: 00000000-0000-0000-0000-000000000000
guid.ToString("B"), // With braces: {00000000-0000-0000-0000-000000000000}
};
foreach (var format in formats)
{
if (format != itemId) // Don't duplicate the original
{
_streamMappings.AddOrUpdate(format, streamInfo, (key, old) => streamInfo);
}
}
_logger.LogDebug("Registered stream with {Count} GUID format variations", formats.Length);
}
if (tokenExpiry.HasValue)
{
_logger.LogInformation(
"Registered stream for item {ItemId} (token expires at {ExpiresAt} UTC): {Url}",
itemId,
tokenExpiry.Value,
authenticatedUrl);
}
else
{
_logger.LogInformation("Registered stream for item {ItemId}: {Url}", itemId, authenticatedUrl);
}
}
/// <summary>
/// Gets the authenticated URL for an item.
/// </summary>
/// <param name="itemId">The item ID.</param>
/// <returns>The authenticated URL, or null if not found or expired.</returns>
public string? GetAuthenticatedUrl(string itemId)
{
_logger.LogInformation("GetAuthenticatedUrl called for itemId: {ItemId}", itemId);
// Try direct lookup first
if (_streamMappings.TryGetValue(itemId, out var streamInfo))
{
_logger.LogInformation("✅ Found stream by direct lookup for itemId: {ItemId}", itemId);
return ValidateAndReturnStream(itemId, streamInfo);
}
_logger.LogWarning("❌ No direct match for itemId: {ItemId}, trying fallbacks... (Registered streams: {Count})", itemId, _streamMappings.Count);
// Fallback: Try to find by GUID variations (with/without dashes)
// This handles cases where Jellyfin uses different GUID formats
var normalizedId = NormalizeGuid(itemId);
if (normalizedId != null)
{
foreach (var kvp in _streamMappings)
{
var normalizedKey = NormalizeGuid(kvp.Key);
if (normalizedKey != null && normalizedKey == normalizedId)
{
_logger.LogInformation(
"Found stream by GUID normalization - Requested: {RequestedId}, Registered: {RegisteredId}",
itemId,
kvp.Key);
var url = ValidateAndReturnStream(kvp.Key, kvp.Value);
if (url != null)
{
return url; // Found valid stream
}
// Stream found but expired, continue to next fallback
_logger.LogDebug("GUID-normalized stream was expired, trying other fallbacks");
break; // Exit foreach, continue to next fallback strategy
}
}
}
// Last resort: Use active stream fallbacks (helps when Jellyfin creates random transcoding session IDs)
var activeStreams = _streamMappings.Where(kvp =>
{
if (!kvp.Value.TokenExpiresAt.HasValue)
{
return true; // No expiry
}
return DateTime.UtcNow < kvp.Value.TokenExpiresAt.Value;
}).ToList();
if (activeStreams.Count == 1)
{
_logger.LogWarning(
"No exact match for {RequestedId}, but found single active stream {RegisteredId} - using as fallback",
itemId,
activeStreams[0].Key);
return ValidateAndReturnStream(activeStreams[0].Key, activeStreams[0].Value);
}
// If multiple active streams, use the most recently registered one (likely the one being transcoded)
// This handles cases where Jellyfin creates a random transcoding session ID seconds after registration
if (activeStreams.Count > 1)
{
var mostRecent = activeStreams.OrderByDescending(kvp => kvp.Value.RegisteredAt).First();
var age = DateTime.UtcNow - mostRecent.Value.RegisteredAt;
// Only use this fallback if the stream was registered very recently (within 30 seconds)
// 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",
itemId,
mostRecent.Key,
age.TotalSeconds);
return ValidateAndReturnStream(mostRecent.Key, mostRecent.Value);
}
}
_logger.LogWarning(
"No stream mapping found for item {ItemId}. Active streams: {Count}. Registered IDs: {RegisteredIds}",
itemId,
activeStreams.Count,
string.Join(", ", _streamMappings.Keys));
return null;
}
/// <summary>
/// Validates a stream and returns its URL if valid.
/// </summary>
private string? ValidateAndReturnStream(string itemId, StreamInfo streamInfo)
{
// Check if token has expired
if (streamInfo.TokenExpiresAt.HasValue)
{
var now = DateTime.UtcNow;
if (now >= streamInfo.TokenExpiresAt.Value)
{
_logger.LogWarning(
"Token expired for item {ItemId} (expired at {ExpiresAt}, now is {Now}) - attempting to refresh",
itemId,
streamInfo.TokenExpiresAt.Value,
now);
// Try to refresh the token
var refreshedUrl = RefreshToken(itemId, streamInfo);
if (refreshedUrl != null)
{
_logger.LogInformation("Successfully refreshed token for item {ItemId}", itemId);
return refreshedUrl;
}
_logger.LogWarning("Failed to refresh token for item {ItemId}, removing mapping", itemId);
_streamMappings.TryRemove(itemId, out _);
return null;
}
_logger.LogDebug(
"Token valid for item {ItemId} (expires at {ExpiresAt}, {TimeLeft} remaining)",
itemId,
streamInfo.TokenExpiresAt.Value,
streamInfo.TokenExpiresAt.Value - now);
}
return streamInfo.AuthenticatedUrl;
}
/// <summary>
/// Attempts to refresh an expired token.
/// </summary>
private string? RefreshToken(string itemId, StreamInfo streamInfo)
{
if (string.IsNullOrEmpty(streamInfo.UnauthenticatedUrl))
{
_logger.LogWarning("Cannot refresh token for {ItemId} - no unauthenticated URL stored", itemId);
return null;
}
try
{
// Re-authenticate the stream URL synchronously (blocking call)
var newAuthenticatedUrl = _streamResolver.GetAuthenticatedStreamUrlAsync(
streamInfo.UnauthenticatedUrl,
CancellationToken.None).GetAwaiter().GetResult();
if (string.IsNullOrEmpty(newAuthenticatedUrl))
{
return null;
}
// Update the stream info with the new token
var newTokenExpiry = ExtractTokenExpiry(newAuthenticatedUrl);
streamInfo.AuthenticatedUrl = newAuthenticatedUrl;
streamInfo.TokenExpiresAt = newTokenExpiry;
_logger.LogInformation(
"Refreshed token for item {ItemId} (new expiry: {ExpiresAt} UTC)",
itemId,
newTokenExpiry);
return newAuthenticatedUrl;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error refreshing token for item {ItemId}", itemId);
return null;
}
}
/// <summary>
/// Strips authentication parameters from a URL to get the base unauthenticated URL.
/// </summary>
private string StripAuthenticationFromUrl(string url)
{
try
{
var uri = new Uri(url);
var query = uri.Query;
// Remove hdnts authentication parameter (Akamai token authentication)
if (query.Contains("hdnts=", StringComparison.OrdinalIgnoreCase))
{
// Keep other parameters like caption, webvttbaseurl
var queryParams = System.Web.HttpUtility.ParseQueryString(query);
queryParams.Remove("hdnts");
var newQuery = queryParams.Count > 0 ? "?" + queryParams.ToString() : string.Empty;
return $"{uri.Scheme}://{uri.Host}{uri.AbsolutePath}{newQuery}";
}
return url;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to strip authentication from URL, using as-is");
return url;
}
}
/// <summary>
/// Normalizes a GUID string to a consistent format for comparison.
/// </summary>
private string? NormalizeGuid(string input)
{
if (string.IsNullOrEmpty(input))
{
return null;
}
// Try to parse as GUID (handles both with and without dashes)
if (Guid.TryParse(input, out var guid))
{
return guid.ToString("N"); // Always return format without dashes
}
return null;
}
/// <summary>
/// Fetches and rewrites an HLS manifest to use proxy URLs.
/// </summary>
/// <param name="itemId">The item ID.</param>
/// <param name="baseProxyUrl">The base proxy URL (e.g., https://jellyfin-server/Plugins/SRFPlay/Proxy/{itemId}).</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The rewritten manifest content.</returns>
public async Task<string?> GetRewrittenManifestAsync(
string itemId,
string baseProxyUrl,
CancellationToken cancellationToken = default)
{
var authenticatedUrl = GetAuthenticatedUrl(itemId);
if (authenticatedUrl == null)
{
return null;
}
try
{
_logger.LogDebug("Fetching manifest from: {Url}", authenticatedUrl);
var manifestContent = await _httpClient.GetStringAsync(authenticatedUrl, cancellationToken).ConfigureAwait(false);
// 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);
return rewrittenContent;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to fetch manifest for item {ItemId} from {Url}", itemId, authenticatedUrl);
return null;
}
}
/// <summary>
/// Fetches a segment from the original source.
/// </summary>
/// <param name="itemId">The item ID.</param>
/// <param name="segmentPath">The segment path.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The segment content as bytes.</returns>
public async Task<byte[]?> GetSegmentAsync(
string itemId,
string segmentPath,
CancellationToken cancellationToken = default)
{
var authenticatedUrl = GetAuthenticatedUrl(itemId);
if (authenticatedUrl == null)
{
return null;
}
try
{
// Build the full segment URL by combining the base URL with the segment path
var baseUri = new Uri(authenticatedUrl);
var baseUrl = $"{baseUri.Scheme}://{baseUri.Host}{string.Join('/', baseUri.AbsolutePath.Split('/')[..^1])}";
// Extract query parameters (auth tokens) from authenticated URL
var queryParams = baseUri.Query;
// Build full segment URL
var segmentUrl = $"{baseUrl}/{segmentPath}{queryParams}";
_logger.LogDebug("Fetching segment: {SegmentUrl}", segmentUrl);
var segmentData = await _httpClient.GetByteArrayAsync(segmentUrl, cancellationToken).ConfigureAwait(false);
_logger.LogDebug("Successfully fetched segment {SegmentPath} ({Size} bytes)", segmentPath, segmentData.Length);
return segmentData;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to fetch segment {SegmentPath} for item {ItemId}", segmentPath, itemId);
return null;
}
}
/// <summary>
/// Rewrites URLs in HLS manifest to point to proxy.
/// </summary>
/// <param name="manifestContent">The original manifest content.</param>
/// <param name="originalBaseUrl">The original base URL.</param>
/// <param name="proxyBaseUrl">The proxy base URL.</param>
/// <returns>The rewritten manifest.</returns>
private string RewriteManifestUrls(string manifestContent, string originalBaseUrl, string proxyBaseUrl)
{
var baseUri = new Uri(originalBaseUrl);
var baseUrl = $"{baseUri.Scheme}://{baseUri.Host}{string.Join('/', baseUri.AbsolutePath.Split('/')[..^1])}";
// Extract query parameters from proxyBaseUrl to propagate them
var queryParams = string.Empty;
var queryStart = proxyBaseUrl.IndexOf('?', StringComparison.Ordinal);
if (queryStart >= 0)
{
queryParams = proxyBaseUrl[queryStart..];
proxyBaseUrl = proxyBaseUrl[..queryStart]; // Remove query from base URL
_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 =>
{
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))
{
// Rewrite absolute URLs to proxy
var relativePath = url.Replace(baseUrl + "/", string.Empty, StringComparison.Ordinal);
return $"\n{proxyBaseUrl}/{relativePath}{queryParams}";
}
// Relative URL - rewrite to proxy
return $"\n{proxyBaseUrl}/{url}{queryParams}";
});
return rewritten;
}
/// <summary>
/// Extracts the token expiry time from a stream URL with hdnts parameter.
/// </summary>
/// <param name="url">The authenticated stream URL.</param>
/// <returns>The expiry time, or null if not found.</returns>
private DateTime? ExtractTokenExpiry(string url)
{
try
{
var uri = new Uri(url);
var query = uri.Query;
// Parse the hdnts parameter (e.g., "exp=1763282021")
var match = Regex.Match(query, @"exp=(\d+)");
if (match.Success && long.TryParse(match.Groups[1].Value, out var unixTimestamp))
{
// Convert Unix timestamp to DateTime
var expiry = DateTimeOffset.FromUnixTimeSeconds(unixTimestamp).UtcDateTime;
_logger.LogDebug("Extracted token expiry from URL: {Expiry} UTC (unix: {Unix})", expiry, unixTimestamp);
return expiry;
}
_logger.LogDebug("No token expiry found in URL: {Url}", url);
return null;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to extract token expiry from URL: {Url}", url);
return null;
}
}
/// <summary>
/// Cleans up old and expired stream mappings.
/// </summary>
public void CleanupOldMappings()
{
var cutoff = DateTime.UtcNow.AddHours(-24);
var now = DateTime.UtcNow;
var keysToRemove = new System.Collections.Generic.List<string>();
foreach (var kvp in _streamMappings)
{
var shouldRemove = false;
// Remove if registered more than 24 hours ago
if (kvp.Value.RegisteredAt < cutoff)
{
shouldRemove = true;
_logger.LogDebug("Marking item {ItemId} for cleanup (old registration)", kvp.Key);
}
// Remove if token has expired
if (kvp.Value.TokenExpiresAt.HasValue && kvp.Value.TokenExpiresAt.Value <= now)
{
shouldRemove = true;
_logger.LogDebug("Marking item {ItemId} for cleanup (expired token)", kvp.Key);
}
if (shouldRemove)
{
keysToRemove.Add(kvp.Key);
}
}
foreach (var key in keysToRemove)
{
_streamMappings.TryRemove(key, out _);
}
if (keysToRemove.Count > 0)
{
_logger.LogInformation("Cleaned up {Count} old/expired stream mappings", keysToRemove.Count);
}
}
/// <summary>
/// Disposes the service.
/// </summary>
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}
/// <summary>
/// Disposes the service.
/// </summary>
/// <param name="disposing">True if disposing.</param>
protected virtual void Dispose(bool disposing)
{
if (_disposed)
{
return;
}
if (disposing)
{
_httpClient?.Dispose();
}
_disposed = true;
}
/// <summary>
/// Stream information.
/// </summary>
private sealed class StreamInfo
{
public string AuthenticatedUrl { get; set; } = string.Empty;
public string UnauthenticatedUrl { get; set; } = string.Empty;
public DateTime RegisteredAt { get; set; }
public DateTime? TokenExpiresAt { get; set; }
}
}
@@ -87,6 +87,17 @@ public class StreamUrlResolver : IDisposable
chapter.Id, chapter.Id,
hlsResources.Count); hlsResources.Count);
// Log all HLS resources with their quality info to help debug quality selection
foreach (var resource in hlsResources)
{
_logger.LogInformation(
"Available HLS resource - Quality={Quality}, Protocol={Protocol}, Streaming={Streaming}, URL={Url}",
resource.Quality ?? "NULL",
resource.Protocol ?? "NULL",
resource.Streaming ?? "NULL",
resource.Url);
}
if (hlsResources.Count == 0) if (hlsResources.Count == 0)
{ {
_logger.LogWarning("No HLS resources found for chapter: {ChapterId}", chapter.Id); _logger.LogWarning("No HLS resources found for chapter: {ChapterId}", chapter.Id);
@@ -114,6 +125,10 @@ public class StreamUrlResolver : IDisposable
} }
// Select based on quality preference // Select based on quality preference
_logger.LogInformation(
"Selecting stream with quality preference: {QualityPreference}",
qualityPreference);
Resource? selectedResource = qualityPreference switch Resource? selectedResource = qualityPreference switch
{ {
QualityPreference.HD => SelectHDResource(hlsResources) ?? SelectBestAvailableResource(hlsResources), QualityPreference.HD => SelectHDResource(hlsResources) ?? SelectBestAvailableResource(hlsResources),
@@ -124,10 +139,10 @@ public class StreamUrlResolver : IDisposable
if (selectedResource != null) if (selectedResource != null)
{ {
_logger.LogDebug( _logger.LogInformation(
"Selected stream for chapter {ChapterId}: Quality={Quality}, Protocol={Protocol}, URL={Url}", "Selected stream for chapter {ChapterId}: Quality={Quality}, Protocol={Protocol}, URL={Url}",
chapter.Id, chapter.Id,
selectedResource.Quality, selectedResource.Quality ?? "NULL",
selectedResource.Protocol, selectedResource.Protocol,
selectedResource.Url); selectedResource.Url);
return selectedResource.Url; return selectedResource.Url;
@@ -144,23 +159,6 @@ public class StreamUrlResolver : IDisposable
/// <returns>True if playable content is available.</returns> /// <returns>True if playable content is available.</returns>
public bool HasPlayableContent(Chapter chapter) public bool HasPlayableContent(Chapter chapter)
{ {
// For scheduled livestreams that haven't started yet, resources won't exist
// but we still want to show them. The stream will become available when the event starts.
if (chapter?.Type == "SCHEDULED_LIVESTREAM")
{
var now = DateTime.UtcNow;
var hasStarted = chapter.ValidFrom == null || chapter.ValidFrom.Value.ToUniversalTime() <= now;
if (!hasStarted)
{
_logger.LogInformation(
"Scheduled livestream '{Title}' hasn't started yet (starts at {ValidFrom}), will be playable when live",
chapter.Title,
chapter.ValidFrom);
return true; // Show it, stream will be available when event starts
}
}
if (chapter?.ResourceList == null || chapter.ResourceList.Count == 0) if (chapter?.ResourceList == null || chapter.ResourceList.Count == 0)
{ {
return false; return false;
-227
View File
@@ -1,227 +0,0 @@
# Network-Level Gateway Routing for SRF Content
This guide explains how to configure network-level routing to direct all SRF-related traffic (including video streams) through your Swiss gateway/proxy at 192.168.1.37.
## Overview
Instead of configuring proxy support at the application level, this approach uses Linux policy-based routing to redirect traffic destined for SRF domains through an alternate gateway. This ensures:
- API requests to `il.srgssr.ch` go through the gateway
- Video stream requests to `srf-vod-amd.akamaized.net` (and other CDNs) go through the gateway
- ffprobe and ffmpeg automatically use the gateway
- No application configuration needed - transparent to Jellyfin
## Prerequisites
- Root access to the Jellyfin server (192.168.1.4)
- Gateway at 192.168.1.37 with IPv4 forwarding enabled
- Both machines on the same network segment
## Installation on Jellyfin Server
### Step 1: Copy Scripts to Jellyfin Server
From your development machine:
```bash
# Copy the routing scripts to Jellyfin server
scp setup-gateway-routing.sh cleanup-gateway-routing.sh user@192.168.1.4:~
```
### Step 2: SSH to Jellyfin Server
```bash
ssh user@192.168.1.4
```
### Step 3: Make Scripts Executable
```bash
chmod +x setup-gateway-routing.sh cleanup-gateway-routing.sh
```
### Step 4: Run Setup Script
```bash
sudo ./setup-gateway-routing.sh
```
When prompted:
- **Gateway IP**: `192.168.1.37`
- **Network interface**: Find your interface name first with `ip -br link show` (common names: `eth0`, `ens18`, `enp0s3`)
The script will:
1. Create a custom routing table named `srf_gateway`
2. Resolve IP addresses for all SRF domains
3. Add routes through your gateway (192.168.1.37)
4. Create routing rules for policy-based routing
5. Set up a systemd service for persistence across reboots
### Step 5: Verify Routing
Check that routes are configured:
```bash
# Show the custom routing table
ip route show table srf_gateway
# Show routing rules
ip rule show | grep srf_gateway
# Test routing for Integration Layer API
ip route get $(dig +short il.srgssr.ch | head -1)
# Test routing for video CDN
ip route get $(dig +short srf-vod-amd.akamaized.net | head -1)
```
### Step 6: Test from Jellyfin Server
Test that the routing is working:
```bash
# Test API access
curl -v "https://il.srgssr.ch/integrationlayer/2.0/mediaComposition/byUrn/urn:srf:video:b84713f0-f81b-460f-9b0f-d0517310fb4f.json" 2>&1 | grep -E "(x-location|HTTP/)"
# Should show: x-location: CH
```
### Step 7: Restart Jellyfin
```bash
sudo systemctl restart jellyfin
```
## What Gets Routed
The following domains are routed through the gateway:
- `il.srgssr.ch` - Integration Layer API (metadata)
- `www.srf.ch` - Main SRF site
- `www.rts.ch` - RTS (Radio Télévision Suisse)
- `www.rsi.ch` - RSI (Radiotelevisione svizzera)
- `www.rtr.ch` - RTR (Radiotelevisiun Svizra Rumantscha)
- `www.swi.ch` - SWI (swissinfo)
- `srf-vod-amd.akamaized.net` - SRF video CDN
- `rts-vod-amd.akamaized.net` - RTS video CDN
- `rsi-vod-amd.akamaized.net` - RSI video CDN
- `play-web.srf.ch` - Play web interface
- `il-stage.srgssr.ch` - Staging environment
## How It Works
1. **DNS Resolution**: Domains are resolved to IP addresses
2. **Routing Table**: A custom routing table (`srf_gateway`) is created with routes through the gateway
3. **Policy Routing**: Rules direct traffic to specific IPs to use the custom routing table
4. **Persistence**: A systemd service ensures routes survive reboots
## Disabling Plugin Proxy Configuration
Once network-level routing is working, you can disable the proxy configuration in the plugin:
1. Go to Jellyfin Dashboard → Plugins → SRF Play
2. Uncheck "Use Proxy"
3. Save configuration
4. Restart Jellyfin
The plugin will use direct HTTP requests, but the network layer will transparently route them through the gateway.
## Troubleshooting
### Routes Not Working
Check if gateway is reachable:
```bash
ping 192.168.1.37
nc -zv 192.168.1.37 3128
```
Check routing table:
```bash
ip route show table srf_gateway
```
### DNS Changes
If SRF changes their IP addresses, you may need to re-run the setup script:
```bash
sudo ./cleanup-gateway-routing.sh
sudo ./setup-gateway-routing.sh
```
### Verify Traffic Path
Use `traceroute` to see the path:
```bash
traceroute $(dig +short il.srgssr.ch | head -1)
# Should show 192.168.1.37 as first hop
```
### Check Systemd Service
```bash
systemctl status srf-gateway-routing.service
journalctl -u srf-gateway-routing.service
```
## Removing the Configuration
To completely remove the routing configuration:
```bash
sudo ./cleanup-gateway-routing.sh
```
This will:
- Remove all routing rules
- Flush the custom routing table
- Disable and remove the systemd service
## Advantages of This Approach
1. **Transparent**: No application changes needed
2. **Complete Coverage**: All network traffic to SRF domains uses gateway
3. **Persistent**: Survives reboots
4. **Centralized**: Managed at network level
5. **Debug-Friendly**: Can verify with standard network tools
## Disadvantages
1. **DNS Changes**: If SRF changes IPs, routing must be updated
2. **New Domains**: New CDN domains require script update
3. **Static IPs Only**: Doesn't work with wildcard domains
## Gateway Configuration
Ensure your gateway (192.168.1.37) has:
1. **IPv4 Forwarding Enabled**:
```bash
# On the gateway (192.168.1.37)
sudo sysctl net.ipv4.ip_forward=1
# Make permanent
echo "net.ipv4.ip_forward=1" | sudo tee -a /etc/sysctl.conf
```
2. **Firewall Rules** (if using iptables):
```bash
# On the gateway (192.168.1.37)
sudo iptables -A FORWARD -s 192.168.1.4 -j ACCEPT
sudo iptables -A FORWARD -d 192.168.1.4 -m state --state RELATED,ESTABLISHED -j ACCEPT
sudo iptables -t nat -A POSTROUTING -s 192.168.1.4 -o <outbound-interface> -j MASQUERADE
```
3. **Squid Proxy** (if using proxy mode):
- Already configured and accessible at port 3128
- Note: With network-level routing, traffic goes through the gateway's routing, not necessarily the Squid proxy
## Testing Complete Data Chain
After setup, test the complete flow:
1. **Metadata API**: Browse shows in Jellyfin - should work
2. **Video Playback**: Try playing a video - should work
3. **Thumbnails**: Images should load
All traffic should be routed through 192.168.1.37, giving you the Swiss location needed to access geo-blocked content.
-197
View File
@@ -1,197 +0,0 @@
# Proxy Configuration Guide for SRF Play Plugin
This guide explains how to configure the Jellyfin SRF Play plugin to route all API traffic through a proxy or alternate gateway.
## Overview
The SRF Play plugin now supports proxy configuration directly in the plugin settings. This allows you to:
- Route traffic through a specific gateway or proxy server
- Use authentication if your proxy requires it
- Bypass geo-restrictions or network policies
- Route only SRF-related traffic without affecting other Jellyfin operations
## Supported Proxy Types
The plugin supports:
- **HTTP proxies**: `http://proxy.example.com:8080`
- **HTTPS proxies**: `https://proxy.example.com:8443`
- **SOCKS5 proxies**: `socks5://proxy.example.com:1080`
## Configuration Steps
### 1. Access Plugin Settings
1. Open Jellyfin Dashboard
2. Navigate to **Dashboard → Plugins → SRF Play**
3. Scroll down to the **Proxy Settings** section
### 2. Configure Proxy
Fill in the following fields:
#### Use Proxy
- **Enable this checkbox** to route all SRF API requests through the proxy
#### Proxy Address
- Enter your proxy server address with protocol and port
- Examples:
- `http://192.168.1.100:8080`
- `http://proxy.example.com:3128`
- `socks5://127.0.0.1:1080`
#### Proxy Username (Optional)
- Enter username if your proxy requires authentication
- Leave empty if no authentication is needed
#### Proxy Password (Optional)
- Enter password if your proxy requires authentication
- Leave empty if no authentication is needed
### 3. Save Configuration
1. Click **Save** button
2. Restart Jellyfin to apply changes (recommended)
## Examples
### Example 1: Simple HTTP Proxy (No Authentication)
```
Use Proxy: ✓ Enabled
Proxy Address: http://192.168.1.1:8080
Proxy Username: (empty)
Proxy Password: (empty)
```
### Example 2: Authenticated HTTP Proxy
```
Use Proxy: ✓ Enabled
Proxy Address: http://proxy.company.com:3128
Proxy Username: myusername
Proxy Password: mypassword
```
### Example 3: SOCKS5 Proxy
```
Use Proxy: ✓ Enabled
Proxy Address: socks5://127.0.0.1:1080
Proxy Username: (empty)
Proxy Password: (empty)
```
## Setting Up a Transparent Proxy Gateway on Ubuntu
If you want to create your own transparent proxy gateway on Ubuntu, here are some options:
### Option A: Squid Proxy
Install and configure Squid as a transparent proxy:
```bash
# Install Squid
sudo apt update
sudo apt install squid
# Edit configuration
sudo nano /etc/squid/squid.conf
# Add these lines:
http_port 3128
acl localnet src 192.168.1.0/24
http_access allow localnet
# Restart Squid
sudo systemctl restart squid
```
Then in plugin settings:
```
Proxy Address: http://192.168.1.1:3128
```
### Option B: SSH Tunnel (SOCKS5)
Create a SOCKS5 proxy through SSH:
```bash
# On your local machine
ssh -D 1080 -N user@remote-gateway-server
```
Then in plugin settings:
```
Proxy Address: socks5://127.0.0.1:1080
```
### Option C: Dante SOCKS Server
Install Dante for a dedicated SOCKS5 server:
```bash
sudo apt install dante-server
# Configure in /etc/danted.conf
sudo systemctl restart danted
```
## Troubleshooting
### Plugin Not Connecting Through Proxy
1. **Check proxy address format**: Ensure it includes the protocol (http://, socks5://, etc.)
2. **Verify proxy is running**: Test connectivity to the proxy from your Jellyfin server
3. **Check Jellyfin logs**: Look for proxy-related errors in Dashboard → Logs
4. **Firewall rules**: Ensure your firewall allows outbound connections to the proxy
### Authentication Errors
1. Verify username and password are correct
2. Check if your proxy requires domain authentication (DOMAIN\\username)
3. Some proxies may require specific authentication methods not supported by .NET HttpClient
### DNS Resolution
- The plugin resolves domain names before sending requests through the proxy
- If you need DNS resolution through the proxy, you may need to use a VPN or network-level routing instead
## Affected Domains
When proxy is enabled, all requests to these domains will be routed through the proxy:
- `il.srgssr.ch` - SRF Integration Layer API
- `www.srf.ch` - SRF Play v3 API (German)
- `www.rts.ch` - RTS Play v3 API (French)
- `www.rsi.ch` - RSI Play v3 API (Italian)
- `www.rtr.ch` - RTR Play v3 API (Romansh)
- `www.swi.ch` - SWI Play v3 API (International)
## Viewing Logs
To verify the proxy is being used:
1. Go to **Dashboard → Logs**
2. Look for entries containing "Proxy configured"
3. Example log entry:
```
Proxy configured: http://192.168.1.1:8080 (Authentication: False)
```
## Security Notes
- Proxy credentials are stored in Jellyfin's plugin configuration
- Use HTTPS for the proxy connection when possible to encrypt traffic
- Consider using a VPN for more secure routing if dealing with sensitive content
- Regularly update your proxy server and Jellyfin to patch security vulnerabilities
## Alternative: Network-Level Routing
If you prefer network-level routing instead of application proxy, see the `setup-srf-routing.sh` script for IP-based routing tables (requires root access and is more complex).
## Support
If you encounter issues:
1. Check Jellyfin logs for detailed error messages
2. Verify proxy connectivity with `curl --proxy http://proxy:port https://il.srgssr.ch`
3. Open an issue on the GitHub repository with logs and configuration details
+37 -2
View File
@@ -14,6 +14,17 @@ A Jellyfin plugin for accessing SRF Play (Swiss Radio and Television) video-on-d
- Proxy support for routing traffic through alternate gateways - Proxy support for routing traffic through alternate gateways
- Smart caching with reduced TTL for upcoming livestreams - Smart caching with reduced TTL for upcoming livestreams
## Screenshots
### Channel Menu
![SRF Play Menu](res/menu.png)
The main channel interface showing the content folders.
### Playback
![SRF Play Playback](res/playback.png)
Video playback with HLS streaming support and quality selection.
## Project Status ## Project Status
### ✅ Completed Components ### ✅ Completed Components
@@ -92,16 +103,40 @@ A Jellyfin plugin for accessing SRF Play (Swiss Radio and Television) video-on-d
**Successfully compiling!** All code analysis warnings resolved. **Successfully compiling!** All code analysis warnings resolved.
### 🧪 Testing Status ### 🧪 Testing Status
- [ ] Unit tests - [x] Unit tests (xUnit framework)
- StreamUrlResolver tests
- MetadataCache tests
- More to be added
- [x] API spec validation tests (nightly automated runs)
- All business units (SRF, RTS, RSI, RTR, SWI)
- Response schema validation
- Performance monitoring
- [ ] Integration testing with Jellyfin instance - [ ] Integration testing with Jellyfin instance
- [ ] End-to-end playback testing for VOD content - [ ] End-to-end playback testing for VOD content
- [ ] Live sports streaming validation - [ ] Live sports streaming validation
**Run tests:**
```bash
# All tests
dotnet test
# Unit tests only
dotnet test --filter "Category!=Integration&Category!=APISpec"
# API spec tests only
dotnet test --filter "Category=APISpec"
# With coverage
dotnet test --collect:"XPlat Code Coverage"
```
See [Test Documentation](Jellyfin.Plugin.SRFPlay.Tests/README.md) for more details.
### 📝 Next Steps ### 📝 Next Steps
1. Test live sports streaming when events are scheduled 1. Test live sports streaming when events are scheduled
2. Verify Akamai token authentication 2. Verify Akamai token authentication
3. Test with different business units (RTS, RSI, RTR) 3. Test with different business units (RTS, RSI, RTR)
4. Add unit tests 4. Add more unit tests for remaining services
5. Performance optimization if needed 5. Performance optimization if needed
## API Information ## API Information
-57
View File
@@ -1,57 +0,0 @@
#!/bin/bash
# Deploy SRF Play plugin to Jellyfin server
set -e
# Configuration
JELLYFIN_SERVER="192.168.1.4"
JELLYFIN_USER="dtourolle" # Change this to your SSH user
DLL_PATH="Jellyfin.Plugin.SRFPlay/bin/Release/net8.0/Jellyfin.Plugin.SRFPlay.dll"
# Colors
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'
echo -e "${GREEN}=== Deploying SRF Play Plugin ===${NC}\n"
# Check if DLL exists
if [ ! -f "$DLL_PATH" ]; then
echo "Error: DLL not found. Building..."
dotnet build Jellyfin.Plugin.SRFPlay/Jellyfin.Plugin.SRFPlay.csproj -c Release
fi
echo "Step 1: Copying DLL to Jellyfin server..."
scp "$DLL_PATH" "${JELLYFIN_USER}@${JELLYFIN_SERVER}:~/"
echo -e "\nStep 2: Installing DLL on Jellyfin server..."
ssh "${JELLYFIN_USER}@${JELLYFIN_SERVER}" << 'ENDSSH'
echo "Stopping Jellyfin..."
sudo systemctl stop jellyfin
echo "Backing up old DLL..."
sudo cp /var/lib/jellyfin/plugins/SRF/Jellyfin.Plugin.SRFPlay.dll \
/var/lib/jellyfin/plugins/SRF/Jellyfin.Plugin.SRFPlay.dll.backup || true
echo "Installing new DLL..."
sudo cp ~/Jellyfin.Plugin.SRFPlay.dll /var/lib/jellyfin/plugins/SRF/
sudo chown jellyfin:jellyfin /var/lib/jellyfin/plugins/SRF/Jellyfin.Plugin.SRFPlay.dll
echo "Starting Jellyfin..."
sudo systemctl start jellyfin
echo "Waiting for Jellyfin to start..."
sleep 5
echo "Checking Jellyfin status..."
sudo systemctl status jellyfin --no-pager -l | head -20
ENDSSH
echo -e "\n${GREEN}✓ Deployment complete!${NC}"
echo -e "\n${YELLOW}Next steps:${NC}"
echo "1. Test video playback - GUID errors should be fixed"
echo "2. If videos still don't play due to geo-blocking, run network routing setup:"
echo " scp setup-gateway-routing.sh cleanup-gateway-routing.sh ${JELLYFIN_USER}@${JELLYFIN_SERVER}:~"
echo " ssh ${JELLYFIN_USER}@${JELLYFIN_SERVER}"
echo " chmod +x setup-gateway-routing.sh"
echo " sudo ./setup-gateway-routing.sh"
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 194 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB