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