Clean up dead code, consolidate duplication, fix redundancies
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:
parent
873e531599
commit
7c76402a4a
@ -115,7 +115,7 @@ namespace Jellyfin.Plugin.SRFPlay.Tests
|
||||
if (chapter.ResourceList != null && chapter.ResourceList.Any())
|
||||
{
|
||||
var hlsResource = chapter.ResourceList.FirstOrDefault(r =>
|
||||
r.Protocol == "HLS" && (r.DrmList == null || r.DrmList.ToString() == "[]"));
|
||||
r.Protocol == "HLS" && r.IsPlayable);
|
||||
|
||||
if (hlsResource != null)
|
||||
{
|
||||
|
||||
@ -74,7 +74,9 @@ public class Chapter
|
||||
/// Gets or sets the list of available resources (streams).
|
||||
/// </summary>
|
||||
[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>
|
||||
/// Gets or sets the episode number.
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Jellyfin.Plugin.SRFPlay.Api.Models;
|
||||
@ -15,6 +14,12 @@ public class MediaComposition
|
||||
[JsonPropertyName("chapterList")]
|
||||
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>
|
||||
/// Gets or sets the episode information.
|
||||
/// </summary>
|
||||
|
||||
@ -2,6 +2,9 @@ using System.Text.Json.Serialization;
|
||||
|
||||
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>
|
||||
/// Represents a streaming resource (URL) in the SRF API response.
|
||||
/// </summary>
|
||||
@ -48,4 +51,10 @@ public class Resource
|
||||
/// </summary>
|
||||
[JsonPropertyName("drmList")]
|
||||
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() == "[]";
|
||||
}
|
||||
|
||||
@ -228,7 +228,7 @@ public class SRFApiClient : IDisposable
|
||||
|
||||
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);
|
||||
}
|
||||
@ -248,38 +248,8 @@ public class SRFApiClient : IDisposable
|
||||
/// <param name="businessUnit">The business unit (e.g., srf, rts).</param>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <returns>The media composition containing latest videos.</returns>
|
||||
public async Task<MediaComposition?> GetLatestVideosAsync(string businessUnit, CancellationToken cancellationToken = default)
|
||||
{
|
||||
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;
|
||||
}
|
||||
}
|
||||
public Task<MediaComposition?> GetLatestVideosAsync(string businessUnit, CancellationToken cancellationToken = default)
|
||||
=> GetMediaCompositionListAsync(businessUnit, "latest", cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// 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="cancellationToken">The cancellation token.</param>
|
||||
/// <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
|
||||
{
|
||||
var url = $"/video/{businessUnit}/trending.json";
|
||||
_logger.LogInformation("Fetching trending videos for business unit: {BusinessUnit} from URL: {Url}", businessUnit, url);
|
||||
var url = $"/video/{businessUnit}/{endpoint}.json";
|
||||
_logger.LogInformation("Fetching {Endpoint} videos for business unit: {BusinessUnit} from URL: {Url}", endpoint, businessUnit, url);
|
||||
|
||||
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)
|
||||
{
|
||||
@ -306,41 +279,16 @@ public class SRFApiClient : IDisposable
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
_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;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error fetching trending videos for business unit: {BusinessUnit}", 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);
|
||||
_logger.LogError(ex, "Error fetching {Endpoint} videos for business unit: {BusinessUnit}", endpoint, businessUnit);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@ -351,35 +299,8 @@ public class SRFApiClient : IDisposable
|
||||
/// <param name="businessUnit">The business unit (e.g., srf, rts).</param>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <returns>List of shows.</returns>
|
||||
public async Task<System.Collections.Generic.List<PlayV3Show>?> GetAllShowsAsync(string businessUnit, CancellationToken cancellationToken = default)
|
||||
{
|
||||
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;
|
||||
}
|
||||
}
|
||||
public Task<System.Collections.Generic.List<PlayV3Show>?> GetAllShowsAsync(string businessUnit, CancellationToken cancellationToken = default)
|
||||
=> GetPlayV3DirectListAsync<PlayV3Show>(businessUnit, "shows", cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// 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="cancellationToken">The cancellation token.</param>
|
||||
/// <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
|
||||
{
|
||||
var baseUrl = string.Format(CultureInfo.InvariantCulture, PlayV3UrlFormat, businessUnit);
|
||||
var url = $"{baseUrl}topics";
|
||||
_logger.LogInformation("Fetching all topics for business unit: {BusinessUnit} from URL: {Url}", businessUnit, url);
|
||||
var url = $"{baseUrl}{endpoint}";
|
||||
_logger.LogInformation("Fetching all {Endpoint} for business unit: {BusinessUnit} from URL: {Url}", endpoint, businessUnit, url);
|
||||
|
||||
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 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;
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -190,7 +190,7 @@ public class SRFPlayChannel : IChannel, IHasCacheKey
|
||||
{
|
||||
try
|
||||
{
|
||||
var businessUnit = config.BusinessUnit.ToString().ToLowerInvariant();
|
||||
var businessUnit = config.BusinessUnit.ToLowerString();
|
||||
var topics = await _categoryService.GetTopicsAsync(businessUnit, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
foreach (var topic in topics.Where(t => !string.IsNullOrEmpty(t.Id)))
|
||||
@ -248,7 +248,7 @@ public class SRFPlayChannel : IChannel, IHasCacheKey
|
||||
|
||||
try
|
||||
{
|
||||
var businessUnit = config?.BusinessUnit.ToString().ToLowerInvariant() ?? "srf";
|
||||
var businessUnit = config?.BusinessUnit.ToLowerString() ?? "srf";
|
||||
|
||||
using var apiClient = _apiClientFactory.CreateClient();
|
||||
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 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 urns = new List<string>();
|
||||
@ -440,7 +440,7 @@ public class SRFPlayChannel : IChannel, IHasCacheKey
|
||||
_logger.LogDebug("Processing URN: {Urn}", urn);
|
||||
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);
|
||||
failedCount++;
|
||||
|
||||
@ -28,12 +28,6 @@ public static class ApiEndpoints
|
||||
/// </summary>
|
||||
public const string SrfPlayHomepage = "https://www.srf.ch/play";
|
||||
|
||||
/// <summary>
|
||||
/// Media composition endpoint template (relative to IntegrationLayerBaseUrl).
|
||||
/// Format: {0} = URN.
|
||||
/// </summary>
|
||||
public const string MediaCompositionByUrnPath = "/mediaComposition/byUrn/{0}.json";
|
||||
|
||||
/// <summary>
|
||||
/// Base proxy route for stream proxying (relative to server root).
|
||||
/// </summary>
|
||||
@ -44,30 +38,4 @@ public static class ApiEndpoints
|
||||
/// Format: {0} = item ID.
|
||||
/// </summary>
|
||||
public const string ProxyMasterManifestPath = "/Plugins/SRFPlay/Proxy/{0}/master.m3u8";
|
||||
|
||||
/// <summary>
|
||||
/// Image proxy route template (relative to server root).
|
||||
/// Format: {0} = base64-encoded original URL.
|
||||
/// </summary>
|
||||
public const string ImageProxyPath = "/Plugins/SRFPlay/Image/{0}";
|
||||
|
||||
/// <summary>
|
||||
/// Play V3 shows endpoint (relative to PlayV3 base URL).
|
||||
/// </summary>
|
||||
public const string PlayV3ShowsPath = "shows";
|
||||
|
||||
/// <summary>
|
||||
/// Play V3 topics endpoint (relative to PlayV3 base URL).
|
||||
/// </summary>
|
||||
public const string PlayV3TopicsPath = "topics";
|
||||
|
||||
/// <summary>
|
||||
/// Play V3 livestreams endpoint (relative to PlayV3 base URL).
|
||||
/// </summary>
|
||||
public const string PlayV3LivestreamsPath = "livestreams";
|
||||
|
||||
/// <summary>
|
||||
/// Play V3 scheduled livestreams endpoint (relative to PlayV3 base URL).
|
||||
/// </summary>
|
||||
public const string PlayV3ScheduledLivestreamsPath = "scheduled-livestreams";
|
||||
}
|
||||
|
||||
@ -196,7 +196,23 @@ public class StreamProxyController : ControllerBase
|
||||
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);
|
||||
|
||||
// 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)
|
||||
AddManifestCacheHeaders(actualItemId);
|
||||
@ -311,81 +327,6 @@ public class StreamProxyController : ControllerBase
|
||||
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>
|
||||
/// Proxies image requests from SRF CDN, fixing Content-Type headers.
|
||||
/// </summary>
|
||||
|
||||
@ -50,7 +50,7 @@ public class SRFEpisodeProvider : IRemoteMetadataProvider<Episode, EpisodeInfo>
|
||||
|
||||
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];
|
||||
results.Add(new RemoteSearchResult
|
||||
@ -93,7 +93,7 @@ public class SRFEpisodeProvider : IRemoteMetadataProvider<Episode, EpisodeInfo>
|
||||
|
||||
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);
|
||||
return result;
|
||||
|
||||
@ -92,7 +92,7 @@ public class SRFImageProvider : IRemoteImageProvider, IHasOrder
|
||||
}
|
||||
|
||||
// Extract images from chapters
|
||||
if (mediaComposition.ChapterList != null && mediaComposition.ChapterList.Count > 0)
|
||||
if (mediaComposition.HasChapters)
|
||||
{
|
||||
var chapter = mediaComposition.ChapterList[0];
|
||||
if (!string.IsNullOrEmpty(chapter.ImageUrl))
|
||||
|
||||
@ -70,7 +70,7 @@ public class SRFMediaProvider : IMediaSourceProvider
|
||||
|
||||
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);
|
||||
return sources;
|
||||
@ -116,7 +116,7 @@ public class SRFMediaProvider : IMediaSourceProvider
|
||||
// Force fresh fetch with short cache duration
|
||||
var freshMediaComposition = await _compositionFetcher.GetMediaCompositionAsync(urn, cancellationToken, 1).ConfigureAwait(false);
|
||||
|
||||
if (freshMediaComposition?.ChapterList != null && freshMediaComposition.ChapterList.Count > 0)
|
||||
if (freshMediaComposition?.HasChapters == true)
|
||||
{
|
||||
var freshChapter = freshMediaComposition.ChapterList[0];
|
||||
mediaSource = await _mediaSourceFactory.CreateMediaSourceAsync(
|
||||
|
||||
@ -60,25 +60,8 @@ public class ExpirationCheckTask : IScheduledTask
|
||||
return;
|
||||
}
|
||||
|
||||
// Get expiration statistics first
|
||||
// Check and remove expired content
|
||||
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);
|
||||
|
||||
// Clean up old stream proxy mappings
|
||||
|
||||
@ -118,7 +118,7 @@ public class ContentExpirationService : IContentExpirationService
|
||||
|
||||
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
|
||||
_logger.LogWarning("Could not fetch media composition for URN: {Urn}, treating as expired", urn);
|
||||
@ -135,72 +135,4 @@ public class ContentExpirationService : IContentExpirationService
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -5,6 +5,7 @@ using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Plugin.SRFPlay.Api;
|
||||
using Jellyfin.Plugin.SRFPlay.Services.Interfaces;
|
||||
using Jellyfin.Plugin.SRFPlay.Utilities;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Jellyfin.Plugin.SRFPlay.Services;
|
||||
@ -45,7 +46,7 @@ public class ContentRefreshService : IContentRefreshService
|
||||
}
|
||||
|
||||
return await FetchVideosFromShowsAsync(
|
||||
config.BusinessUnit.ToString().ToLowerInvariant(),
|
||||
config.BusinessUnit.ToLowerString(),
|
||||
minEpisodeCount: 0,
|
||||
maxShows: 20,
|
||||
videosPerShow: 1,
|
||||
@ -69,7 +70,7 @@ public class ContentRefreshService : IContentRefreshService
|
||||
}
|
||||
|
||||
return await FetchVideosFromShowsAsync(
|
||||
config.BusinessUnit.ToString().ToLowerInvariant(),
|
||||
config.BusinessUnit.ToLowerString(),
|
||||
minEpisodeCount: 10,
|
||||
maxShows: 15,
|
||||
videosPerShow: 2,
|
||||
@ -167,48 +168,4 @@ public class ContentRefreshService : IContentRefreshService
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
@ -14,11 +14,4 @@ public interface IContentExpirationService
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <returns>The number of items removed.</returns>
|
||||
Task<int> CheckAndRemoveExpiredContentAsync(CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Gets statistics about content expiration.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <returns>Tuple with total count, expired count, and items expiring soon.</returns>
|
||||
Task<(int Total, int Expired, int ExpiringSoon)> GetExpirationStatisticsAsync(CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
@ -22,18 +22,4 @@ public interface IContentRefreshService
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <returns>List of URNs for trending content.</returns>
|
||||
Task<List<string>> RefreshTrendingContentAsync(CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Refreshes all content (latest and trending).
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <returns>Tuple with counts of latest and trending items.</returns>
|
||||
Task<(int LatestCount, int TrendingCount)> RefreshAllContentAsync(CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Gets content recommendations (combines latest and trending).
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <returns>List of recommended URNs.</returns>
|
||||
Task<List<string>> GetRecommendedContentAsync(CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
@ -22,20 +22,8 @@ public interface IMetadataCache
|
||||
/// <param name="mediaComposition">The media composition to cache.</param>
|
||||
void SetMediaComposition(string urn, MediaComposition mediaComposition);
|
||||
|
||||
/// <summary>
|
||||
/// Removes media composition from cache.
|
||||
/// </summary>
|
||||
/// <param name="urn">The URN.</param>
|
||||
void RemoveMediaComposition(string urn);
|
||||
|
||||
/// <summary>
|
||||
/// Clears all cached data.
|
||||
/// </summary>
|
||||
void Clear();
|
||||
|
||||
/// <summary>
|
||||
/// Gets the cache statistics.
|
||||
/// </summary>
|
||||
/// <returns>A tuple with cache count and size estimate.</returns>
|
||||
(int Count, long SizeEstimate) GetStatistics();
|
||||
}
|
||||
|
||||
@ -8,15 +8,6 @@ namespace Jellyfin.Plugin.SRFPlay.Services.Interfaces;
|
||||
/// </summary>
|
||||
public interface IStreamProxyService
|
||||
{
|
||||
/// <summary>
|
||||
/// Registers a stream for proxying with an already-authenticated URL.
|
||||
/// </summary>
|
||||
/// <param name="itemId">The item ID.</param>
|
||||
/// <param name="authenticatedUrl">The authenticated stream URL.</param>
|
||||
/// <param name="urn">The SRF URN for this content (used for re-fetching fresh URLs).</param>
|
||||
/// <param name="isLiveStream">Whether this is a livestream (livestreams always fetch fresh URLs).</param>
|
||||
void RegisterStream(string itemId, string authenticatedUrl, string? urn = null, bool isLiveStream = false);
|
||||
|
||||
/// <summary>
|
||||
/// Registers a stream for deferred authentication (authenticates on first playback request).
|
||||
/// Use this when browsing to avoid wasting 30-second tokens before the user clicks play.
|
||||
@ -61,6 +52,15 @@ public interface IStreamProxyService
|
||||
/// <returns>The segment content as bytes.</returns>
|
||||
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>
|
||||
/// Cleans up old and expired stream mappings.
|
||||
/// </summary>
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Threading;
|
||||
using Jellyfin.Plugin.SRFPlay.Api.Models;
|
||||
using Jellyfin.Plugin.SRFPlay.Services.Interfaces;
|
||||
using Microsoft.Extensions.Logging;
|
||||
@ -10,12 +9,10 @@ namespace Jellyfin.Plugin.SRFPlay.Services;
|
||||
/// <summary>
|
||||
/// Service for caching metadata from SRF API.
|
||||
/// </summary>
|
||||
public sealed class MetadataCache : IMetadataCache, IDisposable
|
||||
public sealed class MetadataCache : IMetadataCache
|
||||
{
|
||||
private readonly ILogger<MetadataCache> _logger;
|
||||
private readonly ConcurrentDictionary<string, CacheEntry<MediaComposition>> _mediaCompositionCache;
|
||||
private readonly ReaderWriterLockSlim _lock;
|
||||
private bool _disposed;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="MetadataCache"/> class.
|
||||
@ -25,19 +22,6 @@ public sealed class MetadataCache : IMetadataCache, IDisposable
|
||||
{
|
||||
_logger = logger;
|
||||
_mediaCompositionCache = new ConcurrentDictionary<string, CacheEntry<MediaComposition>>();
|
||||
_lock = new ReaderWriterLockSlim();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Disposes resources.
|
||||
/// </summary>
|
||||
public void Dispose()
|
||||
{
|
||||
if (!_disposed)
|
||||
{
|
||||
_lock?.Dispose();
|
||||
_disposed = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@ -53,30 +37,15 @@ public sealed class MetadataCache : IMetadataCache, IDisposable
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
if (_mediaCompositionCache.TryGetValue(urn, out var entry))
|
||||
{
|
||||
_lock.EnterReadLock();
|
||||
try
|
||||
if (entry.IsValid(cacheDurationMinutes))
|
||||
{
|
||||
if (_mediaCompositionCache.TryGetValue(urn, out var entry))
|
||||
{
|
||||
if (entry.IsValid(cacheDurationMinutes))
|
||||
{
|
||||
_logger.LogDebug("Cache hit for URN: {Urn}", urn);
|
||||
return entry.Value;
|
||||
}
|
||||
_logger.LogDebug("Cache hit for URN: {Urn}", urn);
|
||||
return entry.Value;
|
||||
}
|
||||
|
||||
_logger.LogDebug("Cache entry expired for URN: {Urn}", urn);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_lock.ExitReadLock();
|
||||
}
|
||||
}
|
||||
catch (ObjectDisposedException)
|
||||
{
|
||||
return null;
|
||||
_logger.LogDebug("Cache entry expired for URN: {Urn}", urn);
|
||||
}
|
||||
|
||||
return null;
|
||||
@ -94,56 +63,9 @@ public sealed class MetadataCache : IMetadataCache, IDisposable
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
_lock.EnterWriteLock();
|
||||
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
|
||||
}
|
||||
var entry = new CacheEntry<MediaComposition>(mediaComposition);
|
||||
_mediaCompositionCache.AddOrUpdate(urn, entry, (key, oldValue) => entry);
|
||||
_logger.LogDebug("Cached media composition for URN: {Urn}", urn);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@ -151,50 +73,8 @@ public sealed class MetadataCache : IMetadataCache, IDisposable
|
||||
/// </summary>
|
||||
public void Clear()
|
||||
{
|
||||
try
|
||||
{
|
||||
_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);
|
||||
}
|
||||
_mediaCompositionCache.Clear();
|
||||
_logger.LogInformation("Cleared metadata cache");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@ -46,45 +46,6 @@ public class StreamProxyService : IStreamProxyService
|
||||
_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>
|
||||
/// Registers a stream for deferred authentication (authenticates on first playback request).
|
||||
/// </summary>
|
||||
@ -442,7 +403,7 @@ public class StreamProxyService : IStreamProxyService
|
||||
// Use short cache duration (5 min) for livestreams
|
||||
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);
|
||||
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>
|
||||
/// Cleans up old and expired stream mappings.
|
||||
/// </summary>
|
||||
|
||||
@ -53,7 +53,7 @@ public class StreamUrlResolver : IStreamUrlResolver
|
||||
|
||||
// Filter out DRM-protected content
|
||||
var nonDrmResources = chapter.ResourceList
|
||||
.Where(r => r.DrmList == null || r.DrmList.ToString() == "[]")
|
||||
.Where(r => r.IsPlayable)
|
||||
.ToList();
|
||||
|
||||
_logger.LogInformation(
|
||||
@ -136,8 +136,8 @@ public class StreamUrlResolver : IStreamUrlResolver
|
||||
{
|
||||
QualityPreference.HD => SelectHDResource(hlsResources) ?? SelectBestAvailableResource(hlsResources),
|
||||
QualityPreference.SD => SelectSDResource(hlsResources) ?? SelectBestAvailableResource(hlsResources),
|
||||
QualityPreference.Auto => SelectBestAvailableResource(hlsResources),
|
||||
_ => SelectBestAvailableResource(hlsResources)
|
||||
QualityPreference.Auto => hlsResources.FirstOrDefault(),
|
||||
_ => hlsResources.FirstOrDefault()
|
||||
};
|
||||
|
||||
if (selectedResource != null)
|
||||
@ -164,10 +164,33 @@ public class StreamUrlResolver : IStreamUrlResolver
|
||||
{
|
||||
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 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>
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
using Jellyfin.Plugin.SRFPlay.Configuration;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
|
||||
namespace Jellyfin.Plugin.SRFPlay.Utilities;
|
||||
|
||||
@ -8,18 +7,6 @@ namespace Jellyfin.Plugin.SRFPlay.Utilities;
|
||||
/// </summary>
|
||||
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>
|
||||
/// Converts the BusinessUnit enum to its lowercase string representation.
|
||||
/// </summary>
|
||||
@ -29,13 +16,4 @@ public static class Extensions
|
||||
{
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
95
README.md
95
README.md
@ -23,7 +23,7 @@ Then install "SRF Play" from the plugin catalog.
|
||||
- Support for all Swiss broadcasting units (SRF, RTS, RSI, RTR, SWI)
|
||||
- Automatic content expiration handling
|
||||
- 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
|
||||
- Proxy support for routing traffic through alternate gateways
|
||||
- Smart caching with reduced TTL for upcoming livestreams
|
||||
@ -126,7 +126,7 @@ The compiled plugin will be in `bin/Debug/net8.0/`
|
||||
## Configuration
|
||||
|
||||
- **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)
|
||||
- **Expiration Check Interval**: How often to check for expired content (1-168 hours)
|
||||
- **Cache Duration**: How long to cache metadata (5-1440 minutes)
|
||||
@ -154,49 +154,78 @@ If you encounter issues with the plugin:
|
||||
```
|
||||
Jellyfin.Plugin.SRFPlay/
|
||||
├── Api/
|
||||
│ ├── Models/ # API response models
|
||||
│ │ ├── MediaComposition.cs
|
||||
│ │ ├── Chapter.cs
|
||||
│ │ ├── Resource.cs
|
||||
│ ├── Models/ # API response models
|
||||
│ │ ├── MediaComposition.cs # Root composition (HasChapters helper)
|
||||
│ │ ├── Chapter.cs # Video/episode chapter with resources
|
||||
│ │ ├── Resource.cs # Stream URL entry (IsPlayable helper)
|
||||
│ │ ├── Show.cs
|
||||
│ │ ├── Episode.cs
|
||||
│ │ └── PlayV3/ # Play v3 API models
|
||||
│ │ └── PlayV3/ # Play v3 API models
|
||||
│ │ ├── PlayV3Show.cs
|
||||
│ │ ├── PlayV3Topic.cs
|
||||
│ │ ├── PlayV3Video.cs
|
||||
│ │ ├── PlayV3TvProgram.cs
|
||||
│ │ └── PlayV3TvProgramGuideResponse.cs
|
||||
│ └── SRFApiClient.cs # HTTP client for SRF API
|
||||
│ │ ├── PlayV3Response.cs
|
||||
│ │ ├── PlayV3DirectResponse.cs
|
||||
│ │ └── PlayV3DataContainer.cs
|
||||
│ ├── SRFApiClient.cs # HTTP client for SRF APIs
|
||||
│ ├── ISRFApiClientFactory.cs # Factory interface
|
||||
│ └── SRFApiClientFactory.cs # Factory implementation
|
||||
├── Channels/
|
||||
│ └── SRFPlayChannel.cs # Channel implementation
|
||||
│ └── SRFPlayChannel.cs # Channel implementation
|
||||
├── Configuration/
|
||||
│ ├── PluginConfiguration.cs
|
||||
│ └── configPage.html
|
||||
├── Constants/
|
||||
│ └── ApiEndpoints.cs # API base URLs and endpoint constants
|
||||
├── Controllers/
|
||||
│ └── StreamProxyController.cs # HLS proxy endpoints (master/variant/segment)
|
||||
├── Services/
|
||||
│ ├── StreamUrlResolver.cs # HLS stream resolution & authentication
|
||||
│ ├── MetadataCache.cs # Caching layer
|
||||
│ ├── ContentExpirationService.cs # Expiration management
|
||||
│ ├── ContentRefreshService.cs # Content discovery
|
||||
│ └── CategoryService.cs # Topic/category management
|
||||
│ ├── Interfaces/ # Service contracts
|
||||
│ │ ├── IStreamProxyService.cs
|
||||
│ │ ├── IStreamUrlResolver.cs
|
||||
│ │ ├── IMediaCompositionFetcher.cs
|
||||
│ │ ├── 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/
|
||||
│ ├── SRFSeriesProvider.cs # Series metadata
|
||||
│ ├── SRFEpisodeProvider.cs # Episode metadata
|
||||
│ ├── SRFImageProvider.cs # Image fetching
|
||||
│ └── SRFMediaProvider.cs # Playback URLs
|
||||
│ ├── SRFSeriesProvider.cs # Series metadata
|
||||
│ ├── SRFEpisodeProvider.cs # Episode metadata
|
||||
│ ├── SRFImageProvider.cs # Image fetching
|
||||
│ └── SRFMediaProvider.cs # Playback URLs
|
||||
├── Utilities/
|
||||
│ ├── Extensions.cs # BusinessUnit.ToLowerString() extension
|
||||
│ ├── MimeTypeHelper.cs # Content-type detection
|
||||
│ ├── PlaceholderImageGenerator.cs
|
||||
│ └── UrnHelper.cs # URN parsing utilities
|
||||
├── ScheduledTasks/
|
||||
│ ├── ContentRefreshTask.cs # Periodic content refresh
|
||||
│ └── ExpirationCheckTask.cs # Periodic expiration check
|
||||
├── ServiceRegistrator.cs # DI registration
|
||||
└── Plugin.cs # Main plugin entry point
|
||||
│ ├── ContentRefreshTask.cs # Periodic content refresh
|
||||
│ └── ExpirationCheckTask.cs # Periodic expiration check
|
||||
├── ServiceRegistrator.cs # DI registration
|
||||
└── Plugin.cs # Main plugin entry point
|
||||
```
|
||||
|
||||
### Key Components
|
||||
|
||||
1. **API Client**: Handles all HTTP requests to SRF Integration Layer and Play v3 API
|
||||
2. **Channel**: SRF Play channel with Latest, Trending, and Live Sports folders
|
||||
3. **Stream Resolver**: Extracts and selects optimal HLS streams with Akamai authentication
|
||||
4. **Configuration**: User-configurable settings via Jellyfin dashboard
|
||||
5. **Metadata Cache**: Thread-safe caching with dynamic TTL for livestreams
|
||||
6. **Content Providers**: Jellyfin integration for series, episodes, images, and media sources
|
||||
7. **Scheduled Tasks**: Automatic content refresh and expiration management
|
||||
8. **Service Layer**: Content discovery, expiration handling, stream resolution, and category management
|
||||
1. **API Client** (`SRFApiClient`): HTTP requests to SRF Integration Layer and Play v3 API, with proxy support
|
||||
2. **Channel** (`SRFPlayChannel`): SRF Play channel with Latest, Trending, and Live Sports folders
|
||||
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. **Stream Resolver** (`StreamUrlResolver`): Selects optimal HLS stream by quality preference, filters DRM content
|
||||
5. **Metadata Cache** (`MetadataCache`): Thread-safe `ConcurrentDictionary` cache with dynamic TTL for livestreams
|
||||
6. **Media Composition Fetcher** (`MediaCompositionFetcher`): Cached wrapper around API client for media composition requests
|
||||
7. **Content Providers** (`SRFSeriesProvider`, `SRFEpisodeProvider`, `SRFImageProvider`, `SRFMediaProvider`): Jellyfin integration for series, episodes, images, and media sources
|
||||
8. **Scheduled Tasks**: Automatic content refresh and expiration management
|
||||
9. **Utilities**: Business unit extensions, MIME type helpers, URN parsing, placeholder image generation
|
||||
|
||||
## Important Notes
|
||||
|
||||
@ -249,7 +278,9 @@ See LICENSE file for details.
|
||||
|
||||
## 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
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user