Clean up dead code, consolidate duplication, fix redundancies
Some checks failed
🏗️ Build Plugin / build (push) Failing after 9s
🧪 Test Plugin / test (push) Successful in 1m28s
🚀 Release Plugin / build-and-release (push) Failing after 5s

Remove 9 dead methods, 6 unused constants, and redundant
ReaderWriterLockSlim from MetadataCache. Consolidate repeated
patterns into HasChapters, IsPlayable, and ToLowerString helpers.
Extract shared API methods in SRFApiClient. Move variant manifest
rewriting from controller to StreamProxyService. Make Auto quality
distinct from HD. Update README architecture section.
This commit is contained in:
Duncan Tourolle 2026-02-28 11:34:45 +01:00
parent 873e531599
commit 7c76402a4a
23 changed files with 245 additions and 625 deletions

View File

@ -115,7 +115,7 @@ namespace Jellyfin.Plugin.SRFPlay.Tests
if (chapter.ResourceList != null && chapter.ResourceList.Any()) if (chapter.ResourceList != null && chapter.ResourceList.Any())
{ {
var hlsResource = chapter.ResourceList.FirstOrDefault(r => var hlsResource = chapter.ResourceList.FirstOrDefault(r =>
r.Protocol == "HLS" && (r.DrmList == null || r.DrmList.ToString() == "[]")); r.Protocol == "HLS" && r.IsPlayable);
if (hlsResource != null) if (hlsResource != null)
{ {

View File

@ -74,7 +74,9 @@ public class Chapter
/// Gets or sets the list of available resources (streams). /// Gets or sets the list of available resources (streams).
/// </summary> /// </summary>
[JsonPropertyName("resourceList")] [JsonPropertyName("resourceList")]
public IReadOnlyList<Resource> ResourceList { get; set; } = new List<Resource>(); [System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "CA1002:Do not expose generic lists", Justification = "Required for JSON deserialization")]
[System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "CA2227:Collection properties should be read only", Justification = "Required for JSON deserialization")]
public List<Resource> ResourceList { get; set; } = new List<Resource>();
/// <summary> /// <summary>
/// Gets or sets the episode number. /// Gets or sets the episode number.

View File

@ -1,5 +1,4 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
namespace Jellyfin.Plugin.SRFPlay.Api.Models; namespace Jellyfin.Plugin.SRFPlay.Api.Models;
@ -15,6 +14,12 @@ public class MediaComposition
[JsonPropertyName("chapterList")] [JsonPropertyName("chapterList")]
public IReadOnlyList<Chapter> ChapterList { get; set; } = new List<Chapter>(); public IReadOnlyList<Chapter> ChapterList { get; set; } = new List<Chapter>();
/// <summary>
/// Gets a value indicating whether this composition has any chapters.
/// </summary>
[JsonIgnore]
public bool HasChapters => ChapterList != null && ChapterList.Count > 0;
/// <summary> /// <summary>
/// Gets or sets the episode information. /// Gets or sets the episode information.
/// </summary> /// </summary>

View File

@ -2,6 +2,9 @@ using System.Text.Json.Serialization;
namespace Jellyfin.Plugin.SRFPlay.Api.Models; namespace Jellyfin.Plugin.SRFPlay.Api.Models;
// NOTE: DrmList is typed as object? because the SRF API returns either null or a JSON array.
// IsPlayable checks for both null and empty array ("[]") to determine if content is DRM-free.
/// <summary> /// <summary>
/// Represents a streaming resource (URL) in the SRF API response. /// Represents a streaming resource (URL) in the SRF API response.
/// </summary> /// </summary>
@ -48,4 +51,10 @@ public class Resource
/// </summary> /// </summary>
[JsonPropertyName("drmList")] [JsonPropertyName("drmList")]
public object? DrmList { get; set; } public object? DrmList { get; set; }
/// <summary>
/// Gets a value indicating whether this resource is playable (not DRM-protected).
/// </summary>
[JsonIgnore]
public bool IsPlayable => DrmList == null || DrmList.ToString() == "[]";
} }

View File

@ -228,7 +228,7 @@ public class SRFApiClient : IDisposable
var result = JsonSerializer.Deserialize<MediaComposition>(output, _jsonOptions); var result = JsonSerializer.Deserialize<MediaComposition>(output, _jsonOptions);
if (result?.ChapterList != null && result.ChapterList.Count > 0) if (result?.HasChapters == true)
{ {
_logger.LogInformation("Successfully fetched media composition via curl - Chapters: {ChapterCount}", result.ChapterList.Count); _logger.LogInformation("Successfully fetched media composition via curl - Chapters: {ChapterCount}", result.ChapterList.Count);
} }
@ -248,38 +248,8 @@ public class SRFApiClient : IDisposable
/// <param name="businessUnit">The business unit (e.g., srf, rts).</param> /// <param name="businessUnit">The business unit (e.g., srf, rts).</param>
/// <param name="cancellationToken">The cancellation token.</param> /// <param name="cancellationToken">The cancellation token.</param>
/// <returns>The media composition containing latest videos.</returns> /// <returns>The media composition containing latest videos.</returns>
public async Task<MediaComposition?> GetLatestVideosAsync(string businessUnit, CancellationToken cancellationToken = default) public Task<MediaComposition?> GetLatestVideosAsync(string businessUnit, CancellationToken cancellationToken = default)
{ => GetMediaCompositionListAsync(businessUnit, "latest", cancellationToken);
try
{
var url = $"/video/{businessUnit}/latest.json";
_logger.LogInformation("Fetching latest videos for business unit: {BusinessUnit} from URL: {Url}", businessUnit, url);
var response = await _httpClient.GetAsync(url, cancellationToken).ConfigureAwait(false);
_logger.LogInformation("Latest videos API response: {StatusCode}", response.StatusCode);
if (!response.IsSuccessStatusCode)
{
var errorContent = await ReadAsUtf8StringAsync(response.Content, cancellationToken).ConfigureAwait(false);
_logger.LogError("API returned error {StatusCode}: {Error}", response.StatusCode, errorContent);
return null;
}
var content = await ReadAsUtf8StringAsync(response.Content, cancellationToken).ConfigureAwait(false);
_logger.LogDebug("Latest videos response length: {Length}", content.Length);
var result = JsonSerializer.Deserialize<MediaComposition>(content, _jsonOptions);
_logger.LogInformation("Successfully fetched latest videos for business unit: {BusinessUnit}", businessUnit);
return result;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error fetching latest videos for business unit: {BusinessUnit}", businessUnit);
return null;
}
}
/// <summary> /// <summary>
/// Gets the trending videos for a business unit. /// Gets the trending videos for a business unit.
@ -287,16 +257,19 @@ public class SRFApiClient : IDisposable
/// <param name="businessUnit">The business unit (e.g., srf, rts).</param> /// <param name="businessUnit">The business unit (e.g., srf, rts).</param>
/// <param name="cancellationToken">The cancellation token.</param> /// <param name="cancellationToken">The cancellation token.</param>
/// <returns>The media composition containing trending videos.</returns> /// <returns>The media composition containing trending videos.</returns>
public async Task<MediaComposition?> GetTrendingVideosAsync(string businessUnit, CancellationToken cancellationToken = default) public Task<MediaComposition?> GetTrendingVideosAsync(string businessUnit, CancellationToken cancellationToken = default)
=> GetMediaCompositionListAsync(businessUnit, "trending", cancellationToken);
private async Task<MediaComposition?> GetMediaCompositionListAsync(string businessUnit, string endpoint, CancellationToken cancellationToken)
{ {
try try
{ {
var url = $"/video/{businessUnit}/trending.json"; var url = $"/video/{businessUnit}/{endpoint}.json";
_logger.LogInformation("Fetching trending videos for business unit: {BusinessUnit} from URL: {Url}", businessUnit, url); _logger.LogInformation("Fetching {Endpoint} videos for business unit: {BusinessUnit} from URL: {Url}", endpoint, businessUnit, url);
var response = await _httpClient.GetAsync(url, cancellationToken).ConfigureAwait(false); var response = await _httpClient.GetAsync(url, cancellationToken).ConfigureAwait(false);
_logger.LogInformation("Trending videos API response: {StatusCode}", response.StatusCode); _logger.LogInformation("{Endpoint} videos API response: {StatusCode}", endpoint, response.StatusCode);
if (!response.IsSuccessStatusCode) if (!response.IsSuccessStatusCode)
{ {
@ -306,41 +279,16 @@ public class SRFApiClient : IDisposable
} }
var content = await ReadAsUtf8StringAsync(response.Content, cancellationToken).ConfigureAwait(false); var content = await ReadAsUtf8StringAsync(response.Content, cancellationToken).ConfigureAwait(false);
_logger.LogDebug("Trending videos response length: {Length}", content.Length); _logger.LogDebug("{Endpoint} videos response length: {Length}", endpoint, content.Length);
var result = JsonSerializer.Deserialize<MediaComposition>(content, _jsonOptions); var result = JsonSerializer.Deserialize<MediaComposition>(content, _jsonOptions);
_logger.LogInformation("Successfully fetched trending videos for business unit: {BusinessUnit}", businessUnit); _logger.LogInformation("Successfully fetched {Endpoint} videos for business unit: {BusinessUnit}", endpoint, businessUnit);
return result; return result;
} }
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogError(ex, "Error fetching trending videos for business unit: {BusinessUnit}", businessUnit); _logger.LogError(ex, "Error fetching {Endpoint} videos for business unit: {BusinessUnit}", endpoint, businessUnit);
return null;
}
}
/// <summary>
/// Gets raw JSON response from a URL.
/// </summary>
/// <param name="url">The relative URL.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>The JSON string.</returns>
public async Task<string?> GetJsonAsync(string url, CancellationToken cancellationToken = default)
{
try
{
_logger.LogDebug("Fetching JSON from URL: {Url}", url);
var response = await _httpClient.GetAsync(url, cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
var content = await ReadAsUtf8StringAsync(response.Content, cancellationToken).ConfigureAwait(false);
return content;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error fetching JSON from URL: {Url}", url);
return null; return null;
} }
} }
@ -351,35 +299,8 @@ public class SRFApiClient : IDisposable
/// <param name="businessUnit">The business unit (e.g., srf, rts).</param> /// <param name="businessUnit">The business unit (e.g., srf, rts).</param>
/// <param name="cancellationToken">The cancellation token.</param> /// <param name="cancellationToken">The cancellation token.</param>
/// <returns>List of shows.</returns> /// <returns>List of shows.</returns>
public async Task<System.Collections.Generic.List<PlayV3Show>?> GetAllShowsAsync(string businessUnit, CancellationToken cancellationToken = default) public Task<System.Collections.Generic.List<PlayV3Show>?> GetAllShowsAsync(string businessUnit, CancellationToken cancellationToken = default)
{ => GetPlayV3DirectListAsync<PlayV3Show>(businessUnit, "shows", cancellationToken);
try
{
var baseUrl = string.Format(CultureInfo.InvariantCulture, PlayV3UrlFormat, businessUnit);
var url = $"{baseUrl}shows";
_logger.LogInformation("Fetching all shows for business unit: {BusinessUnit} from URL: {Url}", businessUnit, url);
var response = await _playV3HttpClient.GetAsync(url, cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
var errorContent = await ReadAsUtf8StringAsync(response.Content, cancellationToken).ConfigureAwait(false);
_logger.LogError("API returned error {StatusCode}: {Error}", response.StatusCode, errorContent);
return null;
}
var content = await ReadAsUtf8StringAsync(response.Content, cancellationToken).ConfigureAwait(false);
var result = JsonSerializer.Deserialize<PlayV3DirectResponse<PlayV3Show>>(content, _jsonOptions);
_logger.LogInformation("Successfully fetched {Count} shows for business unit: {BusinessUnit}", result?.Data?.Count ?? 0, businessUnit);
return result?.Data;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error fetching shows for business unit: {BusinessUnit}", businessUnit);
return null;
}
}
/// <summary> /// <summary>
/// Gets all topics from the Play v3 API. /// Gets all topics from the Play v3 API.
@ -387,13 +308,16 @@ public class SRFApiClient : IDisposable
/// <param name="businessUnit">The business unit (e.g., srf, rts).</param> /// <param name="businessUnit">The business unit (e.g., srf, rts).</param>
/// <param name="cancellationToken">The cancellation token.</param> /// <param name="cancellationToken">The cancellation token.</param>
/// <returns>List of topics.</returns> /// <returns>List of topics.</returns>
public async Task<System.Collections.Generic.List<PlayV3Topic>?> GetAllTopicsAsync(string businessUnit, CancellationToken cancellationToken = default) public Task<System.Collections.Generic.List<PlayV3Topic>?> GetAllTopicsAsync(string businessUnit, CancellationToken cancellationToken = default)
=> GetPlayV3DirectListAsync<PlayV3Topic>(businessUnit, "topics", cancellationToken);
private async Task<System.Collections.Generic.List<T>?> GetPlayV3DirectListAsync<T>(string businessUnit, string endpoint, CancellationToken cancellationToken)
{ {
try try
{ {
var baseUrl = string.Format(CultureInfo.InvariantCulture, PlayV3UrlFormat, businessUnit); var baseUrl = string.Format(CultureInfo.InvariantCulture, PlayV3UrlFormat, businessUnit);
var url = $"{baseUrl}topics"; var url = $"{baseUrl}{endpoint}";
_logger.LogInformation("Fetching all topics for business unit: {BusinessUnit} from URL: {Url}", businessUnit, url); _logger.LogInformation("Fetching all {Endpoint} for business unit: {BusinessUnit} from URL: {Url}", endpoint, businessUnit, url);
var response = await _playV3HttpClient.GetAsync(url, cancellationToken).ConfigureAwait(false); var response = await _playV3HttpClient.GetAsync(url, cancellationToken).ConfigureAwait(false);
@ -405,14 +329,14 @@ public class SRFApiClient : IDisposable
} }
var content = await ReadAsUtf8StringAsync(response.Content, cancellationToken).ConfigureAwait(false); var content = await ReadAsUtf8StringAsync(response.Content, cancellationToken).ConfigureAwait(false);
var result = JsonSerializer.Deserialize<PlayV3DirectResponse<PlayV3Topic>>(content, _jsonOptions); var result = JsonSerializer.Deserialize<PlayV3DirectResponse<T>>(content, _jsonOptions);
_logger.LogInformation("Successfully fetched {Count} topics for business unit: {BusinessUnit}", result?.Data?.Count ?? 0, businessUnit); _logger.LogInformation("Successfully fetched {Count} {Endpoint} for business unit: {BusinessUnit}", result?.Data?.Count ?? 0, endpoint, businessUnit);
return result?.Data; return result?.Data;
} }
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogError(ex, "Error fetching topics for business unit: {BusinessUnit}", businessUnit); _logger.LogError(ex, "Error fetching {Endpoint} for business unit: {BusinessUnit}", endpoint, businessUnit);
return null; return null;
} }
} }

View File

@ -190,7 +190,7 @@ public class SRFPlayChannel : IChannel, IHasCacheKey
{ {
try try
{ {
var businessUnit = config.BusinessUnit.ToString().ToLowerInvariant(); var businessUnit = config.BusinessUnit.ToLowerString();
var topics = await _categoryService.GetTopicsAsync(businessUnit, cancellationToken).ConfigureAwait(false); var topics = await _categoryService.GetTopicsAsync(businessUnit, cancellationToken).ConfigureAwait(false);
foreach (var topic in topics.Where(t => !string.IsNullOrEmpty(t.Id))) foreach (var topic in topics.Where(t => !string.IsNullOrEmpty(t.Id)))
@ -248,7 +248,7 @@ public class SRFPlayChannel : IChannel, IHasCacheKey
try try
{ {
var businessUnit = config?.BusinessUnit.ToString().ToLowerInvariant() ?? "srf"; var businessUnit = config?.BusinessUnit.ToLowerString() ?? "srf";
using var apiClient = _apiClientFactory.CreateClient(); using var apiClient = _apiClientFactory.CreateClient();
var scheduledLivestreams = await apiClient.GetScheduledLivestreamsAsync(businessUnit, "SPORT", cancellationToken).ConfigureAwait(false); var scheduledLivestreams = await apiClient.GetScheduledLivestreamsAsync(businessUnit, "SPORT", cancellationToken).ConfigureAwait(false);
@ -310,7 +310,7 @@ public class SRFPlayChannel : IChannel, IHasCacheKey
{ {
var config = Plugin.Instance?.Configuration; var config = Plugin.Instance?.Configuration;
var topicId = folderId.Substring("category_".Length); var topicId = folderId.Substring("category_".Length);
var businessUnit = config?.BusinessUnit.ToString().ToLowerInvariant() ?? "srf"; var businessUnit = config?.BusinessUnit.ToLowerString() ?? "srf";
var shows = await _categoryService.GetShowsByTopicAsync(topicId, businessUnit, 20, cancellationToken).ConfigureAwait(false); var shows = await _categoryService.GetShowsByTopicAsync(topicId, businessUnit, 20, cancellationToken).ConfigureAwait(false);
var urns = new List<string>(); var urns = new List<string>();
@ -440,7 +440,7 @@ public class SRFPlayChannel : IChannel, IHasCacheKey
_logger.LogDebug("Processing URN: {Urn}", urn); _logger.LogDebug("Processing URN: {Urn}", urn);
var mediaComposition = await apiClient.GetMediaCompositionByUrnAsync(urn, cancellationToken).ConfigureAwait(false); var mediaComposition = await apiClient.GetMediaCompositionByUrnAsync(urn, cancellationToken).ConfigureAwait(false);
if (mediaComposition?.ChapterList == null || mediaComposition.ChapterList.Count == 0) if (mediaComposition?.HasChapters != true)
{ {
_logger.LogWarning("URN {Urn}: No media composition or chapters found", urn); _logger.LogWarning("URN {Urn}: No media composition or chapters found", urn);
failedCount++; failedCount++;

View File

@ -28,12 +28,6 @@ public static class ApiEndpoints
/// </summary> /// </summary>
public const string SrfPlayHomepage = "https://www.srf.ch/play"; public const string SrfPlayHomepage = "https://www.srf.ch/play";
/// <summary>
/// Media composition endpoint template (relative to IntegrationLayerBaseUrl).
/// Format: {0} = URN.
/// </summary>
public const string MediaCompositionByUrnPath = "/mediaComposition/byUrn/{0}.json";
/// <summary> /// <summary>
/// Base proxy route for stream proxying (relative to server root). /// Base proxy route for stream proxying (relative to server root).
/// </summary> /// </summary>
@ -44,30 +38,4 @@ public static class ApiEndpoints
/// Format: {0} = item ID. /// Format: {0} = item ID.
/// </summary> /// </summary>
public const string ProxyMasterManifestPath = "/Plugins/SRFPlay/Proxy/{0}/master.m3u8"; public const string ProxyMasterManifestPath = "/Plugins/SRFPlay/Proxy/{0}/master.m3u8";
/// <summary>
/// Image proxy route template (relative to server root).
/// Format: {0} = base64-encoded original URL.
/// </summary>
public const string ImageProxyPath = "/Plugins/SRFPlay/Image/{0}";
/// <summary>
/// Play V3 shows endpoint (relative to PlayV3 base URL).
/// </summary>
public const string PlayV3ShowsPath = "shows";
/// <summary>
/// Play V3 topics endpoint (relative to PlayV3 base URL).
/// </summary>
public const string PlayV3TopicsPath = "topics";
/// <summary>
/// Play V3 livestreams endpoint (relative to PlayV3 base URL).
/// </summary>
public const string PlayV3LivestreamsPath = "livestreams";
/// <summary>
/// Play V3 scheduled livestreams endpoint (relative to PlayV3 base URL).
/// </summary>
public const string PlayV3ScheduledLivestreamsPath = "scheduled-livestreams";
} }

View File

@ -196,7 +196,23 @@ public class StreamProxyController : ControllerBase
var manifestContent = System.Text.Encoding.UTF8.GetString(manifestData); var manifestContent = System.Text.Encoding.UTF8.GetString(manifestData);
var scheme = GetProxyScheme(); var scheme = GetProxyScheme();
var baseProxyUrl = $"{scheme}://{Request.Host}/Plugins/SRFPlay/Proxy/{itemId}"; var baseProxyUrl = $"{scheme}://{Request.Host}/Plugins/SRFPlay/Proxy/{itemId}";
var rewrittenContent = RewriteSegmentUrls(manifestContent, baseProxyUrl);
// Extract query parameters from the current request to propagate them
string queryParams;
if (Request.Query.TryGetValue("token", out var tokenVal) && !string.IsNullOrEmpty(tokenVal))
{
queryParams = $"?token={tokenVal}";
}
else if (Request.Query.TryGetValue("itemId", out var itemIdVal) && !string.IsNullOrEmpty(itemIdVal))
{
queryParams = $"?itemId={itemIdVal}";
}
else
{
queryParams = string.Empty;
}
var rewrittenContent = _proxyService.RewriteVariantManifestUrls(manifestContent, baseProxyUrl, queryParams);
// Set cache headers based on stream type (live vs VOD) // Set cache headers based on stream type (live vs VOD)
AddManifestCacheHeaders(actualItemId); AddManifestCacheHeaders(actualItemId);
@ -311,81 +327,6 @@ public class StreamProxyController : ControllerBase
return 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;
}
// Helper function to rewrite a single URL
string RewriteUrl(string url)
{
if (url.Contains("://", StringComparison.Ordinal))
{
// Absolute URL - extract filename and rewrite
var uri = new Uri(url.Trim());
var segments = uri.AbsolutePath.Split('/');
var fileName = segments[^1];
return $"{baseProxyUrl}/{fileName}{queryParams}";
}
// Relative URL - rewrite to proxy
return $"{baseProxyUrl}/{url.Trim()}{queryParams}";
}
var lines = manifestContent.Split('\n');
var result = new System.Text.StringBuilder();
foreach (var line in lines)
{
if (string.IsNullOrWhiteSpace(line))
{
result.AppendLine(line);
}
else if (line.StartsWith('#'))
{
// HLS tag line - check for URI="..." attributes (e.g., #EXT-X-MAP:URI="init.mp4")
if (line.Contains("URI=\"", StringComparison.Ordinal))
{
var rewrittenLine = System.Text.RegularExpressions.Regex.Replace(
line,
@"URI=""([^""]+)""",
match => $"URI=\"{RewriteUrl(match.Groups[1].Value)}\"");
result.AppendLine(rewrittenLine);
}
else
{
// Keep other metadata lines as-is
result.AppendLine(line);
}
}
else
{
// Non-tag line with URL - rewrite it
result.AppendLine(RewriteUrl(line));
}
}
return result.ToString();
}
/// <summary> /// <summary>
/// Proxies image requests from SRF CDN, fixing Content-Type headers. /// Proxies image requests from SRF CDN, fixing Content-Type headers.
/// </summary> /// </summary>

View File

@ -50,7 +50,7 @@ public class SRFEpisodeProvider : IRemoteMetadataProvider<Episode, EpisodeInfo>
var mediaComposition = await _compositionFetcher.GetMediaCompositionAsync(urn, cancellationToken).ConfigureAwait(false); var mediaComposition = await _compositionFetcher.GetMediaCompositionAsync(urn, cancellationToken).ConfigureAwait(false);
if (mediaComposition?.ChapterList != null && mediaComposition.ChapterList.Count > 0) if (mediaComposition?.HasChapters == true)
{ {
var chapter = mediaComposition.ChapterList[0]; var chapter = mediaComposition.ChapterList[0];
results.Add(new RemoteSearchResult results.Add(new RemoteSearchResult
@ -93,7 +93,7 @@ public class SRFEpisodeProvider : IRemoteMetadataProvider<Episode, EpisodeInfo>
var mediaComposition = await _compositionFetcher.GetMediaCompositionAsync(urn, cancellationToken).ConfigureAwait(false); var mediaComposition = await _compositionFetcher.GetMediaCompositionAsync(urn, cancellationToken).ConfigureAwait(false);
if (mediaComposition?.ChapterList == null || mediaComposition.ChapterList.Count == 0) if (mediaComposition?.HasChapters != true)
{ {
_logger.LogWarning("No chapter information found for URN: {Urn}", urn); _logger.LogWarning("No chapter information found for URN: {Urn}", urn);
return result; return result;

View File

@ -92,7 +92,7 @@ public class SRFImageProvider : IRemoteImageProvider, IHasOrder
} }
// Extract images from chapters // Extract images from chapters
if (mediaComposition.ChapterList != null && mediaComposition.ChapterList.Count > 0) if (mediaComposition.HasChapters)
{ {
var chapter = mediaComposition.ChapterList[0]; var chapter = mediaComposition.ChapterList[0];
if (!string.IsNullOrEmpty(chapter.ImageUrl)) if (!string.IsNullOrEmpty(chapter.ImageUrl))

View File

@ -70,7 +70,7 @@ public class SRFMediaProvider : IMediaSourceProvider
var mediaComposition = await _compositionFetcher.GetMediaCompositionAsync(urn, cancellationToken, cacheDuration).ConfigureAwait(false); var mediaComposition = await _compositionFetcher.GetMediaCompositionAsync(urn, cancellationToken, cacheDuration).ConfigureAwait(false);
if (mediaComposition?.ChapterList == null || mediaComposition.ChapterList.Count == 0) if (mediaComposition?.HasChapters != true)
{ {
_logger.LogWarning("No chapters found for URN: {Urn}", urn); _logger.LogWarning("No chapters found for URN: {Urn}", urn);
return sources; return sources;
@ -116,7 +116,7 @@ public class SRFMediaProvider : IMediaSourceProvider
// Force fresh fetch with short cache duration // Force fresh fetch with short cache duration
var freshMediaComposition = await _compositionFetcher.GetMediaCompositionAsync(urn, cancellationToken, 1).ConfigureAwait(false); var freshMediaComposition = await _compositionFetcher.GetMediaCompositionAsync(urn, cancellationToken, 1).ConfigureAwait(false);
if (freshMediaComposition?.ChapterList != null && freshMediaComposition.ChapterList.Count > 0) if (freshMediaComposition?.HasChapters == true)
{ {
var freshChapter = freshMediaComposition.ChapterList[0]; var freshChapter = freshMediaComposition.ChapterList[0];
mediaSource = await _mediaSourceFactory.CreateMediaSourceAsync( mediaSource = await _mediaSourceFactory.CreateMediaSourceAsync(

View File

@ -60,25 +60,8 @@ public class ExpirationCheckTask : IScheduledTask
return; return;
} }
// Get expiration statistics first // Check and remove expired content
progress?.Report(25); progress?.Report(25);
var (total, expired, expiringSoon) = await _expirationService.GetExpirationStatisticsAsync(cancellationToken).ConfigureAwait(false);
_logger.LogInformation(
"Expiration statistics - Total: {Total}, Expired: {Expired}, Expiring Soon: {ExpiringSoon}",
total,
expired,
expiringSoon);
if (expired == 0)
{
_logger.LogInformation("No expired content found");
progress?.Report(100);
return;
}
// Remove expired content
progress?.Report(50);
var removedCount = await _expirationService.CheckAndRemoveExpiredContentAsync(cancellationToken).ConfigureAwait(false); var removedCount = await _expirationService.CheckAndRemoveExpiredContentAsync(cancellationToken).ConfigureAwait(false);
// Clean up old stream proxy mappings // Clean up old stream proxy mappings

View File

@ -118,7 +118,7 @@ public class ContentExpirationService : IContentExpirationService
var mediaComposition = await _compositionFetcher.GetMediaCompositionAsync(urn, cancellationToken).ConfigureAwait(false); var mediaComposition = await _compositionFetcher.GetMediaCompositionAsync(urn, cancellationToken).ConfigureAwait(false);
if (mediaComposition?.ChapterList == null || mediaComposition.ChapterList.Count == 0) if (mediaComposition?.HasChapters != true)
{ {
// If we can't fetch the content, consider it expired // If we can't fetch the content, consider it expired
_logger.LogWarning("Could not fetch media composition for URN: {Urn}, treating as expired", urn); _logger.LogWarning("Could not fetch media composition for URN: {Urn}, treating as expired", urn);
@ -135,72 +135,4 @@ public class ContentExpirationService : IContentExpirationService
return isExpired; return isExpired;
} }
/// <summary>
/// Gets statistics about content expiration.
/// </summary>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>Tuple with total count, expired count, and items expiring soon.</returns>
public async Task<(int Total, int Expired, int ExpiringSoon)> GetExpirationStatisticsAsync(CancellationToken cancellationToken)
{
var total = 0;
var expired = 0;
var expiringSoon = 0;
var soonThreshold = DateTime.UtcNow.AddDays(7); // Items expiring within 7 days
try
{
var query = new InternalItemsQuery
{
HasAnyProviderId = new Dictionary<string, string> { { "SRF", string.Empty } },
IsVirtualItem = false
};
var items = _libraryManager.GetItemList(query);
total = items.Count;
foreach (var item in items)
{
if (cancellationToken.IsCancellationRequested)
{
break;
}
try
{
var urn = item.ProviderIds.GetValueOrDefault("SRF");
if (string.IsNullOrEmpty(urn))
{
continue;
}
var mediaComposition = await _compositionFetcher.GetMediaCompositionAsync(urn, cancellationToken).ConfigureAwait(false);
if (mediaComposition?.ChapterList != null && mediaComposition.ChapterList.Count > 0)
{
var chapter = mediaComposition.ChapterList[0];
if (_streamResolver.IsContentExpired(chapter))
{
expired++;
}
else if (chapter.ValidTo.HasValue && chapter.ValidTo.Value.ToUniversalTime() <= soonThreshold)
{
expiringSoon++;
}
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error checking expiration statistics for item: {Name}", item.Name);
}
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error getting expiration statistics");
}
return (total, expired, expiringSoon);
}
} }

View File

@ -5,6 +5,7 @@ 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.Interfaces; using Jellyfin.Plugin.SRFPlay.Services.Interfaces;
using Jellyfin.Plugin.SRFPlay.Utilities;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.SRFPlay.Services; namespace Jellyfin.Plugin.SRFPlay.Services;
@ -45,7 +46,7 @@ public class ContentRefreshService : IContentRefreshService
} }
return await FetchVideosFromShowsAsync( return await FetchVideosFromShowsAsync(
config.BusinessUnit.ToString().ToLowerInvariant(), config.BusinessUnit.ToLowerString(),
minEpisodeCount: 0, minEpisodeCount: 0,
maxShows: 20, maxShows: 20,
videosPerShow: 1, videosPerShow: 1,
@ -69,7 +70,7 @@ public class ContentRefreshService : IContentRefreshService
} }
return await FetchVideosFromShowsAsync( return await FetchVideosFromShowsAsync(
config.BusinessUnit.ToString().ToLowerInvariant(), config.BusinessUnit.ToLowerString(),
minEpisodeCount: 10, minEpisodeCount: 10,
maxShows: 15, maxShows: 15,
videosPerShow: 2, videosPerShow: 2,
@ -167,48 +168,4 @@ public class ContentRefreshService : IContentRefreshService
return urns; return urns;
} }
/// <summary>
/// Refreshes all content (latest and trending).
/// </summary>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>Tuple with counts of latest and trending items.</returns>
public async Task<(int LatestCount, int TrendingCount)> RefreshAllContentAsync(CancellationToken cancellationToken)
{
_logger.LogInformation("Starting full content refresh");
var latestUrns = await RefreshLatestContentAsync(cancellationToken).ConfigureAwait(false);
var trendingUrns = await RefreshTrendingContentAsync(cancellationToken).ConfigureAwait(false);
var latestCount = latestUrns.Count;
var trendingCount = trendingUrns.Count;
_logger.LogInformation(
"Content refresh completed. Latest: {LatestCount}, Trending: {TrendingCount}",
latestCount,
trendingCount);
return (latestCount, trendingCount);
}
/// <summary>
/// Gets content recommendations (combines latest and trending).
/// </summary>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>List of recommended URNs.</returns>
public async Task<List<string>> GetRecommendedContentAsync(CancellationToken cancellationToken)
{
var recommendations = new HashSet<string>();
var latestUrns = await RefreshLatestContentAsync(cancellationToken).ConfigureAwait(false);
var trendingUrns = await RefreshTrendingContentAsync(cancellationToken).ConfigureAwait(false);
foreach (var urn in latestUrns.Concat(trendingUrns))
{
recommendations.Add(urn);
}
_logger.LogInformation("Generated {Count} content recommendations", recommendations.Count);
return recommendations.ToList();
}
} }

View File

@ -14,11 +14,4 @@ public interface IContentExpirationService
/// <param name="cancellationToken">The cancellation token.</param> /// <param name="cancellationToken">The cancellation token.</param>
/// <returns>The number of items removed.</returns> /// <returns>The number of items removed.</returns>
Task<int> CheckAndRemoveExpiredContentAsync(CancellationToken cancellationToken); Task<int> CheckAndRemoveExpiredContentAsync(CancellationToken cancellationToken);
/// <summary>
/// Gets statistics about content expiration.
/// </summary>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>Tuple with total count, expired count, and items expiring soon.</returns>
Task<(int Total, int Expired, int ExpiringSoon)> GetExpirationStatisticsAsync(CancellationToken cancellationToken);
} }

View File

@ -22,18 +22,4 @@ public interface IContentRefreshService
/// <param name="cancellationToken">The cancellation token.</param> /// <param name="cancellationToken">The cancellation token.</param>
/// <returns>List of URNs for trending content.</returns> /// <returns>List of URNs for trending content.</returns>
Task<List<string>> RefreshTrendingContentAsync(CancellationToken cancellationToken); Task<List<string>> RefreshTrendingContentAsync(CancellationToken cancellationToken);
/// <summary>
/// Refreshes all content (latest and trending).
/// </summary>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>Tuple with counts of latest and trending items.</returns>
Task<(int LatestCount, int TrendingCount)> RefreshAllContentAsync(CancellationToken cancellationToken);
/// <summary>
/// Gets content recommendations (combines latest and trending).
/// </summary>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>List of recommended URNs.</returns>
Task<List<string>> GetRecommendedContentAsync(CancellationToken cancellationToken);
} }

View File

@ -22,20 +22,8 @@ public interface IMetadataCache
/// <param name="mediaComposition">The media composition to cache.</param> /// <param name="mediaComposition">The media composition to cache.</param>
void SetMediaComposition(string urn, MediaComposition mediaComposition); void SetMediaComposition(string urn, MediaComposition mediaComposition);
/// <summary>
/// Removes media composition from cache.
/// </summary>
/// <param name="urn">The URN.</param>
void RemoveMediaComposition(string urn);
/// <summary> /// <summary>
/// Clears all cached data. /// Clears all cached data.
/// </summary> /// </summary>
void Clear(); void Clear();
/// <summary>
/// Gets the cache statistics.
/// </summary>
/// <returns>A tuple with cache count and size estimate.</returns>
(int Count, long SizeEstimate) GetStatistics();
} }

View File

@ -8,15 +8,6 @@ namespace Jellyfin.Plugin.SRFPlay.Services.Interfaces;
/// </summary> /// </summary>
public interface IStreamProxyService public interface IStreamProxyService
{ {
/// <summary>
/// Registers a stream for proxying with an already-authenticated URL.
/// </summary>
/// <param name="itemId">The item ID.</param>
/// <param name="authenticatedUrl">The authenticated stream URL.</param>
/// <param name="urn">The SRF URN for this content (used for re-fetching fresh URLs).</param>
/// <param name="isLiveStream">Whether this is a livestream (livestreams always fetch fresh URLs).</param>
void RegisterStream(string itemId, string authenticatedUrl, string? urn = null, bool isLiveStream = false);
/// <summary> /// <summary>
/// Registers a stream for deferred authentication (authenticates on first playback request). /// 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. /// Use this when browsing to avoid wasting 30-second tokens before the user clicks play.
@ -61,6 +52,15 @@ public interface IStreamProxyService
/// <returns>The segment content as bytes.</returns> /// <returns>The segment content as bytes.</returns>
Task<byte[]?> GetSegmentAsync(string itemId, string segmentPath, string? queryString = null, CancellationToken cancellationToken = default); Task<byte[]?> GetSegmentAsync(string itemId, string segmentPath, string? queryString = null, CancellationToken cancellationToken = default);
/// <summary>
/// Rewrites URLs in a variant (sub) manifest to point to the proxy.
/// </summary>
/// <param name="manifestContent">The variant manifest content.</param>
/// <param name="baseProxyUrl">The base proxy URL (without query params).</param>
/// <param name="queryParams">Query parameters to append to rewritten URLs (e.g., "?token=abc").</param>
/// <returns>The rewritten manifest content.</returns>
string RewriteVariantManifestUrls(string manifestContent, string baseProxyUrl, string queryParams);
/// <summary> /// <summary>
/// Cleans up old and expired stream mappings. /// Cleans up old and expired stream mappings.
/// </summary> /// </summary>

View File

@ -1,6 +1,5 @@
using System; using System;
using System.Collections.Concurrent; using System.Collections.Concurrent;
using System.Threading;
using Jellyfin.Plugin.SRFPlay.Api.Models; using Jellyfin.Plugin.SRFPlay.Api.Models;
using Jellyfin.Plugin.SRFPlay.Services.Interfaces; using Jellyfin.Plugin.SRFPlay.Services.Interfaces;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
@ -10,12 +9,10 @@ namespace Jellyfin.Plugin.SRFPlay.Services;
/// <summary> /// <summary>
/// Service for caching metadata from SRF API. /// Service for caching metadata from SRF API.
/// </summary> /// </summary>
public sealed class MetadataCache : IMetadataCache, IDisposable public sealed class MetadataCache : IMetadataCache
{ {
private readonly ILogger<MetadataCache> _logger; private readonly ILogger<MetadataCache> _logger;
private readonly ConcurrentDictionary<string, CacheEntry<MediaComposition>> _mediaCompositionCache; private readonly ConcurrentDictionary<string, CacheEntry<MediaComposition>> _mediaCompositionCache;
private readonly ReaderWriterLockSlim _lock;
private bool _disposed;
/// <summary> /// <summary>
/// Initializes a new instance of the <see cref="MetadataCache"/> class. /// Initializes a new instance of the <see cref="MetadataCache"/> class.
@ -25,19 +22,6 @@ public sealed class MetadataCache : IMetadataCache, IDisposable
{ {
_logger = logger; _logger = logger;
_mediaCompositionCache = new ConcurrentDictionary<string, CacheEntry<MediaComposition>>(); _mediaCompositionCache = new ConcurrentDictionary<string, CacheEntry<MediaComposition>>();
_lock = new ReaderWriterLockSlim();
}
/// <summary>
/// Disposes resources.
/// </summary>
public void Dispose()
{
if (!_disposed)
{
_lock?.Dispose();
_disposed = true;
}
} }
/// <summary> /// <summary>
@ -53,30 +37,15 @@ public sealed class MetadataCache : IMetadataCache, IDisposable
return null; return null;
} }
try if (_mediaCompositionCache.TryGetValue(urn, out var entry))
{ {
_lock.EnterReadLock(); if (entry.IsValid(cacheDurationMinutes))
try
{ {
if (_mediaCompositionCache.TryGetValue(urn, out var entry)) _logger.LogDebug("Cache hit for URN: {Urn}", urn);
{ return entry.Value;
if (entry.IsValid(cacheDurationMinutes)) }
{
_logger.LogDebug("Cache hit for URN: {Urn}", urn);
return entry.Value;
}
_logger.LogDebug("Cache entry expired for URN: {Urn}", urn); _logger.LogDebug("Cache entry expired for URN: {Urn}", urn);
}
}
finally
{
_lock.ExitReadLock();
}
}
catch (ObjectDisposedException)
{
return null;
} }
return null; return null;
@ -94,56 +63,9 @@ public sealed class MetadataCache : IMetadataCache, IDisposable
return; return;
} }
try var entry = new CacheEntry<MediaComposition>(mediaComposition);
{ _mediaCompositionCache.AddOrUpdate(urn, entry, (key, oldValue) => entry);
_lock.EnterWriteLock(); _logger.LogDebug("Cached media composition for URN: {Urn}", urn);
try
{
var entry = new CacheEntry<MediaComposition>(mediaComposition);
_mediaCompositionCache.AddOrUpdate(urn, entry, (key, oldValue) => entry);
_logger.LogDebug("Cached media composition for URN: {Urn}", urn);
}
finally
{
_lock.ExitWriteLock();
}
}
catch (ObjectDisposedException)
{
// Cache is disposed, ignore
}
}
/// <summary>
/// Removes media composition from cache.
/// </summary>
/// <param name="urn">The URN.</param>
public void RemoveMediaComposition(string urn)
{
if (string.IsNullOrEmpty(urn))
{
return;
}
try
{
_lock.EnterWriteLock();
try
{
if (_mediaCompositionCache.TryRemove(urn, out _))
{
_logger.LogDebug("Removed cached media composition for URN: {Urn}", urn);
}
}
finally
{
_lock.ExitWriteLock();
}
}
catch (ObjectDisposedException)
{
// Cache is disposed, ignore
}
} }
/// <summary> /// <summary>
@ -151,50 +73,8 @@ public sealed class MetadataCache : IMetadataCache, IDisposable
/// </summary> /// </summary>
public void Clear() public void Clear()
{ {
try _mediaCompositionCache.Clear();
{ _logger.LogInformation("Cleared metadata cache");
_lock.EnterWriteLock();
try
{
_mediaCompositionCache.Clear();
_logger.LogInformation("Cleared metadata cache");
}
finally
{
_lock.ExitWriteLock();
}
}
catch (ObjectDisposedException)
{
// Cache is disposed, ignore
}
}
/// <summary>
/// Gets the cache statistics.
/// </summary>
/// <returns>A tuple with cache count and size estimate.</returns>
public (int Count, long SizeEstimate) GetStatistics()
{
try
{
_lock.EnterReadLock();
try
{
var count = _mediaCompositionCache.Count;
// Rough estimate: average 50KB per entry
var sizeEstimate = count * 50L * 1024;
return (count, sizeEstimate);
}
finally
{
_lock.ExitReadLock();
}
}
catch (ObjectDisposedException)
{
return (0, 0);
}
} }
/// <summary> /// <summary>

View File

@ -46,45 +46,6 @@ public class StreamProxyService : IStreamProxyService
_streamMappings = new ConcurrentDictionary<string, StreamInfo>(); _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>
/// <param name="urn">The SRF URN for this content (used for re-fetching fresh URLs).</param>
/// <param name="isLiveStream">Whether this is a livestream (livestreams always fetch fresh URLs).</param>
public void RegisterStream(string itemId, string authenticatedUrl, string? urn = null, bool isLiveStream = false)
{
var tokenExpiry = ExtractTokenExpiry(authenticatedUrl);
var unauthenticatedUrl = StripAuthenticationFromUrl(authenticatedUrl);
var streamInfo = new StreamInfo
{
AuthenticatedUrl = authenticatedUrl,
UnauthenticatedUrl = unauthenticatedUrl,
RegisteredAt = DateTime.UtcNow,
TokenExpiresAt = tokenExpiry,
Urn = urn,
IsLiveStream = isLiveStream,
LastLivestreamFetchAt = isLiveStream ? DateTime.UtcNow : null
};
RegisterWithGuidFormats(itemId, streamInfo);
if (tokenExpiry.HasValue)
{
_logger.LogDebug(
"Registered stream for item {ItemId} (token expires at {ExpiresAt} UTC): {Url}",
itemId,
tokenExpiry.Value,
authenticatedUrl);
}
else
{
_logger.LogDebug("Registered stream for item {ItemId}: {Url}", itemId, authenticatedUrl);
}
}
/// <summary> /// <summary>
/// Registers a stream for deferred authentication (authenticates on first playback request). /// Registers a stream for deferred authentication (authenticates on first playback request).
/// </summary> /// </summary>
@ -442,7 +403,7 @@ public class StreamProxyService : IStreamProxyService
// Use short cache duration (5 min) for livestreams // Use short cache duration (5 min) for livestreams
var mediaComposition = await _compositionFetcher.GetMediaCompositionAsync(streamInfo.Urn, cancellationToken, 5).ConfigureAwait(false); var mediaComposition = await _compositionFetcher.GetMediaCompositionAsync(streamInfo.Urn, cancellationToken, 5).ConfigureAwait(false);
if (mediaComposition?.ChapterList == null || mediaComposition.ChapterList.Count == 0) if (mediaComposition?.HasChapters != true)
{ {
_logger.LogWarning("No chapters found when refreshing livestream URL for URN: {Urn}", streamInfo.Urn); _logger.LogWarning("No chapters found when refreshing livestream URL for URN: {Urn}", streamInfo.Urn);
return null; return null;
@ -863,6 +824,65 @@ public class StreamProxyService : IStreamProxyService
} }
} }
/// <summary>
/// Rewrites URLs in a variant (sub) manifest to point to the proxy.
/// </summary>
/// <param name="manifestContent">The variant manifest content.</param>
/// <param name="baseProxyUrl">The base proxy URL (without query params).</param>
/// <param name="queryParams">Query parameters to append to rewritten URLs (e.g., "?token=abc").</param>
/// <returns>The rewritten manifest content.</returns>
public string RewriteVariantManifestUrls(string manifestContent, string baseProxyUrl, string queryParams)
{
string RewriteUrl(string url)
{
if (url.Contains("://", StringComparison.Ordinal))
{
// Absolute URL - extract filename and rewrite
var uri = new Uri(url.Trim());
var segments = uri.AbsolutePath.Split('/');
var fileName = segments[^1];
return $"{baseProxyUrl}/{fileName}{queryParams}";
}
// Relative URL - rewrite to proxy
return $"{baseProxyUrl}/{url.Trim()}{queryParams}";
}
var lines = manifestContent.Split('\n');
var result = new System.Text.StringBuilder();
foreach (var line in lines)
{
if (string.IsNullOrWhiteSpace(line))
{
result.AppendLine(line);
}
else if (line.StartsWith('#'))
{
// HLS tag line - check for URI="..." attributes (e.g., #EXT-X-MAP:URI="init.mp4")
if (line.Contains("URI=\"", StringComparison.Ordinal))
{
var rewrittenLine = Regex.Replace(
line,
@"URI=""([^""]+)""",
match => $"URI=\"{RewriteUrl(match.Groups[1].Value)}\"");
result.AppendLine(rewrittenLine);
}
else
{
result.AppendLine(line);
}
}
else
{
// Non-tag line with URL - rewrite it
result.AppendLine(RewriteUrl(line));
}
}
return result.ToString();
}
/// <summary> /// <summary>
/// Cleans up old and expired stream mappings. /// Cleans up old and expired stream mappings.
/// </summary> /// </summary>

View File

@ -53,7 +53,7 @@ public class StreamUrlResolver : IStreamUrlResolver
// Filter out DRM-protected content // Filter out DRM-protected content
var nonDrmResources = chapter.ResourceList var nonDrmResources = chapter.ResourceList
.Where(r => r.DrmList == null || r.DrmList.ToString() == "[]") .Where(r => r.IsPlayable)
.ToList(); .ToList();
_logger.LogInformation( _logger.LogInformation(
@ -136,8 +136,8 @@ public class StreamUrlResolver : IStreamUrlResolver
{ {
QualityPreference.HD => SelectHDResource(hlsResources) ?? SelectBestAvailableResource(hlsResources), QualityPreference.HD => SelectHDResource(hlsResources) ?? SelectBestAvailableResource(hlsResources),
QualityPreference.SD => SelectSDResource(hlsResources) ?? SelectBestAvailableResource(hlsResources), QualityPreference.SD => SelectSDResource(hlsResources) ?? SelectBestAvailableResource(hlsResources),
QualityPreference.Auto => SelectBestAvailableResource(hlsResources), QualityPreference.Auto => hlsResources.FirstOrDefault(),
_ => SelectBestAvailableResource(hlsResources) _ => hlsResources.FirstOrDefault()
}; };
if (selectedResource != null) if (selectedResource != null)
@ -164,10 +164,33 @@ public class StreamUrlResolver : IStreamUrlResolver
{ {
if (chapter?.ResourceList == null || chapter.ResourceList.Count == 0) if (chapter?.ResourceList == null || chapter.ResourceList.Count == 0)
{ {
_logger.LogWarning(
"Chapter {ChapterId}: ResourceList is null or empty. ResourceList count: {Count}, ResourceList type: {Type}",
chapter?.Id ?? "null",
chapter?.ResourceList?.Count ?? -1,
chapter?.ResourceList?.GetType().Name ?? "null");
return false; return false;
} }
return chapter.ResourceList.Any(r => r.DrmList == null || r.DrmList.ToString() == "[]"); _logger.LogInformation(
"Chapter {ChapterId}: Found {ResourceCount} resources",
chapter.Id,
chapter.ResourceList.Count);
foreach (var resource in chapter.ResourceList)
{
var urlPreview = resource.Url == null ? "null" : resource.Url.AsSpan(0, Math.Min(60, resource.Url.Length)).ToString() + "...";
_logger.LogDebug(
"Resource - URL: {Url}, Quality: {Quality}, DrmList: {DrmList}, DrmList type: {DrmListType}",
urlPreview,
resource.Quality,
resource.DrmList?.ToString() ?? "null",
resource.DrmList?.GetType().Name ?? "null");
}
var hasPlayable = chapter.ResourceList.Any(r => r.IsPlayable);
_logger.LogInformation("Chapter {ChapterId}: Has playable content: {HasPlayable}", chapter.Id, hasPlayable);
return hasPlayable;
} }
/// <summary> /// <summary>

View File

@ -1,5 +1,4 @@
using Jellyfin.Plugin.SRFPlay.Configuration; using Jellyfin.Plugin.SRFPlay.Configuration;
using MediaBrowser.Controller.Entities;
namespace Jellyfin.Plugin.SRFPlay.Utilities; namespace Jellyfin.Plugin.SRFPlay.Utilities;
@ -8,18 +7,6 @@ namespace Jellyfin.Plugin.SRFPlay.Utilities;
/// </summary> /// </summary>
public static class Extensions public static class Extensions
{ {
/// <summary>
/// Gets the SRF URN from the item's provider IDs.
/// </summary>
/// <param name="item">The base item.</param>
/// <returns>The SRF URN, or null if not found or empty.</returns>
public static string? GetSrfUrn(this BaseItem item)
{
return item.ProviderIds.TryGetValue("SRF", out var urn) && !string.IsNullOrEmpty(urn)
? urn
: null;
}
/// <summary> /// <summary>
/// Converts the BusinessUnit enum to its lowercase string representation. /// Converts the BusinessUnit enum to its lowercase string representation.
/// </summary> /// </summary>
@ -29,13 +16,4 @@ public static class Extensions
{ {
return businessUnit.ToString().ToLowerInvariant(); return businessUnit.ToString().ToLowerInvariant();
} }
/// <summary>
/// Gets the plugin configuration safely, returning null if not available.
/// </summary>
/// <returns>The plugin configuration, or null if not available.</returns>
public static PluginConfiguration? GetPluginConfig()
{
return Plugin.Instance?.Configuration;
}
} }

View File

@ -23,7 +23,7 @@ Then install "SRF Play" from the plugin catalog.
- Support for all Swiss broadcasting units (SRF, RTS, RSI, RTR, SWI) - Support for all Swiss broadcasting units (SRF, RTS, RSI, RTR, SWI)
- Automatic content expiration handling - Automatic content expiration handling
- Latest and trending content discovery - Latest and trending content discovery
- Quality selection (Auto, SD, HD) - Quality selection (Auto lets CDN decide, SD prefers 480p/360p, HD prefers 1080p/720p)
- HLS streaming support with Akamai token authentication - HLS streaming support with Akamai token authentication
- 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
@ -126,7 +126,7 @@ The compiled plugin will be in `bin/Debug/net8.0/`
## Configuration ## Configuration
- **Business Unit**: Select the Swiss broadcasting unit (default: SRF) - **Business Unit**: Select the Swiss broadcasting unit (default: SRF)
- **Quality Preference**: Choose video quality (Auto/SD/HD) - **Quality Preference**: Choose video quality — Auto (first available, CDN decides), SD, or HD
- **Content Refresh Interval**: How often to check for new content (1-168 hours) - **Content Refresh Interval**: How often to check for new content (1-168 hours)
- **Expiration Check Interval**: How often to check for expired content (1-168 hours) - **Expiration Check Interval**: How often to check for expired content (1-168 hours)
- **Cache Duration**: How long to cache metadata (5-1440 minutes) - **Cache Duration**: How long to cache metadata (5-1440 minutes)
@ -154,49 +154,78 @@ If you encounter issues with the plugin:
``` ```
Jellyfin.Plugin.SRFPlay/ Jellyfin.Plugin.SRFPlay/
├── Api/ ├── Api/
│ ├── Models/ # API response models │ ├── Models/ # API response models
│ │ ├── MediaComposition.cs │ │ ├── MediaComposition.cs # Root composition (HasChapters helper)
│ │ ├── Chapter.cs │ │ ├── Chapter.cs # Video/episode chapter with resources
│ │ ├── Resource.cs │ │ ├── Resource.cs # Stream URL entry (IsPlayable helper)
│ │ ├── Show.cs │ │ ├── Show.cs
│ │ ├── Episode.cs │ │ ├── Episode.cs
│ │ └── PlayV3/ # Play v3 API models │ │ └── PlayV3/ # Play v3 API models
│ │ ├── PlayV3Show.cs
│ │ ├── PlayV3Topic.cs
│ │ ├── PlayV3Video.cs
│ │ ├── PlayV3TvProgram.cs │ │ ├── PlayV3TvProgram.cs
│ │ └── PlayV3TvProgramGuideResponse.cs │ │ ├── PlayV3Response.cs
│ └── SRFApiClient.cs # HTTP client for SRF API │ │ ├── PlayV3DirectResponse.cs
│ │ └── PlayV3DataContainer.cs
│ ├── SRFApiClient.cs # HTTP client for SRF APIs
│ ├── ISRFApiClientFactory.cs # Factory interface
│ └── SRFApiClientFactory.cs # Factory implementation
├── Channels/ ├── Channels/
│ └── SRFPlayChannel.cs # Channel implementation │ └── SRFPlayChannel.cs # Channel implementation
├── Configuration/ ├── Configuration/
│ ├── PluginConfiguration.cs │ ├── PluginConfiguration.cs
│ └── configPage.html │ └── configPage.html
├── Constants/
│ └── ApiEndpoints.cs # API base URLs and endpoint constants
├── Controllers/
│ └── StreamProxyController.cs # HLS proxy endpoints (master/variant/segment)
├── Services/ ├── Services/
│ ├── StreamUrlResolver.cs # HLS stream resolution & authentication │ ├── Interfaces/ # Service contracts
│ ├── MetadataCache.cs # Caching layer │ │ ├── IStreamProxyService.cs
│ ├── ContentExpirationService.cs # Expiration management │ │ ├── IStreamUrlResolver.cs
│ ├── ContentRefreshService.cs # Content discovery │ │ ├── IMediaCompositionFetcher.cs
│ └── CategoryService.cs # Topic/category management │ │ ├── IMediaSourceFactory.cs
│ │ ├── IMetadataCache.cs
│ │ ├── IContentRefreshService.cs
│ │ ├── IContentExpirationService.cs
│ │ └── ICategoryService.cs
│ ├── StreamProxyService.cs # HLS proxy: auth, manifest rewriting, segments
│ ├── StreamUrlResolver.cs # Stream selection & Akamai authentication
│ ├── MediaCompositionFetcher.cs # Cached API fetcher
│ ├── MediaSourceFactory.cs # Jellyfin MediaSourceInfo builder
│ ├── MetadataCache.cs # Thread-safe ConcurrentDictionary cache
│ ├── ContentExpirationService.cs
│ ├── ContentRefreshService.cs
│ └── CategoryService.cs
├── Providers/ ├── Providers/
│ ├── SRFSeriesProvider.cs # Series metadata │ ├── SRFSeriesProvider.cs # Series metadata
│ ├── SRFEpisodeProvider.cs # Episode metadata │ ├── SRFEpisodeProvider.cs # Episode metadata
│ ├── SRFImageProvider.cs # Image fetching │ ├── SRFImageProvider.cs # Image fetching
│ └── SRFMediaProvider.cs # Playback URLs │ └── SRFMediaProvider.cs # Playback URLs
├── Utilities/
│ ├── Extensions.cs # BusinessUnit.ToLowerString() extension
│ ├── MimeTypeHelper.cs # Content-type detection
│ ├── PlaceholderImageGenerator.cs
│ └── UrnHelper.cs # URN parsing utilities
├── ScheduledTasks/ ├── ScheduledTasks/
│ ├── ContentRefreshTask.cs # Periodic content refresh │ ├── ContentRefreshTask.cs # Periodic content refresh
│ └── ExpirationCheckTask.cs # Periodic expiration check │ └── ExpirationCheckTask.cs # Periodic expiration check
├── ServiceRegistrator.cs # DI registration ├── ServiceRegistrator.cs # DI registration
└── Plugin.cs # Main plugin entry point └── Plugin.cs # Main plugin entry point
``` ```
### Key Components ### Key Components
1. **API Client**: Handles all HTTP requests to SRF Integration Layer and Play v3 API 1. **API Client** (`SRFApiClient`): HTTP requests to SRF Integration Layer and Play v3 API, with proxy support
2. **Channel**: SRF Play channel with Latest, Trending, and Live Sports folders 2. **Channel** (`SRFPlayChannel`): SRF Play channel with Latest, Trending, and Live Sports folders
3. **Stream Resolver**: Extracts and selects optimal HLS streams with Akamai authentication 3. **Stream Proxy** (`StreamProxyService` + `StreamProxyController`): HLS proxy that handles Akamai token auth, manifest URL rewriting, deferred authentication, and token refresh for both VOD and livestreams
4. **Configuration**: User-configurable settings via Jellyfin dashboard 4. **Stream Resolver** (`StreamUrlResolver`): Selects optimal HLS stream by quality preference, filters DRM content
5. **Metadata Cache**: Thread-safe caching with dynamic TTL for livestreams 5. **Metadata Cache** (`MetadataCache`): Thread-safe `ConcurrentDictionary` cache with dynamic TTL for livestreams
6. **Content Providers**: Jellyfin integration for series, episodes, images, and media sources 6. **Media Composition Fetcher** (`MediaCompositionFetcher`): Cached wrapper around API client for media composition requests
7. **Scheduled Tasks**: Automatic content refresh and expiration management 7. **Content Providers** (`SRFSeriesProvider`, `SRFEpisodeProvider`, `SRFImageProvider`, `SRFMediaProvider`): Jellyfin integration for series, episodes, images, and media sources
8. **Service Layer**: Content discovery, expiration handling, stream resolution, and category management 8. **Scheduled Tasks**: Automatic content refresh and expiration management
9. **Utilities**: Business unit extensions, MIME type helpers, URN parsing, placeholder image generation
## Important Notes ## Important Notes
@ -249,7 +278,9 @@ See LICENSE file for details.
## Acknowledgments ## Acknowledgments
This plugin was developed with inspiration from the excellent [Kodi SRG SSR addon](https://github.com/goggle/script.module.srgssr) by [@goggle](https://github.com/goggle). The Kodi addon served as a fantastic reference for understanding the SRG SSR API structure, authentication mechanisms, and handling of scheduled livestreams. This plugin was developed partly using [Claude Code](https://docs.anthropic.com/en/docs/claude-code) by Anthropic.
Inspired by the excellent [Kodi SRG SSR addon](https://github.com/goggle/script.module.srgssr) by [@goggle](https://github.com/goggle), which served as a fantastic reference for understanding the SRG SSR API structure, authentication mechanisms, and handling of scheduled livestreams.
## References ## References