Remove 9 dead methods, 6 unused constants, and redundant ReaderWriterLockSlim from MetadataCache. Consolidate repeated patterns into HasChapters, IsPlayable, and ToLowerString helpers. Extract shared API methods in SRFApiClient. Move variant manifest rewriting from controller to StreamProxyService. Make Auto quality distinct from HD. Update README architecture section.
180 lines
7.2 KiB
C#
180 lines
7.2 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
using Jellyfin.Plugin.SRFPlay.Configuration;
|
|
using Jellyfin.Plugin.SRFPlay.Services.Interfaces;
|
|
using MediaBrowser.Controller.Entities;
|
|
using MediaBrowser.Controller.Library;
|
|
using MediaBrowser.Model.Dto;
|
|
using Microsoft.Extensions.Logging;
|
|
|
|
namespace Jellyfin.Plugin.SRFPlay.Providers;
|
|
|
|
/// <summary>
|
|
/// Provides media sources (playback URLs) for SRF Play content.
|
|
/// </summary>
|
|
public class SRFMediaProvider : IMediaSourceProvider
|
|
{
|
|
private readonly ILogger<SRFMediaProvider> _logger;
|
|
private readonly IMediaCompositionFetcher _compositionFetcher;
|
|
private readonly IStreamUrlResolver _streamResolver;
|
|
private readonly IMediaSourceFactory _mediaSourceFactory;
|
|
|
|
/// <summary>
|
|
/// Initializes a new instance of the <see cref="SRFMediaProvider"/> class.
|
|
/// </summary>
|
|
/// <param name="loggerFactory">The logger factory.</param>
|
|
/// <param name="compositionFetcher">The media composition fetcher.</param>
|
|
/// <param name="streamResolver">The stream URL resolver.</param>
|
|
/// <param name="mediaSourceFactory">The media source factory.</param>
|
|
public SRFMediaProvider(
|
|
ILoggerFactory loggerFactory,
|
|
IMediaCompositionFetcher compositionFetcher,
|
|
IStreamUrlResolver streamResolver,
|
|
IMediaSourceFactory mediaSourceFactory)
|
|
{
|
|
_logger = loggerFactory.CreateLogger<SRFMediaProvider>();
|
|
_compositionFetcher = compositionFetcher;
|
|
_streamResolver = streamResolver;
|
|
_mediaSourceFactory = mediaSourceFactory;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets the provider name.
|
|
/// </summary>
|
|
public string Name => "SRF Play";
|
|
|
|
/// <summary>
|
|
/// Gets media sources for the specified item.
|
|
/// </summary>
|
|
/// <param name="item">The item.</param>
|
|
/// <param name="cancellationToken">The cancellation token.</param>
|
|
/// <returns>List of media sources.</returns>
|
|
public async Task<IEnumerable<MediaSourceInfo>> GetMediaSources(BaseItem item, CancellationToken cancellationToken)
|
|
{
|
|
var sources = new List<MediaSourceInfo>();
|
|
|
|
try
|
|
{
|
|
// Check if this is an SRF item
|
|
if (!item.ProviderIds.TryGetValue("SRF", out var urn) || string.IsNullOrEmpty(urn))
|
|
{
|
|
return sources;
|
|
}
|
|
|
|
_logger.LogDebug("GetMediaSources for URN: {Urn}, Item: {ItemName}", urn, item.Name);
|
|
|
|
// For scheduled livestreams, use shorter cache TTL (5 minutes) so they refresh when they go live
|
|
var cacheDuration = urn.Contains("scheduled_livestream", StringComparison.OrdinalIgnoreCase) ? 5 : (int?)null;
|
|
|
|
var mediaComposition = await _compositionFetcher.GetMediaCompositionAsync(urn, cancellationToken, cacheDuration).ConfigureAwait(false);
|
|
|
|
if (mediaComposition?.HasChapters != true)
|
|
{
|
|
_logger.LogWarning("No chapters found for URN: {Urn}", urn);
|
|
return sources;
|
|
}
|
|
|
|
// Get the first chapter (main video)
|
|
var chapter = mediaComposition.ChapterList[0];
|
|
|
|
// Check if content is expired
|
|
if (_streamResolver.IsContentExpired(chapter))
|
|
{
|
|
_logger.LogWarning("Content expired for URN: {Urn}, ValidTo: {ValidTo}", urn, chapter.ValidTo);
|
|
return sources;
|
|
}
|
|
|
|
// Check if content has playable streams
|
|
if (!_streamResolver.HasPlayableContent(chapter))
|
|
{
|
|
_logger.LogWarning("No playable content found for URN: {Urn}", urn);
|
|
return sources;
|
|
}
|
|
|
|
// Get quality preference from config
|
|
var config = Plugin.Instance?.Configuration;
|
|
var qualityPref = config?.QualityPreference ?? QualityPreference.HD;
|
|
|
|
// Use item ID in hex format without dashes
|
|
var itemIdStr = item.Id.ToString("N");
|
|
|
|
// Use factory to create MediaSourceInfo
|
|
var mediaSource = await _mediaSourceFactory.CreateMediaSourceAsync(
|
|
chapter,
|
|
itemIdStr,
|
|
urn,
|
|
qualityPref,
|
|
cancellationToken).ConfigureAwait(false);
|
|
|
|
// For scheduled livestreams, retry with fresh data if no stream URL
|
|
if (mediaSource == null && chapter.Type == "SCHEDULED_LIVESTREAM")
|
|
{
|
|
_logger.LogDebug("URN {Urn}: Scheduled livestream has no stream URL, fetching fresh data", urn);
|
|
|
|
// Force fresh fetch with short cache duration
|
|
var freshMediaComposition = await _compositionFetcher.GetMediaCompositionAsync(urn, cancellationToken, 1).ConfigureAwait(false);
|
|
|
|
if (freshMediaComposition?.HasChapters == true)
|
|
{
|
|
var freshChapter = freshMediaComposition.ChapterList[0];
|
|
mediaSource = await _mediaSourceFactory.CreateMediaSourceAsync(
|
|
freshChapter,
|
|
itemIdStr,
|
|
urn,
|
|
qualityPref,
|
|
cancellationToken).ConfigureAwait(false);
|
|
|
|
if (mediaSource != null)
|
|
{
|
|
chapter = freshChapter;
|
|
_logger.LogInformation("URN {Urn}: Got fresh stream URL for scheduled livestream", urn);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (mediaSource == null)
|
|
{
|
|
_logger.LogWarning("Could not resolve stream URL for URN: {Urn}", urn);
|
|
return sources;
|
|
}
|
|
|
|
sources.Add(mediaSource);
|
|
_logger.LogDebug(
|
|
"MediaSource created for {Title} - Id={Id}, DirectPlay={DirectPlay}, Transcoding={Transcoding}",
|
|
chapter.Title,
|
|
mediaSource.Id,
|
|
mediaSource.SupportsDirectPlay,
|
|
mediaSource.SupportsTranscoding);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Error getting media sources for item: {Name}", item.Name);
|
|
}
|
|
|
|
return sources;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets direct stream provider by unique ID.
|
|
/// </summary>
|
|
/// <param name="uniqueId">The unique ID.</param>
|
|
/// <param name="cancellationToken">The cancellation token.</param>
|
|
/// <returns>The direct stream provider.</returns>
|
|
public Task<IDirectStreamProvider?> GetDirectStreamProviderByUniqueId(string uniqueId, CancellationToken cancellationToken)
|
|
{
|
|
_logger.LogInformation("GetDirectStreamProviderByUniqueId called with uniqueId: {UniqueId}", uniqueId);
|
|
// Not needed for HTTP streams
|
|
return Task.FromResult<IDirectStreamProvider?>(null);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public Task<ILiveStream> OpenMediaSource(string openToken, List<ILiveStream> currentLiveStreams, CancellationToken cancellationToken)
|
|
{
|
|
// Not used - RequiresOpening is false, proxy handles authentication directly
|
|
_logger.LogWarning("OpenMediaSource unexpectedly called with openToken: {OpenToken}", openToken);
|
|
throw new NotSupportedException("OpenMediaSource not supported - streams use direct proxy access");
|
|
}
|
|
}
|