From 7c76402a4a74015075a8b6dad018bd9d4fa68ae6 Mon Sep 17 00:00:00 2001 From: Duncan Tourolle Date: Sat, 28 Feb 2026 11:34:45 +0100 Subject: [PATCH] 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. --- .../TestPlayV3Api.cs | 2 +- Jellyfin.Plugin.SRFPlay/Api/Models/Chapter.cs | 4 +- .../Api/Models/MediaComposition.cs | 7 +- .../Api/Models/Resource.cs | 9 ++ Jellyfin.Plugin.SRFPlay/Api/SRFApiClient.cs | 124 +++------------ .../Channels/SRFPlayChannel.cs | 8 +- .../Constants/ApiEndpoints.cs | 32 ---- .../Controllers/StreamProxyController.cs | 93 +++-------- .../Providers/SRFEpisodeProvider.cs | 4 +- .../Providers/SRFImageProvider.cs | 2 +- .../Providers/SRFMediaProvider.cs | 4 +- .../ScheduledTasks/ExpirationCheckTask.cs | 19 +-- .../Services/ContentExpirationService.cs | 70 +-------- .../Services/ContentRefreshService.cs | 49 +----- .../Interfaces/IContentExpirationService.cs | 7 - .../Interfaces/IContentRefreshService.cs | 14 -- .../Services/Interfaces/IMetadataCache.cs | 12 -- .../Interfaces/IStreamProxyService.cs | 18 +-- .../Services/MetadataCache.cs | 144 ++---------------- .../Services/StreamProxyService.cs | 100 +++++++----- .../Services/StreamUrlResolver.cs | 31 +++- .../Utilities/Extensions.cs | 22 --- README.md | 95 ++++++++---- 23 files changed, 245 insertions(+), 625 deletions(-) diff --git a/Jellyfin.Plugin.SRFPlay.Tests/TestPlayV3Api.cs b/Jellyfin.Plugin.SRFPlay.Tests/TestPlayV3Api.cs index 4f10b98..469910a 100644 --- a/Jellyfin.Plugin.SRFPlay.Tests/TestPlayV3Api.cs +++ b/Jellyfin.Plugin.SRFPlay.Tests/TestPlayV3Api.cs @@ -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) { diff --git a/Jellyfin.Plugin.SRFPlay/Api/Models/Chapter.cs b/Jellyfin.Plugin.SRFPlay/Api/Models/Chapter.cs index 3420dac..684b70f 100644 --- a/Jellyfin.Plugin.SRFPlay/Api/Models/Chapter.cs +++ b/Jellyfin.Plugin.SRFPlay/Api/Models/Chapter.cs @@ -74,7 +74,9 @@ public class Chapter /// Gets or sets the list of available resources (streams). /// [JsonPropertyName("resourceList")] - public IReadOnlyList ResourceList { get; set; } = new List(); + [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 ResourceList { get; set; } = new List(); /// /// Gets or sets the episode number. diff --git a/Jellyfin.Plugin.SRFPlay/Api/Models/MediaComposition.cs b/Jellyfin.Plugin.SRFPlay/Api/Models/MediaComposition.cs index 3fa604e..0b93147 100644 --- a/Jellyfin.Plugin.SRFPlay/Api/Models/MediaComposition.cs +++ b/Jellyfin.Plugin.SRFPlay/Api/Models/MediaComposition.cs @@ -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 ChapterList { get; set; } = new List(); + /// + /// Gets a value indicating whether this composition has any chapters. + /// + [JsonIgnore] + public bool HasChapters => ChapterList != null && ChapterList.Count > 0; + /// /// Gets or sets the episode information. /// diff --git a/Jellyfin.Plugin.SRFPlay/Api/Models/Resource.cs b/Jellyfin.Plugin.SRFPlay/Api/Models/Resource.cs index 4964da0..486a80a 100644 --- a/Jellyfin.Plugin.SRFPlay/Api/Models/Resource.cs +++ b/Jellyfin.Plugin.SRFPlay/Api/Models/Resource.cs @@ -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. + /// /// Represents a streaming resource (URL) in the SRF API response. /// @@ -48,4 +51,10 @@ public class Resource /// [JsonPropertyName("drmList")] public object? DrmList { get; set; } + + /// + /// Gets a value indicating whether this resource is playable (not DRM-protected). + /// + [JsonIgnore] + public bool IsPlayable => DrmList == null || DrmList.ToString() == "[]"; } diff --git a/Jellyfin.Plugin.SRFPlay/Api/SRFApiClient.cs b/Jellyfin.Plugin.SRFPlay/Api/SRFApiClient.cs index 3d73d59..8f6b0cc 100644 --- a/Jellyfin.Plugin.SRFPlay/Api/SRFApiClient.cs +++ b/Jellyfin.Plugin.SRFPlay/Api/SRFApiClient.cs @@ -228,7 +228,7 @@ public class SRFApiClient : IDisposable var result = JsonSerializer.Deserialize(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 /// The business unit (e.g., srf, rts). /// The cancellation token. /// The media composition containing latest videos. - public async Task 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(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 GetLatestVideosAsync(string businessUnit, CancellationToken cancellationToken = default) + => GetMediaCompositionListAsync(businessUnit, "latest", cancellationToken); /// /// Gets the trending videos for a business unit. @@ -287,16 +257,19 @@ public class SRFApiClient : IDisposable /// The business unit (e.g., srf, rts). /// The cancellation token. /// The media composition containing trending videos. - public async Task GetTrendingVideosAsync(string businessUnit, CancellationToken cancellationToken = default) + public Task GetTrendingVideosAsync(string businessUnit, CancellationToken cancellationToken = default) + => GetMediaCompositionListAsync(businessUnit, "trending", cancellationToken); + + private async Task 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(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; - } - } - - /// - /// Gets raw JSON response from a URL. - /// - /// The relative URL. - /// The cancellation token. - /// The JSON string. - public async Task 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 /// The business unit (e.g., srf, rts). /// The cancellation token. /// List of shows. - public async Task?> 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>(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?> GetAllShowsAsync(string businessUnit, CancellationToken cancellationToken = default) + => GetPlayV3DirectListAsync(businessUnit, "shows", cancellationToken); /// /// Gets all topics from the Play v3 API. @@ -387,13 +308,16 @@ public class SRFApiClient : IDisposable /// The business unit (e.g., srf, rts). /// The cancellation token. /// List of topics. - public async Task?> GetAllTopicsAsync(string businessUnit, CancellationToken cancellationToken = default) + public Task?> GetAllTopicsAsync(string businessUnit, CancellationToken cancellationToken = default) + => GetPlayV3DirectListAsync(businessUnit, "topics", cancellationToken); + + private async Task?> GetPlayV3DirectListAsync(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>(content, _jsonOptions); + var result = JsonSerializer.Deserialize>(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; } } diff --git a/Jellyfin.Plugin.SRFPlay/Channels/SRFPlayChannel.cs b/Jellyfin.Plugin.SRFPlay/Channels/SRFPlayChannel.cs index 88b02ad..4276497 100644 --- a/Jellyfin.Plugin.SRFPlay/Channels/SRFPlayChannel.cs +++ b/Jellyfin.Plugin.SRFPlay/Channels/SRFPlayChannel.cs @@ -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(); @@ -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++; diff --git a/Jellyfin.Plugin.SRFPlay/Constants/ApiEndpoints.cs b/Jellyfin.Plugin.SRFPlay/Constants/ApiEndpoints.cs index 9d1e6a1..930442f 100644 --- a/Jellyfin.Plugin.SRFPlay/Constants/ApiEndpoints.cs +++ b/Jellyfin.Plugin.SRFPlay/Constants/ApiEndpoints.cs @@ -28,12 +28,6 @@ public static class ApiEndpoints /// public const string SrfPlayHomepage = "https://www.srf.ch/play"; - /// - /// Media composition endpoint template (relative to IntegrationLayerBaseUrl). - /// Format: {0} = URN. - /// - public const string MediaCompositionByUrnPath = "/mediaComposition/byUrn/{0}.json"; - /// /// Base proxy route for stream proxying (relative to server root). /// @@ -44,30 +38,4 @@ public static class ApiEndpoints /// Format: {0} = item ID. /// public const string ProxyMasterManifestPath = "/Plugins/SRFPlay/Proxy/{0}/master.m3u8"; - - /// - /// Image proxy route template (relative to server root). - /// Format: {0} = base64-encoded original URL. - /// - public const string ImageProxyPath = "/Plugins/SRFPlay/Image/{0}"; - - /// - /// Play V3 shows endpoint (relative to PlayV3 base URL). - /// - public const string PlayV3ShowsPath = "shows"; - - /// - /// Play V3 topics endpoint (relative to PlayV3 base URL). - /// - public const string PlayV3TopicsPath = "topics"; - - /// - /// Play V3 livestreams endpoint (relative to PlayV3 base URL). - /// - public const string PlayV3LivestreamsPath = "livestreams"; - - /// - /// Play V3 scheduled livestreams endpoint (relative to PlayV3 base URL). - /// - public const string PlayV3ScheduledLivestreamsPath = "scheduled-livestreams"; } diff --git a/Jellyfin.Plugin.SRFPlay/Controllers/StreamProxyController.cs b/Jellyfin.Plugin.SRFPlay/Controllers/StreamProxyController.cs index d4b7df4..4dba2cc 100644 --- a/Jellyfin.Plugin.SRFPlay/Controllers/StreamProxyController.cs +++ b/Jellyfin.Plugin.SRFPlay/Controllers/StreamProxyController.cs @@ -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; } - /// - /// Rewrites segment URLs in a manifest to point to proxy. - /// - /// The manifest content. - /// The base proxy URL. - /// The rewritten manifest. - 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(); - } - /// /// Proxies image requests from SRF CDN, fixing Content-Type headers. /// diff --git a/Jellyfin.Plugin.SRFPlay/Providers/SRFEpisodeProvider.cs b/Jellyfin.Plugin.SRFPlay/Providers/SRFEpisodeProvider.cs index 55680e2..a0409eb 100644 --- a/Jellyfin.Plugin.SRFPlay/Providers/SRFEpisodeProvider.cs +++ b/Jellyfin.Plugin.SRFPlay/Providers/SRFEpisodeProvider.cs @@ -50,7 +50,7 @@ public class SRFEpisodeProvider : IRemoteMetadataProvider 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 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; diff --git a/Jellyfin.Plugin.SRFPlay/Providers/SRFImageProvider.cs b/Jellyfin.Plugin.SRFPlay/Providers/SRFImageProvider.cs index 8bbb058..5fbb845 100644 --- a/Jellyfin.Plugin.SRFPlay/Providers/SRFImageProvider.cs +++ b/Jellyfin.Plugin.SRFPlay/Providers/SRFImageProvider.cs @@ -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)) diff --git a/Jellyfin.Plugin.SRFPlay/Providers/SRFMediaProvider.cs b/Jellyfin.Plugin.SRFPlay/Providers/SRFMediaProvider.cs index 9f9518f..ec47bcd 100644 --- a/Jellyfin.Plugin.SRFPlay/Providers/SRFMediaProvider.cs +++ b/Jellyfin.Plugin.SRFPlay/Providers/SRFMediaProvider.cs @@ -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( diff --git a/Jellyfin.Plugin.SRFPlay/ScheduledTasks/ExpirationCheckTask.cs b/Jellyfin.Plugin.SRFPlay/ScheduledTasks/ExpirationCheckTask.cs index 916c2f3..2996afb 100644 --- a/Jellyfin.Plugin.SRFPlay/ScheduledTasks/ExpirationCheckTask.cs +++ b/Jellyfin.Plugin.SRFPlay/ScheduledTasks/ExpirationCheckTask.cs @@ -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 diff --git a/Jellyfin.Plugin.SRFPlay/Services/ContentExpirationService.cs b/Jellyfin.Plugin.SRFPlay/Services/ContentExpirationService.cs index 75c1bfb..bb62daf 100644 --- a/Jellyfin.Plugin.SRFPlay/Services/ContentExpirationService.cs +++ b/Jellyfin.Plugin.SRFPlay/Services/ContentExpirationService.cs @@ -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; } - - /// - /// Gets statistics about content expiration. - /// - /// The cancellation token. - /// Tuple with total count, expired count, and items expiring soon. - 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 { { "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); - } } diff --git a/Jellyfin.Plugin.SRFPlay/Services/ContentRefreshService.cs b/Jellyfin.Plugin.SRFPlay/Services/ContentRefreshService.cs index 2361b54..60194af 100644 --- a/Jellyfin.Plugin.SRFPlay/Services/ContentRefreshService.cs +++ b/Jellyfin.Plugin.SRFPlay/Services/ContentRefreshService.cs @@ -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; } - - /// - /// Refreshes all content (latest and trending). - /// - /// The cancellation token. - /// Tuple with counts of latest and trending items. - 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); - } - - /// - /// Gets content recommendations (combines latest and trending). - /// - /// The cancellation token. - /// List of recommended URNs. - public async Task> GetRecommendedContentAsync(CancellationToken cancellationToken) - { - var recommendations = new HashSet(); - - 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(); - } } diff --git a/Jellyfin.Plugin.SRFPlay/Services/Interfaces/IContentExpirationService.cs b/Jellyfin.Plugin.SRFPlay/Services/Interfaces/IContentExpirationService.cs index 72b6592..12a5526 100644 --- a/Jellyfin.Plugin.SRFPlay/Services/Interfaces/IContentExpirationService.cs +++ b/Jellyfin.Plugin.SRFPlay/Services/Interfaces/IContentExpirationService.cs @@ -14,11 +14,4 @@ public interface IContentExpirationService /// The cancellation token. /// The number of items removed. Task CheckAndRemoveExpiredContentAsync(CancellationToken cancellationToken); - - /// - /// Gets statistics about content expiration. - /// - /// The cancellation token. - /// Tuple with total count, expired count, and items expiring soon. - Task<(int Total, int Expired, int ExpiringSoon)> GetExpirationStatisticsAsync(CancellationToken cancellationToken); } diff --git a/Jellyfin.Plugin.SRFPlay/Services/Interfaces/IContentRefreshService.cs b/Jellyfin.Plugin.SRFPlay/Services/Interfaces/IContentRefreshService.cs index 7fe65cb..b0fd5c6 100644 --- a/Jellyfin.Plugin.SRFPlay/Services/Interfaces/IContentRefreshService.cs +++ b/Jellyfin.Plugin.SRFPlay/Services/Interfaces/IContentRefreshService.cs @@ -22,18 +22,4 @@ public interface IContentRefreshService /// The cancellation token. /// List of URNs for trending content. Task> RefreshTrendingContentAsync(CancellationToken cancellationToken); - - /// - /// Refreshes all content (latest and trending). - /// - /// The cancellation token. - /// Tuple with counts of latest and trending items. - Task<(int LatestCount, int TrendingCount)> RefreshAllContentAsync(CancellationToken cancellationToken); - - /// - /// Gets content recommendations (combines latest and trending). - /// - /// The cancellation token. - /// List of recommended URNs. - Task> GetRecommendedContentAsync(CancellationToken cancellationToken); } diff --git a/Jellyfin.Plugin.SRFPlay/Services/Interfaces/IMetadataCache.cs b/Jellyfin.Plugin.SRFPlay/Services/Interfaces/IMetadataCache.cs index de13818..35b8110 100644 --- a/Jellyfin.Plugin.SRFPlay/Services/Interfaces/IMetadataCache.cs +++ b/Jellyfin.Plugin.SRFPlay/Services/Interfaces/IMetadataCache.cs @@ -22,20 +22,8 @@ public interface IMetadataCache /// The media composition to cache. void SetMediaComposition(string urn, MediaComposition mediaComposition); - /// - /// Removes media composition from cache. - /// - /// The URN. - void RemoveMediaComposition(string urn); - /// /// Clears all cached data. /// void Clear(); - - /// - /// Gets the cache statistics. - /// - /// A tuple with cache count and size estimate. - (int Count, long SizeEstimate) GetStatistics(); } diff --git a/Jellyfin.Plugin.SRFPlay/Services/Interfaces/IStreamProxyService.cs b/Jellyfin.Plugin.SRFPlay/Services/Interfaces/IStreamProxyService.cs index 9ad0702..d669a96 100644 --- a/Jellyfin.Plugin.SRFPlay/Services/Interfaces/IStreamProxyService.cs +++ b/Jellyfin.Plugin.SRFPlay/Services/Interfaces/IStreamProxyService.cs @@ -8,15 +8,6 @@ namespace Jellyfin.Plugin.SRFPlay.Services.Interfaces; /// public interface IStreamProxyService { - /// - /// Registers a stream for proxying with an already-authenticated URL. - /// - /// The item ID. - /// The authenticated stream URL. - /// The SRF URN for this content (used for re-fetching fresh URLs). - /// Whether this is a livestream (livestreams always fetch fresh URLs). - void RegisterStream(string itemId, string authenticatedUrl, string? urn = null, bool isLiveStream = false); - /// /// 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 /// The segment content as bytes. Task GetSegmentAsync(string itemId, string segmentPath, string? queryString = null, CancellationToken cancellationToken = default); + /// + /// Rewrites URLs in a variant (sub) manifest to point to the proxy. + /// + /// The variant manifest content. + /// The base proxy URL (without query params). + /// Query parameters to append to rewritten URLs (e.g., "?token=abc"). + /// The rewritten manifest content. + string RewriteVariantManifestUrls(string manifestContent, string baseProxyUrl, string queryParams); + /// /// Cleans up old and expired stream mappings. /// diff --git a/Jellyfin.Plugin.SRFPlay/Services/MetadataCache.cs b/Jellyfin.Plugin.SRFPlay/Services/MetadataCache.cs index cacb2fb..2bfd594 100644 --- a/Jellyfin.Plugin.SRFPlay/Services/MetadataCache.cs +++ b/Jellyfin.Plugin.SRFPlay/Services/MetadataCache.cs @@ -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; /// /// Service for caching metadata from SRF API. /// -public sealed class MetadataCache : IMetadataCache, IDisposable +public sealed class MetadataCache : IMetadataCache { private readonly ILogger _logger; private readonly ConcurrentDictionary> _mediaCompositionCache; - private readonly ReaderWriterLockSlim _lock; - private bool _disposed; /// /// Initializes a new instance of the class. @@ -25,19 +22,6 @@ public sealed class MetadataCache : IMetadataCache, IDisposable { _logger = logger; _mediaCompositionCache = new ConcurrentDictionary>(); - _lock = new ReaderWriterLockSlim(); - } - - /// - /// Disposes resources. - /// - public void Dispose() - { - if (!_disposed) - { - _lock?.Dispose(); - _disposed = true; - } } /// @@ -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); - _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 - } - } - - /// - /// Removes media composition from cache. - /// - /// The URN. - 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); + _mediaCompositionCache.AddOrUpdate(urn, entry, (key, oldValue) => entry); + _logger.LogDebug("Cached media composition for URN: {Urn}", urn); } /// @@ -151,50 +73,8 @@ public sealed class MetadataCache : IMetadataCache, IDisposable /// public void Clear() { - try - { - _lock.EnterWriteLock(); - try - { - _mediaCompositionCache.Clear(); - _logger.LogInformation("Cleared metadata cache"); - } - finally - { - _lock.ExitWriteLock(); - } - } - catch (ObjectDisposedException) - { - // Cache is disposed, ignore - } - } - - /// - /// Gets the cache statistics. - /// - /// A tuple with cache count and size estimate. - 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"); } /// diff --git a/Jellyfin.Plugin.SRFPlay/Services/StreamProxyService.cs b/Jellyfin.Plugin.SRFPlay/Services/StreamProxyService.cs index 1204a11..a2b7544 100644 --- a/Jellyfin.Plugin.SRFPlay/Services/StreamProxyService.cs +++ b/Jellyfin.Plugin.SRFPlay/Services/StreamProxyService.cs @@ -46,45 +46,6 @@ public class StreamProxyService : IStreamProxyService _streamMappings = new ConcurrentDictionary(); } - /// - /// Registers a stream for proxying. - /// - /// The item ID. - /// The authenticated stream URL. - /// The SRF URN for this content (used for re-fetching fresh URLs). - /// Whether this is a livestream (livestreams always fetch fresh URLs). - 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); - } - } - /// /// Registers a stream for deferred authentication (authenticates on first playback request). /// @@ -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 } } + /// + /// Rewrites URLs in a variant (sub) manifest to point to the proxy. + /// + /// The variant manifest content. + /// The base proxy URL (without query params). + /// Query parameters to append to rewritten URLs (e.g., "?token=abc"). + /// The rewritten manifest content. + 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(); + } + /// /// Cleans up old and expired stream mappings. /// diff --git a/Jellyfin.Plugin.SRFPlay/Services/StreamUrlResolver.cs b/Jellyfin.Plugin.SRFPlay/Services/StreamUrlResolver.cs index 470e040..817fc36 100644 --- a/Jellyfin.Plugin.SRFPlay/Services/StreamUrlResolver.cs +++ b/Jellyfin.Plugin.SRFPlay/Services/StreamUrlResolver.cs @@ -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; } /// diff --git a/Jellyfin.Plugin.SRFPlay/Utilities/Extensions.cs b/Jellyfin.Plugin.SRFPlay/Utilities/Extensions.cs index c400649..72f076b 100644 --- a/Jellyfin.Plugin.SRFPlay/Utilities/Extensions.cs +++ b/Jellyfin.Plugin.SRFPlay/Utilities/Extensions.cs @@ -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; /// public static class Extensions { - /// - /// Gets the SRF URN from the item's provider IDs. - /// - /// The base item. - /// The SRF URN, or null if not found or empty. - public static string? GetSrfUrn(this BaseItem item) - { - return item.ProviderIds.TryGetValue("SRF", out var urn) && !string.IsNullOrEmpty(urn) - ? urn - : null; - } - /// /// Converts the BusinessUnit enum to its lowercase string representation. /// @@ -29,13 +16,4 @@ public static class Extensions { return businessUnit.ToString().ToLowerInvariant(); } - - /// - /// Gets the plugin configuration safely, returning null if not available. - /// - /// The plugin configuration, or null if not available. - public static PluginConfiguration? GetPluginConfig() - { - return Plugin.Instance?.Configuration; - } } diff --git a/README.md b/README.md index d135726..bd4f5d5 100644 --- a/README.md +++ b/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