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;