diff --git a/Jellyfin.Plugin.SRFPlay/Api/ISRFApiClientFactory.cs b/Jellyfin.Plugin.SRFPlay/Api/ISRFApiClientFactory.cs new file mode 100644 index 0000000..d127230 --- /dev/null +++ b/Jellyfin.Plugin.SRFPlay/Api/ISRFApiClientFactory.cs @@ -0,0 +1,13 @@ +namespace Jellyfin.Plugin.SRFPlay.Api; + +/// +/// Factory interface for creating SRF API clients. +/// +public interface ISRFApiClientFactory +{ + /// + /// Creates a new instance of the SRF API client. + /// + /// A new SRFApiClient instance. + SRFApiClient CreateClient(); +} diff --git a/Jellyfin.Plugin.SRFPlay/Api/SRFApiClientFactory.cs b/Jellyfin.Plugin.SRFPlay/Api/SRFApiClientFactory.cs new file mode 100644 index 0000000..4be4156 --- /dev/null +++ b/Jellyfin.Plugin.SRFPlay/Api/SRFApiClientFactory.cs @@ -0,0 +1,26 @@ +using Microsoft.Extensions.Logging; + +namespace Jellyfin.Plugin.SRFPlay.Api; + +/// +/// Factory for creating SRF API clients. +/// +public class SRFApiClientFactory : ISRFApiClientFactory +{ + private readonly ILoggerFactory _loggerFactory; + + /// + /// Initializes a new instance of the class. + /// + /// The logger factory. + public SRFApiClientFactory(ILoggerFactory loggerFactory) + { + _loggerFactory = loggerFactory; + } + + /// + public SRFApiClient CreateClient() + { + return new SRFApiClient(_loggerFactory); + } +} diff --git a/Jellyfin.Plugin.SRFPlay/Channels/SRFPlayChannel.cs b/Jellyfin.Plugin.SRFPlay/Channels/SRFPlayChannel.cs index 73e08d0..3c19588 100644 --- a/Jellyfin.Plugin.SRFPlay/Channels/SRFPlayChannel.cs +++ b/Jellyfin.Plugin.SRFPlay/Channels/SRFPlayChannel.cs @@ -6,7 +6,7 @@ using System.Security.Cryptography; using System.Text; using System.Threading; using System.Threading.Tasks; -using Jellyfin.Plugin.SRFPlay.Services; +using Jellyfin.Plugin.SRFPlay.Services.Interfaces; using MediaBrowser.Controller; using MediaBrowser.Controller.Channels; using MediaBrowser.Controller.Providers; @@ -24,10 +24,10 @@ public class SRFPlayChannel : IChannel, IHasCacheKey { private readonly ILogger _logger; private readonly ILoggerFactory _loggerFactory; - private readonly ContentRefreshService _contentRefreshService; - private readonly StreamUrlResolver _streamResolver; - private readonly StreamProxyService _proxyService; - private readonly CategoryService? _categoryService; + private readonly IContentRefreshService _contentRefreshService; + private readonly IStreamUrlResolver _streamResolver; + private readonly IStreamProxyService _proxyService; + private readonly ICategoryService? _categoryService; private readonly IServerApplicationHost _appHost; /// @@ -41,11 +41,11 @@ public class SRFPlayChannel : IChannel, IHasCacheKey /// The category service (optional). public SRFPlayChannel( ILoggerFactory loggerFactory, - ContentRefreshService contentRefreshService, - StreamUrlResolver streamResolver, - StreamProxyService proxyService, + IContentRefreshService contentRefreshService, + IStreamUrlResolver streamResolver, + IStreamProxyService proxyService, IServerApplicationHost appHost, - CategoryService? categoryService = null) + ICategoryService? categoryService = null) { _loggerFactory = loggerFactory; _logger = loggerFactory.CreateLogger(); diff --git a/Jellyfin.Plugin.SRFPlay/Controllers/StreamProxyController.cs b/Jellyfin.Plugin.SRFPlay/Controllers/StreamProxyController.cs index d0ddb2a..e52c986 100644 --- a/Jellyfin.Plugin.SRFPlay/Controllers/StreamProxyController.cs +++ b/Jellyfin.Plugin.SRFPlay/Controllers/StreamProxyController.cs @@ -2,7 +2,7 @@ using System; using System.Globalization; using System.Threading; using System.Threading.Tasks; -using Jellyfin.Plugin.SRFPlay.Services; +using Jellyfin.Plugin.SRFPlay.Services.Interfaces; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; @@ -18,14 +18,14 @@ namespace Jellyfin.Plugin.SRFPlay.Controllers; public class StreamProxyController : ControllerBase { private readonly ILogger _logger; - private readonly StreamProxyService _proxyService; + private readonly IStreamProxyService _proxyService; /// /// Initializes a new instance of the class. /// /// The logger. /// The proxy service. - public StreamProxyController(ILogger logger, StreamProxyService proxyService) + public StreamProxyController(ILogger logger, IStreamProxyService proxyService) { _logger = logger; _proxyService = proxyService; diff --git a/Jellyfin.Plugin.SRFPlay/Providers/SRFEpisodeProvider.cs b/Jellyfin.Plugin.SRFPlay/Providers/SRFEpisodeProvider.cs index 413a588..2a27611 100644 --- a/Jellyfin.Plugin.SRFPlay/Providers/SRFEpisodeProvider.cs +++ b/Jellyfin.Plugin.SRFPlay/Providers/SRFEpisodeProvider.cs @@ -4,8 +4,7 @@ using System.Linq; using System.Net.Http; using System.Threading; using System.Threading.Tasks; -using Jellyfin.Plugin.SRFPlay.Api; -using Jellyfin.Plugin.SRFPlay.Services; +using Jellyfin.Plugin.SRFPlay.Services.Interfaces; using MediaBrowser.Controller.Entities.TV; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Entities; @@ -20,25 +19,23 @@ namespace Jellyfin.Plugin.SRFPlay.Providers; public class SRFEpisodeProvider : IRemoteMetadataProvider { private readonly ILogger _logger; - private readonly ILoggerFactory _loggerFactory; private readonly IHttpClientFactory _httpClientFactory; - private readonly MetadataCache _metadataCache; + private readonly IMediaCompositionFetcher _compositionFetcher; /// /// Initializes a new instance of the class. /// /// The logger factory. /// The HTTP client factory. - /// The metadata cache. + /// The media composition fetcher. public SRFEpisodeProvider( ILoggerFactory loggerFactory, IHttpClientFactory httpClientFactory, - MetadataCache metadataCache) + IMediaCompositionFetcher compositionFetcher) { - _loggerFactory = loggerFactory; _logger = loggerFactory.CreateLogger(); _httpClientFactory = httpClientFactory; - _metadataCache = metadataCache; + _compositionFetcher = compositionFetcher; } /// @@ -56,26 +53,7 @@ public class SRFEpisodeProvider : IRemoteMetadataProvider { _logger.LogDebug("Searching for episode with URN: {Urn}", urn); - var config = Plugin.Instance?.Configuration; - if (config == null) - { - return results; - } - - // Try cache first - var mediaComposition = _metadataCache.GetMediaComposition(urn, config.CacheDurationMinutes); - - // If not in cache, fetch from API - if (mediaComposition == null) - { - using var apiClient = new SRFApiClient(_loggerFactory); - mediaComposition = await apiClient.GetMediaCompositionByUrnAsync(urn, cancellationToken).ConfigureAwait(false); - - if (mediaComposition != null) - { - _metadataCache.SetMediaComposition(urn, mediaComposition); - } - } + var mediaComposition = await _compositionFetcher.GetMediaCompositionAsync(urn, cancellationToken).ConfigureAwait(false); if (mediaComposition?.ChapterList != null && mediaComposition.ChapterList.Count > 0) { @@ -118,26 +96,7 @@ public class SRFEpisodeProvider : IRemoteMetadataProvider _logger.LogDebug("Fetching metadata for episode URN: {Urn}", urn); - var config = Plugin.Instance?.Configuration; - if (config == null) - { - return result; - } - - // Try cache first - var mediaComposition = _metadataCache.GetMediaComposition(urn, config.CacheDurationMinutes); - - // If not in cache, fetch from API - if (mediaComposition == null) - { - using var apiClient = new SRFApiClient(_loggerFactory); - mediaComposition = await apiClient.GetMediaCompositionByUrnAsync(urn, cancellationToken).ConfigureAwait(false); - - if (mediaComposition != null) - { - _metadataCache.SetMediaComposition(urn, mediaComposition); - } - } + var mediaComposition = await _compositionFetcher.GetMediaCompositionAsync(urn, cancellationToken).ConfigureAwait(false); if (mediaComposition?.ChapterList == null || mediaComposition.ChapterList.Count == 0) { diff --git a/Jellyfin.Plugin.SRFPlay/Providers/SRFImageProvider.cs b/Jellyfin.Plugin.SRFPlay/Providers/SRFImageProvider.cs index e6b32e5..59f7b04 100644 --- a/Jellyfin.Plugin.SRFPlay/Providers/SRFImageProvider.cs +++ b/Jellyfin.Plugin.SRFPlay/Providers/SRFImageProvider.cs @@ -1,10 +1,10 @@ using System; using System.Collections.Generic; -using System.Linq; using System.Net.Http; +using System.Net.Http.Headers; using System.Threading; using System.Threading.Tasks; -using Jellyfin.Plugin.SRFPlay.Api; +using Jellyfin.Plugin.SRFPlay.Services.Interfaces; using MediaBrowser.Common.Net; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Providers; @@ -21,18 +21,22 @@ public class SRFImageProvider : IRemoteImageProvider, IHasOrder { private readonly IHttpClientFactory _httpClientFactory; private readonly ILogger _logger; - private readonly ILoggerFactory _loggerFactory; + private readonly IMediaCompositionFetcher _compositionFetcher; /// /// Initializes a new instance of the class. /// /// The HTTP client factory. /// The logger factory. - public SRFImageProvider(IHttpClientFactory httpClientFactory, ILoggerFactory loggerFactory) + /// The media composition fetcher. + public SRFImageProvider( + IHttpClientFactory httpClientFactory, + ILoggerFactory loggerFactory, + IMediaCompositionFetcher compositionFetcher) { _httpClientFactory = httpClientFactory; - _loggerFactory = loggerFactory; _logger = loggerFactory.CreateLogger(); + _compositionFetcher = compositionFetcher; } /// @@ -78,8 +82,7 @@ public class SRFImageProvider : IRemoteImageProvider, IHasOrder _logger.LogDebug("Fetching images for SRF URN: {Urn}", urn); // Fetch media composition to get image URLs - using var apiClient = new SRFApiClient(_loggerFactory); - var mediaComposition = await apiClient.GetMediaCompositionByUrnAsync(urn, cancellationToken).ConfigureAwait(false); + var mediaComposition = await _compositionFetcher.GetMediaCompositionAsync(urn, cancellationToken).ConfigureAwait(false); if (mediaComposition == null) { @@ -151,9 +154,69 @@ public class SRFImageProvider : IRemoteImageProvider, IHasOrder } /// - public Task GetImageResponse(string url, CancellationToken cancellationToken) + public async Task GetImageResponse(string url, CancellationToken cancellationToken) { var httpClient = _httpClientFactory.CreateClient(NamedClient.Default); - return httpClient.GetAsync(new Uri(url), cancellationToken); + + // Create request with proper headers - SRF CDN requires User-Agent + var request = new HttpRequestMessage(HttpMethod.Get, new Uri(url)); + request.Headers.UserAgent.ParseAdd("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"); + request.Headers.Accept.ParseAdd("image/*"); + + var response = await httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false); + + // Fix Content-Type if it's binary/octet-stream - SRF CDN returns wrong content type + // Jellyfin needs correct Content-Type to process images + if (response.IsSuccessStatusCode && + response.Content.Headers.ContentType?.MediaType == "binary/octet-stream") + { + // Determine correct content type from URL extension + var contentType = GetContentTypeFromUrl(url); + if (!string.IsNullOrEmpty(contentType)) + { + _logger.LogDebug("Fixing Content-Type from binary/octet-stream to {ContentType} for {Url}", contentType, url); + response.Content.Headers.ContentType = new MediaTypeHeaderValue(contentType); + } + } + + return response; + } + + /// + /// Determines the correct content type based on URL file extension. + /// + private static string? GetContentTypeFromUrl(string url) + { + if (string.IsNullOrEmpty(url)) + { + return null; + } + + // Get the file extension from the URL (ignore query string) + var uri = new Uri(url); + var path = uri.AbsolutePath.ToLowerInvariant(); + + if (path.EndsWith(".jpg", StringComparison.Ordinal) || path.EndsWith(".jpeg", StringComparison.Ordinal)) + { + return "image/jpeg"; + } + + if (path.EndsWith(".png", StringComparison.Ordinal)) + { + return "image/png"; + } + + if (path.EndsWith(".gif", StringComparison.Ordinal)) + { + return "image/gif"; + } + + if (path.EndsWith(".webp", StringComparison.Ordinal)) + { + return "image/webp"; + } + + // Default to JPEG for SRF images (most common) + return "image/jpeg"; } } diff --git a/Jellyfin.Plugin.SRFPlay/Providers/SRFLiveStream.cs b/Jellyfin.Plugin.SRFPlay/Providers/SRFLiveStream.cs index 01846db..f7928ec 100644 --- a/Jellyfin.Plugin.SRFPlay/Providers/SRFLiveStream.cs +++ b/Jellyfin.Plugin.SRFPlay/Providers/SRFLiveStream.cs @@ -2,7 +2,7 @@ using System; using System.IO; using System.Threading; using System.Threading.Tasks; -using Jellyfin.Plugin.SRFPlay.Services; +using Jellyfin.Plugin.SRFPlay.Services.Interfaces; using MediaBrowser.Controller.Library; using MediaBrowser.Model.Dto; using Microsoft.Extensions.Logging; @@ -15,7 +15,7 @@ namespace Jellyfin.Plugin.SRFPlay.Providers; internal sealed class SRFLiveStream : ILiveStream { private readonly ILogger _logger; - private readonly StreamProxyService _proxyService; + private readonly IStreamProxyService _proxyService; private readonly string _originalItemId; private MediaSourceInfo? _mediaSource; @@ -26,13 +26,11 @@ internal sealed class SRFLiveStream : ILiveStream /// The stream proxy service. /// The original item ID. /// The open token. - /// The logger factory. public SRFLiveStream( ILogger logger, - StreamProxyService proxyService, + IStreamProxyService proxyService, string originalItemId, - string openToken, - ILoggerFactory loggerFactory) + string openToken) { _logger = logger; _proxyService = proxyService; diff --git a/Jellyfin.Plugin.SRFPlay/Providers/SRFMediaProvider.cs b/Jellyfin.Plugin.SRFPlay/Providers/SRFMediaProvider.cs index 8c6d552..1cfd52a 100644 --- a/Jellyfin.Plugin.SRFPlay/Providers/SRFMediaProvider.cs +++ b/Jellyfin.Plugin.SRFPlay/Providers/SRFMediaProvider.cs @@ -4,8 +4,8 @@ using System.IO; using System.Linq; using System.Threading; using System.Threading.Tasks; -using Jellyfin.Plugin.SRFPlay.Api; -using Jellyfin.Plugin.SRFPlay.Services; +using Jellyfin.Plugin.SRFPlay.Configuration; +using Jellyfin.Plugin.SRFPlay.Services.Interfaces; using MediaBrowser.Controller; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; @@ -23,9 +23,9 @@ public class SRFMediaProvider : IMediaSourceProvider { private readonly ILogger _logger; private readonly ILoggerFactory _loggerFactory; - private readonly MetadataCache _metadataCache; - private readonly StreamUrlResolver _streamResolver; - private readonly StreamProxyService _proxyService; + private readonly IMediaCompositionFetcher _compositionFetcher; + private readonly IStreamUrlResolver _streamResolver; + private readonly IStreamProxyService _proxyService; private readonly IServerApplicationHost _appHost; private readonly Dictionary _openTokenToItemId = new(); @@ -33,20 +33,20 @@ public class SRFMediaProvider : IMediaSourceProvider /// Initializes a new instance of the class. /// /// The logger factory. - /// The metadata cache. + /// The media composition fetcher. /// The stream URL resolver. /// The stream proxy service. /// The server application host. public SRFMediaProvider( ILoggerFactory loggerFactory, - MetadataCache metadataCache, - StreamUrlResolver streamResolver, - StreamProxyService proxyService, + IMediaCompositionFetcher compositionFetcher, + IStreamUrlResolver streamResolver, + IStreamProxyService proxyService, IServerApplicationHost appHost) { _loggerFactory = loggerFactory; _logger = loggerFactory.CreateLogger(); - _metadataCache = metadataCache; + _compositionFetcher = compositionFetcher; _streamResolver = streamResolver; _proxyService = proxyService; _appHost = appHost; @@ -88,32 +88,10 @@ public class SRFMediaProvider : IMediaSourceProvider _logger.LogInformation("Getting media sources for URN: {Urn}, Item: {ItemName}", urn, item.Name); - var config = Plugin.Instance?.Configuration; - if (config == null) - { - return sources; - } - // For scheduled livestreams, use shorter cache TTL (5 minutes) so they refresh when they go live - // For regular content, use configured cache duration - var cacheDuration = urn.Contains("scheduled_livestream", StringComparison.OrdinalIgnoreCase) - ? 5 - : config.CacheDurationMinutes; + var cacheDuration = urn.Contains("scheduled_livestream", StringComparison.OrdinalIgnoreCase) ? 5 : (int?)null; - // Try cache first - var mediaComposition = _metadataCache.GetMediaComposition(urn, cacheDuration); - - // If not in cache, fetch from API - if (mediaComposition == null) - { - using var apiClient = new SRFApiClient(_loggerFactory); - mediaComposition = await apiClient.GetMediaCompositionByUrnAsync(urn, cancellationToken).ConfigureAwait(false); - - if (mediaComposition != null) - { - _metadataCache.SetMediaComposition(urn, mediaComposition); - } - } + var mediaComposition = await _compositionFetcher.GetMediaCompositionAsync(urn, cancellationToken, cacheDuration).ConfigureAwait(false); if (mediaComposition?.ChapterList == null || mediaComposition.ChapterList.Count == 0) { @@ -139,25 +117,25 @@ public class SRFMediaProvider : IMediaSourceProvider } // Get stream URL based on quality preference - var streamUrl = _streamResolver.GetStreamUrl(chapter, config.QualityPreference); + var config = Plugin.Instance?.Configuration; + var qualityPref = config?.QualityPreference ?? QualityPreference.HD; + var streamUrl = _streamResolver.GetStreamUrl(chapter, qualityPref); // For scheduled livestreams, always fetch fresh data to ensure stream URL is current if (chapter.Type == "SCHEDULED_LIVESTREAM" && string.IsNullOrEmpty(streamUrl)) { _logger.LogDebug("URN {Urn}: Scheduled livestream has no stream URL, fetching fresh data", urn); - using var freshApiClient = new SRFApiClient(_loggerFactory); - var freshMediaComposition = await freshApiClient.GetMediaCompositionByUrnAsync(urn, cancellationToken).ConfigureAwait(false); + // 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) { var freshChapter = freshMediaComposition.ChapterList[0]; - streamUrl = _streamResolver.GetStreamUrl(freshChapter, config.QualityPreference); + streamUrl = _streamResolver.GetStreamUrl(freshChapter, qualityPref); if (!string.IsNullOrEmpty(streamUrl)) { - // Update cache with fresh data - _metadataCache.SetMediaComposition(urn, freshMediaComposition); chapter = freshChapter; _logger.LogInformation("URN {Urn}: Got fresh stream URL for scheduled livestream", urn); } @@ -190,7 +168,7 @@ public class SRFMediaProvider : IMediaSourceProvider _proxyService.RegisterStream(itemIdStr, streamUrl, urn, isLiveStream); // Get the server URL for proxy - prefer configured public URL for remote clients - var serverUrl = !string.IsNullOrWhiteSpace(config.PublicServerUrl) + var serverUrl = config != null && !string.IsNullOrWhiteSpace(config.PublicServerUrl) ? config.PublicServerUrl.TrimEnd('/') // Use configured public URL (important for Android/remote clients) : _appHost.GetSmartApiUrl(string.Empty); // Fall back to Jellyfin's smart URL resolution @@ -207,7 +185,7 @@ public class SRFMediaProvider : IMediaSourceProvider "Using proxy URL for item {ItemId}: {ProxyUrl} (PublicServerUrl configured: {IsPublicConfigured})", itemIdStr, proxyUrl, - !string.IsNullOrWhiteSpace(config.PublicServerUrl)); + config != null && !string.IsNullOrWhiteSpace(config.PublicServerUrl)); // Create media source using proxy URL - enables DirectPlay! var mediaSource = new MediaSourceInfo @@ -311,8 +289,7 @@ public class SRFMediaProvider : IMediaSourceProvider _logger, _proxyService, originalItemId, - openToken, - _loggerFactory); + openToken); return await Task.FromResult(liveStream).ConfigureAwait(false); } diff --git a/Jellyfin.Plugin.SRFPlay/Providers/SRFSeriesProvider.cs b/Jellyfin.Plugin.SRFPlay/Providers/SRFSeriesProvider.cs index 8b65f95..e2fe0a4 100644 --- a/Jellyfin.Plugin.SRFPlay/Providers/SRFSeriesProvider.cs +++ b/Jellyfin.Plugin.SRFPlay/Providers/SRFSeriesProvider.cs @@ -4,8 +4,7 @@ using System.Linq; using System.Net.Http; using System.Threading; using System.Threading.Tasks; -using Jellyfin.Plugin.SRFPlay.Api; -using Jellyfin.Plugin.SRFPlay.Services; +using Jellyfin.Plugin.SRFPlay.Services.Interfaces; using MediaBrowser.Controller.Entities.TV; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Entities; @@ -20,27 +19,23 @@ namespace Jellyfin.Plugin.SRFPlay.Providers; public class SRFSeriesProvider : IRemoteMetadataProvider { private readonly ILogger _logger; - private readonly ILoggerFactory _loggerFactory; private readonly IHttpClientFactory _httpClientFactory; - private readonly MetadataCache _metadataCache; + private readonly IMediaCompositionFetcher _compositionFetcher; /// /// Initializes a new instance of the class. /// - /// The logger instance. /// The logger factory. /// The HTTP client factory. - /// The metadata cache. + /// The media composition fetcher. public SRFSeriesProvider( - ILogger logger, ILoggerFactory loggerFactory, IHttpClientFactory httpClientFactory, - MetadataCache metadataCache) + IMediaCompositionFetcher compositionFetcher) { - _logger = logger; - _loggerFactory = loggerFactory; + _logger = loggerFactory.CreateLogger(); _httpClientFactory = httpClientFactory; - _metadataCache = metadataCache; + _compositionFetcher = compositionFetcher; } /// @@ -58,26 +53,7 @@ public class SRFSeriesProvider : IRemoteMetadataProvider { _logger.LogDebug("Searching for series with URN: {Urn}", urn); - var config = Plugin.Instance?.Configuration; - if (config == null) - { - return results; - } - - // Try cache first - var mediaComposition = _metadataCache.GetMediaComposition(urn, config.CacheDurationMinutes); - - // If not in cache, fetch from API - if (mediaComposition == null) - { - using var apiClient = new SRFApiClient(_loggerFactory); - mediaComposition = await apiClient.GetMediaCompositionByUrnAsync(urn, cancellationToken).ConfigureAwait(false); - - if (mediaComposition != null) - { - _metadataCache.SetMediaComposition(urn, mediaComposition); - } - } + var mediaComposition = await _compositionFetcher.GetMediaCompositionAsync(urn, cancellationToken).ConfigureAwait(false); if (mediaComposition?.Show != null) { @@ -125,26 +101,7 @@ public class SRFSeriesProvider : IRemoteMetadataProvider _logger.LogDebug("Fetching metadata for series URN: {Urn}", urn); - var config = Plugin.Instance?.Configuration; - if (config == null) - { - return result; - } - - // Try cache first - var mediaComposition = _metadataCache.GetMediaComposition(urn, config.CacheDurationMinutes); - - // If not in cache, fetch from API - if (mediaComposition == null) - { - using var apiClient = new SRFApiClient(_loggerFactory); - mediaComposition = await apiClient.GetMediaCompositionByUrnAsync(urn, cancellationToken).ConfigureAwait(false); - - if (mediaComposition != null) - { - _metadataCache.SetMediaComposition(urn, mediaComposition); - } - } + var mediaComposition = await _compositionFetcher.GetMediaCompositionAsync(urn, cancellationToken).ConfigureAwait(false); if (mediaComposition?.Show == null) { diff --git a/Jellyfin.Plugin.SRFPlay/ScheduledTasks/ContentRefreshTask.cs b/Jellyfin.Plugin.SRFPlay/ScheduledTasks/ContentRefreshTask.cs index ba00f0f..84678ca 100644 --- a/Jellyfin.Plugin.SRFPlay/ScheduledTasks/ContentRefreshTask.cs +++ b/Jellyfin.Plugin.SRFPlay/ScheduledTasks/ContentRefreshTask.cs @@ -2,7 +2,7 @@ using System; using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; -using Jellyfin.Plugin.SRFPlay.Services; +using Jellyfin.Plugin.SRFPlay.Services.Interfaces; using MediaBrowser.Model.Tasks; using Microsoft.Extensions.Logging; @@ -14,7 +14,7 @@ namespace Jellyfin.Plugin.SRFPlay.ScheduledTasks; public class ContentRefreshTask : IScheduledTask { private readonly ILogger _logger; - private readonly ContentRefreshService _contentRefreshService; + private readonly IContentRefreshService _contentRefreshService; /// /// Initializes a new instance of the class. @@ -23,7 +23,7 @@ public class ContentRefreshTask : IScheduledTask /// The content refresh service. public ContentRefreshTask( ILogger logger, - ContentRefreshService contentRefreshService) + IContentRefreshService contentRefreshService) { _logger = logger; _contentRefreshService = contentRefreshService; diff --git a/Jellyfin.Plugin.SRFPlay/ScheduledTasks/ExpirationCheckTask.cs b/Jellyfin.Plugin.SRFPlay/ScheduledTasks/ExpirationCheckTask.cs index badc7f6..9cdb592 100644 --- a/Jellyfin.Plugin.SRFPlay/ScheduledTasks/ExpirationCheckTask.cs +++ b/Jellyfin.Plugin.SRFPlay/ScheduledTasks/ExpirationCheckTask.cs @@ -2,7 +2,7 @@ using System; using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; -using Jellyfin.Plugin.SRFPlay.Services; +using Jellyfin.Plugin.SRFPlay.Services.Interfaces; using MediaBrowser.Model.Tasks; using Microsoft.Extensions.Logging; @@ -14,7 +14,7 @@ namespace Jellyfin.Plugin.SRFPlay.ScheduledTasks; public class ExpirationCheckTask : IScheduledTask { private readonly ILogger _logger; - private readonly ContentExpirationService _expirationService; + private readonly IContentExpirationService _expirationService; /// /// Initializes a new instance of the class. @@ -23,7 +23,7 @@ public class ExpirationCheckTask : IScheduledTask /// The content expiration service. public ExpirationCheckTask( ILogger logger, - ContentExpirationService expirationService) + IContentExpirationService expirationService) { _logger = logger; _expirationService = expirationService; diff --git a/Jellyfin.Plugin.SRFPlay/ServiceRegistrator.cs b/Jellyfin.Plugin.SRFPlay/ServiceRegistrator.cs index cf08f17..771d47e 100644 --- a/Jellyfin.Plugin.SRFPlay/ServiceRegistrator.cs +++ b/Jellyfin.Plugin.SRFPlay/ServiceRegistrator.cs @@ -1,13 +1,14 @@ +using Jellyfin.Plugin.SRFPlay.Api; using Jellyfin.Plugin.SRFPlay.Channels; using Jellyfin.Plugin.SRFPlay.Providers; using Jellyfin.Plugin.SRFPlay.ScheduledTasks; using Jellyfin.Plugin.SRFPlay.Services; +using Jellyfin.Plugin.SRFPlay.Services.Interfaces; using MediaBrowser.Controller; using MediaBrowser.Controller.Channels; using MediaBrowser.Controller.Plugins; using MediaBrowser.Model.Tasks; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; namespace Jellyfin.Plugin.SRFPlay; @@ -19,13 +20,17 @@ public class ServiceRegistrator : IPluginServiceRegistrator /// public void RegisterServices(IServiceCollection serviceCollection, IServerApplicationHost applicationHost) { - // Register services as singletons - serviceCollection.AddSingleton(); - serviceCollection.AddSingleton(); - serviceCollection.AddSingleton(); - serviceCollection.AddSingleton(); - serviceCollection.AddSingleton(); - serviceCollection.AddSingleton(); // Stream proxy service + // Register API client factory + serviceCollection.AddSingleton(); + + // Register core services with interfaces + serviceCollection.AddSingleton(); + serviceCollection.AddSingleton(); + serviceCollection.AddSingleton(); + serviceCollection.AddSingleton(); + serviceCollection.AddSingleton(); + serviceCollection.AddSingleton(); + serviceCollection.AddSingleton(); // Register metadata providers serviceCollection.AddSingleton(); diff --git a/Jellyfin.Plugin.SRFPlay/Services/CategoryService.cs b/Jellyfin.Plugin.SRFPlay/Services/CategoryService.cs index 8cd64a3..85575bd 100644 --- a/Jellyfin.Plugin.SRFPlay/Services/CategoryService.cs +++ b/Jellyfin.Plugin.SRFPlay/Services/CategoryService.cs @@ -6,6 +6,7 @@ using System.Threading; using System.Threading.Tasks; using Jellyfin.Plugin.SRFPlay.Api; using Jellyfin.Plugin.SRFPlay.Api.Models; +using Jellyfin.Plugin.SRFPlay.Services.Interfaces; using Microsoft.Extensions.Logging; namespace Jellyfin.Plugin.SRFPlay.Services; @@ -13,7 +14,7 @@ namespace Jellyfin.Plugin.SRFPlay.Services; /// /// Service for managing topic/category data and filtering. /// -public class CategoryService +public class CategoryService : ICategoryService { private readonly ILogger _logger; private readonly ILoggerFactory _loggerFactory; diff --git a/Jellyfin.Plugin.SRFPlay/Services/ContentExpirationService.cs b/Jellyfin.Plugin.SRFPlay/Services/ContentExpirationService.cs index 45cd9c1..75c1bfb 100644 --- a/Jellyfin.Plugin.SRFPlay/Services/ContentExpirationService.cs +++ b/Jellyfin.Plugin.SRFPlay/Services/ContentExpirationService.cs @@ -3,10 +3,9 @@ using System.Collections.Generic; using System.Linq; using System.Threading; using System.Threading.Tasks; -using Jellyfin.Plugin.SRFPlay.Api; +using Jellyfin.Plugin.SRFPlay.Services.Interfaces; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; -using MediaBrowser.Model.IO; using Microsoft.Extensions.Logging; namespace Jellyfin.Plugin.SRFPlay.Services; @@ -14,13 +13,12 @@ namespace Jellyfin.Plugin.SRFPlay.Services; /// /// Service for managing content expiration. /// -public class ContentExpirationService +public class ContentExpirationService : IContentExpirationService { private readonly ILogger _logger; - private readonly ILoggerFactory _loggerFactory; private readonly ILibraryManager _libraryManager; - private readonly StreamUrlResolver _streamResolver; - private readonly MetadataCache _metadataCache; + private readonly IStreamUrlResolver _streamResolver; + private readonly IMediaCompositionFetcher _compositionFetcher; /// /// Initializes a new instance of the class. @@ -28,18 +26,17 @@ public class ContentExpirationService /// The logger factory. /// The library manager. /// The stream URL resolver. - /// The metadata cache. + /// The media composition fetcher. public ContentExpirationService( ILoggerFactory loggerFactory, ILibraryManager libraryManager, - StreamUrlResolver streamResolver, - MetadataCache metadataCache) + IStreamUrlResolver streamResolver, + IMediaCompositionFetcher compositionFetcher) { - _loggerFactory = loggerFactory; _logger = loggerFactory.CreateLogger(); _libraryManager = libraryManager; _streamResolver = streamResolver; - _metadataCache = metadataCache; + _compositionFetcher = compositionFetcher; } /// @@ -119,26 +116,7 @@ public class ContentExpirationService return false; } - var config = Plugin.Instance?.Configuration; - if (config == null) - { - return false; - } - - // Try cache first - var mediaComposition = _metadataCache.GetMediaComposition(urn, config.CacheDurationMinutes); - - // If not in cache, fetch from API - if (mediaComposition == null) - { - using var apiClient = new SRFApiClient(_loggerFactory); - mediaComposition = await apiClient.GetMediaCompositionByUrnAsync(urn, cancellationToken).ConfigureAwait(false); - - if (mediaComposition != null) - { - _metadataCache.SetMediaComposition(urn, mediaComposition); - } - } + var mediaComposition = await _compositionFetcher.GetMediaCompositionAsync(urn, cancellationToken).ConfigureAwait(false); if (mediaComposition?.ChapterList == null || mediaComposition.ChapterList.Count == 0) { @@ -196,24 +174,7 @@ public class ContentExpirationService continue; } - var config = Plugin.Instance?.Configuration; - if (config == null) - { - continue; - } - - var mediaComposition = _metadataCache.GetMediaComposition(urn, config.CacheDurationMinutes); - - if (mediaComposition == null) - { - using var apiClient = new SRFApiClient(_loggerFactory); - mediaComposition = await apiClient.GetMediaCompositionByUrnAsync(urn, cancellationToken).ConfigureAwait(false); - - if (mediaComposition != null) - { - _metadataCache.SetMediaComposition(urn, mediaComposition); - } - } + var mediaComposition = await _compositionFetcher.GetMediaCompositionAsync(urn, cancellationToken).ConfigureAwait(false); if (mediaComposition?.ChapterList != null && mediaComposition.ChapterList.Count > 0) { diff --git a/Jellyfin.Plugin.SRFPlay/Services/ContentRefreshService.cs b/Jellyfin.Plugin.SRFPlay/Services/ContentRefreshService.cs index 6bbcec5..a76041d 100644 --- a/Jellyfin.Plugin.SRFPlay/Services/ContentRefreshService.cs +++ b/Jellyfin.Plugin.SRFPlay/Services/ContentRefreshService.cs @@ -4,7 +4,7 @@ using System.Linq; using System.Threading; using System.Threading.Tasks; using Jellyfin.Plugin.SRFPlay.Api; -using Jellyfin.Plugin.SRFPlay.Api.Models; +using Jellyfin.Plugin.SRFPlay.Services.Interfaces; using Microsoft.Extensions.Logging; namespace Jellyfin.Plugin.SRFPlay.Services; @@ -12,24 +12,22 @@ namespace Jellyfin.Plugin.SRFPlay.Services; /// /// Service for refreshing content from SRF API. /// -public class ContentRefreshService +public class ContentRefreshService : IContentRefreshService { private readonly ILogger _logger; - private readonly ILoggerFactory _loggerFactory; - private readonly MetadataCache _metadataCache; + private readonly ISRFApiClientFactory _apiClientFactory; /// /// Initializes a new instance of the class. /// /// The logger factory. - /// The metadata cache. + /// The API client factory. public ContentRefreshService( ILoggerFactory loggerFactory, - MetadataCache metadataCache) + ISRFApiClientFactory apiClientFactory) { - _loggerFactory = loggerFactory; _logger = loggerFactory.CreateLogger(); - _metadataCache = metadataCache; + _apiClientFactory = apiClientFactory; } /// @@ -52,7 +50,7 @@ public class ContentRefreshService _logger.LogInformation("Refreshing latest content for business unit: {BusinessUnit}", config.BusinessUnit); - using var apiClient = new SRFApiClient(_loggerFactory); + using var apiClient = _apiClientFactory.CreateClient(); var businessUnit = config.BusinessUnit.ToString().ToLowerInvariant(); // Get all shows from Play v3 API @@ -167,7 +165,7 @@ public class ContentRefreshService _logger.LogInformation("Refreshing trending content for business unit: {BusinessUnit}", config.BusinessUnit); - using var apiClient = new SRFApiClient(_loggerFactory); + using var apiClient = _apiClientFactory.CreateClient(); var businessUnit = config.BusinessUnit.ToString().ToLowerInvariant(); // Get all shows from Play v3 API diff --git a/Jellyfin.Plugin.SRFPlay/Services/Interfaces/ICategoryService.cs b/Jellyfin.Plugin.SRFPlay/Services/Interfaces/ICategoryService.cs new file mode 100644 index 0000000..c8780a5 --- /dev/null +++ b/Jellyfin.Plugin.SRFPlay/Services/Interfaces/ICategoryService.cs @@ -0,0 +1,70 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Plugin.SRFPlay.Api.Models; + +namespace Jellyfin.Plugin.SRFPlay.Services.Interfaces; + +/// +/// Interface for managing topic/category data and filtering. +/// +public interface ICategoryService +{ + /// + /// Gets all topics for a business unit. + /// + /// The business unit. + /// The cancellation token. + /// List of topics. + Task> GetTopicsAsync(string businessUnit, CancellationToken cancellationToken = default); + + /// + /// Gets a topic by ID. + /// + /// The topic ID. + /// The business unit. + /// The cancellation token. + /// The topic, or null if not found. + Task GetTopicByIdAsync(string topicId, string businessUnit, CancellationToken cancellationToken = default); + + /// + /// Filters shows by topic ID. + /// + /// The shows to filter. + /// The topic ID to filter by. + /// Filtered list of shows. + IReadOnlyList FilterShowsByTopic(IReadOnlyList shows, string topicId); + + /// + /// Groups shows by their topics. + /// + /// The shows to group. + /// Dictionary mapping topic IDs to shows. + IReadOnlyDictionary> GroupShowsByTopics(IReadOnlyList shows); + + /// + /// Gets shows for a specific topic, sorted by number of episodes. + /// + /// The topic ID. + /// The business unit. + /// Maximum number of results to return. + /// The cancellation token. + /// List of shows for the topic. + Task> GetShowsByTopicAsync( + string topicId, + string businessUnit, + int maxResults = 50, + CancellationToken cancellationToken = default); + + /// + /// Gets video count for each topic. + /// + /// The shows to analyze. + /// Dictionary mapping topic IDs to video counts. + IReadOnlyDictionary GetVideoCountByTopic(IReadOnlyList shows); + + /// + /// Clears the topics cache. + /// + void ClearCache(); +} diff --git a/Jellyfin.Plugin.SRFPlay/Services/Interfaces/IContentExpirationService.cs b/Jellyfin.Plugin.SRFPlay/Services/Interfaces/IContentExpirationService.cs new file mode 100644 index 0000000..72b6592 --- /dev/null +++ b/Jellyfin.Plugin.SRFPlay/Services/Interfaces/IContentExpirationService.cs @@ -0,0 +1,24 @@ +using System.Threading; +using System.Threading.Tasks; + +namespace Jellyfin.Plugin.SRFPlay.Services.Interfaces; + +/// +/// Interface for managing content expiration. +/// +public interface IContentExpirationService +{ + /// + /// Checks for expired content and removes it from the library. + /// + /// 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 new file mode 100644 index 0000000..7fe65cb --- /dev/null +++ b/Jellyfin.Plugin.SRFPlay/Services/Interfaces/IContentRefreshService.cs @@ -0,0 +1,39 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace Jellyfin.Plugin.SRFPlay.Services.Interfaces; + +/// +/// Interface for refreshing content from SRF API. +/// +public interface IContentRefreshService +{ + /// + /// Refreshes latest content from SRF API using Play v3. + /// + /// The cancellation token. + /// List of URNs for new content. + Task> RefreshLatestContentAsync(CancellationToken cancellationToken); + + /// + /// Refreshes trending content from SRF API using Play v3. + /// + /// 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/IMediaCompositionFetcher.cs b/Jellyfin.Plugin.SRFPlay/Services/Interfaces/IMediaCompositionFetcher.cs new file mode 100644 index 0000000..baf34cd --- /dev/null +++ b/Jellyfin.Plugin.SRFPlay/Services/Interfaces/IMediaCompositionFetcher.cs @@ -0,0 +1,23 @@ +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Plugin.SRFPlay.Api.Models; + +namespace Jellyfin.Plugin.SRFPlay.Services.Interfaces; + +/// +/// Interface for fetching media composition with caching support. +/// +public interface IMediaCompositionFetcher +{ + /// + /// Gets media composition by URN, using cache if available. + /// + /// The URN to fetch. + /// Cancellation token. + /// Optional override for cache duration (e.g., 5 min for livestreams). + /// The media composition, or null if not found. + Task GetMediaCompositionAsync( + string urn, + CancellationToken cancellationToken, + int? cacheDurationOverride = null); +} diff --git a/Jellyfin.Plugin.SRFPlay/Services/Interfaces/IMetadataCache.cs b/Jellyfin.Plugin.SRFPlay/Services/Interfaces/IMetadataCache.cs new file mode 100644 index 0000000..de13818 --- /dev/null +++ b/Jellyfin.Plugin.SRFPlay/Services/Interfaces/IMetadataCache.cs @@ -0,0 +1,41 @@ +using Jellyfin.Plugin.SRFPlay.Api.Models; + +namespace Jellyfin.Plugin.SRFPlay.Services.Interfaces; + +/// +/// Interface for caching metadata from SRF API. +/// +public interface IMetadataCache +{ + /// + /// Gets cached media composition by URN. + /// + /// The URN. + /// The cache duration in minutes. + /// The cached media composition, or null if not found or expired. + MediaComposition? GetMediaComposition(string urn, int cacheDurationMinutes); + + /// + /// Sets media composition in cache. + /// + /// The URN. + /// 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 new file mode 100644 index 0000000..aeda3ec --- /dev/null +++ b/Jellyfin.Plugin.SRFPlay/Services/Interfaces/IStreamProxyService.cs @@ -0,0 +1,56 @@ +using System.Threading; +using System.Threading.Tasks; + +namespace Jellyfin.Plugin.SRFPlay.Services.Interfaces; + +/// +/// Interface for proxying SRF Play streams and managing authentication. +/// +public interface IStreamProxyService +{ + /// + /// 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). + void RegisterStream(string itemId, string authenticatedUrl, string? urn = null, bool isLiveStream = false); + + /// + /// Gets stream metadata for an item (URN and isLiveStream flag). + /// + /// The item ID. + /// A tuple of (URN, IsLiveStream), or null if not found. + (string? Urn, bool IsLiveStream)? GetStreamMetadata(string itemId); + + /// + /// Gets the authenticated URL for an item. + /// + /// The item ID. + /// The authenticated URL, or null if not found or expired. + string? GetAuthenticatedUrl(string itemId); + + /// + /// Fetches and rewrites an HLS manifest to use proxy URLs. + /// + /// The item ID. + /// The base proxy URL. + /// Cancellation token. + /// The rewritten manifest content. + Task GetRewrittenManifestAsync(string itemId, string baseProxyUrl, CancellationToken cancellationToken = default); + + /// + /// Fetches a segment from the original source. + /// + /// The item ID. + /// The segment path. + /// Cancellation token. + /// The segment content as bytes. + Task GetSegmentAsync(string itemId, string segmentPath, CancellationToken cancellationToken = default); + + /// + /// Cleans up old and expired stream mappings. + /// + void CleanupOldMappings(); +} diff --git a/Jellyfin.Plugin.SRFPlay/Services/Interfaces/IStreamUrlResolver.cs b/Jellyfin.Plugin.SRFPlay/Services/Interfaces/IStreamUrlResolver.cs new file mode 100644 index 0000000..00c25a6 --- /dev/null +++ b/Jellyfin.Plugin.SRFPlay/Services/Interfaces/IStreamUrlResolver.cs @@ -0,0 +1,42 @@ +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Plugin.SRFPlay.Api.Models; +using Jellyfin.Plugin.SRFPlay.Configuration; + +namespace Jellyfin.Plugin.SRFPlay.Services.Interfaces; + +/// +/// Interface for resolving stream URLs from media composition resources. +/// +public interface IStreamUrlResolver +{ + /// + /// Gets the best stream URL from a chapter based on quality preference. + /// + /// The chapter containing resources. + /// The quality preference. + /// The stream URL, or null if no suitable stream found. + string? GetStreamUrl(Chapter chapter, QualityPreference qualityPreference); + + /// + /// Checks if a chapter has non-DRM playable content. + /// + /// The chapter to check. + /// True if playable content is available. + bool HasPlayableContent(Chapter chapter); + + /// + /// Checks if content is expired based on ValidTo date. + /// + /// The chapter to check. + /// True if the content is expired. + bool IsContentExpired(Chapter chapter); + + /// + /// Authenticates a stream URL by fetching an Akamai token. + /// + /// The unauthenticated stream URL. + /// Cancellation token. + /// The authenticated stream URL with token. + Task GetAuthenticatedStreamUrlAsync(string streamUrl, CancellationToken cancellationToken = default); +} diff --git a/Jellyfin.Plugin.SRFPlay/Services/MediaCompositionFetcher.cs b/Jellyfin.Plugin.SRFPlay/Services/MediaCompositionFetcher.cs new file mode 100644 index 0000000..4940f7d --- /dev/null +++ b/Jellyfin.Plugin.SRFPlay/Services/MediaCompositionFetcher.cs @@ -0,0 +1,82 @@ +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Plugin.SRFPlay.Api; +using Jellyfin.Plugin.SRFPlay.Api.Models; +using Jellyfin.Plugin.SRFPlay.Services.Interfaces; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.Plugin.SRFPlay.Services; + +/// +/// Service for fetching media composition with caching support. +/// This consolidates the cache-check → API-fetch → cache-store pattern. +/// +public class MediaCompositionFetcher : IMediaCompositionFetcher +{ + private readonly ILogger _logger; + private readonly IMetadataCache _metadataCache; + private readonly ISRFApiClientFactory _apiClientFactory; + + /// + /// Initializes a new instance of the class. + /// + /// The logger. + /// The metadata cache. + /// The API client factory. + public MediaCompositionFetcher( + ILogger logger, + IMetadataCache metadataCache, + ISRFApiClientFactory apiClientFactory) + { + _logger = logger; + _metadataCache = metadataCache; + _apiClientFactory = apiClientFactory; + } + + /// + public async Task GetMediaCompositionAsync( + string urn, + CancellationToken cancellationToken, + int? cacheDurationOverride = null) + { + if (string.IsNullOrEmpty(urn)) + { + _logger.LogDebug("GetMediaCompositionAsync called with null/empty URN"); + return null; + } + + var config = Plugin.Instance?.Configuration; + if (config == null) + { + _logger.LogWarning("Plugin configuration is null, cannot fetch media composition"); + return null; + } + + var cacheDuration = cacheDurationOverride ?? config.CacheDurationMinutes; + + // Try cache first + var mediaComposition = _metadataCache.GetMediaComposition(urn, cacheDuration); + if (mediaComposition != null) + { + _logger.LogDebug("Cache hit for URN: {Urn}", urn); + return mediaComposition; + } + + // Fetch from API + _logger.LogDebug("Cache miss for URN: {Urn}, fetching from API", urn); + using var apiClient = _apiClientFactory.CreateClient(); + mediaComposition = await apiClient.GetMediaCompositionByUrnAsync(urn, cancellationToken).ConfigureAwait(false); + + if (mediaComposition != null) + { + _metadataCache.SetMediaComposition(urn, mediaComposition); + _logger.LogDebug("Cached media composition for URN: {Urn}", urn); + } + else + { + _logger.LogWarning("Failed to fetch media composition for URN: {Urn}", urn); + } + + return mediaComposition; + } +} diff --git a/Jellyfin.Plugin.SRFPlay/Services/MetadataCache.cs b/Jellyfin.Plugin.SRFPlay/Services/MetadataCache.cs index a99de5f..cacb2fb 100644 --- a/Jellyfin.Plugin.SRFPlay/Services/MetadataCache.cs +++ b/Jellyfin.Plugin.SRFPlay/Services/MetadataCache.cs @@ -2,6 +2,7 @@ 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; namespace Jellyfin.Plugin.SRFPlay.Services; @@ -9,7 +10,7 @@ namespace Jellyfin.Plugin.SRFPlay.Services; /// /// Service for caching metadata from SRF API. /// -public sealed class MetadataCache : IDisposable +public sealed class MetadataCache : IMetadataCache, IDisposable { private readonly ILogger _logger; private readonly ConcurrentDictionary> _mediaCompositionCache; diff --git a/Jellyfin.Plugin.SRFPlay/Services/StreamProxyService.cs b/Jellyfin.Plugin.SRFPlay/Services/StreamProxyService.cs index c461d97..1e8e455 100644 --- a/Jellyfin.Plugin.SRFPlay/Services/StreamProxyService.cs +++ b/Jellyfin.Plugin.SRFPlay/Services/StreamProxyService.cs @@ -8,6 +8,7 @@ using System.Threading.Tasks; using System.Web; using Jellyfin.Plugin.SRFPlay.Api; using Jellyfin.Plugin.SRFPlay.Configuration; +using Jellyfin.Plugin.SRFPlay.Services.Interfaces; using Microsoft.Extensions.Logging; namespace Jellyfin.Plugin.SRFPlay.Services; @@ -15,11 +16,11 @@ namespace Jellyfin.Plugin.SRFPlay.Services; /// /// Service for proxying SRF Play streams and managing authentication. /// -public class StreamProxyService : IDisposable +public class StreamProxyService : IStreamProxyService, IDisposable { private readonly ILogger _logger; - private readonly ILoggerFactory _loggerFactory; - private readonly StreamUrlResolver _streamResolver; + private readonly IStreamUrlResolver _streamResolver; + private readonly IMediaCompositionFetcher _compositionFetcher; private readonly HttpClient _httpClient; private readonly ConcurrentDictionary _streamMappings; private bool _disposed; @@ -28,13 +29,16 @@ public class StreamProxyService : IDisposable /// Initializes a new instance of the class. /// /// The logger. - /// The logger factory (for creating API clients). /// The stream URL resolver. - public StreamProxyService(ILogger logger, ILoggerFactory loggerFactory, StreamUrlResolver streamResolver) + /// The media composition fetcher. + public StreamProxyService( + ILogger logger, + IStreamUrlResolver streamResolver, + IMediaCompositionFetcher compositionFetcher) { _logger = logger; - _loggerFactory = loggerFactory; _streamResolver = streamResolver; + _compositionFetcher = compositionFetcher; _httpClient = new HttpClient { Timeout = TimeSpan.FromSeconds(30) @@ -306,8 +310,8 @@ public class StreamProxyService : IDisposable try { - using var apiClient = new SRFApiClient(_loggerFactory); - var mediaComposition = apiClient.GetMediaCompositionByUrnAsync(streamInfo.Urn, CancellationToken.None) + // Use short cache duration (5 min) for livestreams + var mediaComposition = _compositionFetcher.GetMediaCompositionAsync(streamInfo.Urn, CancellationToken.None, 5) .GetAwaiter().GetResult(); if (mediaComposition?.ChapterList == null || mediaComposition.ChapterList.Count == 0) diff --git a/Jellyfin.Plugin.SRFPlay/Services/StreamUrlResolver.cs b/Jellyfin.Plugin.SRFPlay/Services/StreamUrlResolver.cs index f46c1cc..bbdc4f9 100644 --- a/Jellyfin.Plugin.SRFPlay/Services/StreamUrlResolver.cs +++ b/Jellyfin.Plugin.SRFPlay/Services/StreamUrlResolver.cs @@ -6,6 +6,7 @@ using System.Threading; using System.Threading.Tasks; using Jellyfin.Plugin.SRFPlay.Api.Models; using Jellyfin.Plugin.SRFPlay.Configuration; +using Jellyfin.Plugin.SRFPlay.Services.Interfaces; using Microsoft.Extensions.Logging; namespace Jellyfin.Plugin.SRFPlay.Services; @@ -13,7 +14,7 @@ namespace Jellyfin.Plugin.SRFPlay.Services; /// /// Service for resolving stream URLs from media composition resources. /// -public class StreamUrlResolver : IDisposable +public class StreamUrlResolver : IStreamUrlResolver, IDisposable { private readonly ILogger _logger; private readonly HttpClient _httpClient;