first commit
🏗️ Build Plugin / call (push) Failing after 0s
📝 Create/Update Release Draft & Release Bump PR / call (push) Failing after 0s
🧪 Test Plugin / call (push) Failing after 0s
🔬 Run CodeQL / call (push) Failing after 0s

This commit is contained in:
2025-11-12 22:05:36 +01:00
parent d544b71939
commit ac6a3842dd
46 changed files with 5891 additions and 499 deletions
@@ -0,0 +1,212 @@
using System;
using System.Collections.Generic;
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 MediaBrowser.Controller.Entities.TV;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.Providers;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.SRFPlay.Providers;
/// <summary>
/// Provides metadata for SRF Play episodes.
/// </summary>
public class SRFEpisodeProvider : IRemoteMetadataProvider<Episode, EpisodeInfo>
{
private readonly ILogger<SRFEpisodeProvider> _logger;
private readonly ILoggerFactory _loggerFactory;
private readonly IHttpClientFactory _httpClientFactory;
private readonly MetadataCache _metadataCache;
/// <summary>
/// Initializes a new instance of the <see cref="SRFEpisodeProvider"/> class.
/// </summary>
/// <param name="loggerFactory">The logger factory.</param>
/// <param name="httpClientFactory">The HTTP client factory.</param>
/// <param name="metadataCache">The metadata cache.</param>
public SRFEpisodeProvider(
ILoggerFactory loggerFactory,
IHttpClientFactory httpClientFactory,
MetadataCache metadataCache)
{
_loggerFactory = loggerFactory;
_logger = loggerFactory.CreateLogger<SRFEpisodeProvider>();
_httpClientFactory = httpClientFactory;
_metadataCache = metadataCache;
}
/// <inheritdoc />
public string Name => "SRF Play";
/// <inheritdoc />
public async Task<IEnumerable<RemoteSearchResult>> GetSearchResults(EpisodeInfo searchInfo, CancellationToken cancellationToken)
{
var results = new List<RemoteSearchResult>();
try
{
// Check if we have a URN to search with
if (searchInfo.ProviderIds.TryGetValue("SRF", out var urn) && !string.IsNullOrEmpty(urn))
{
_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);
}
}
if (mediaComposition?.ChapterList != null && mediaComposition.ChapterList.Count > 0)
{
var chapter = mediaComposition.ChapterList[0];
results.Add(new RemoteSearchResult
{
Name = chapter.Title,
Overview = chapter.Description ?? chapter.Lead,
ImageUrl = chapter.ImageUrl,
SearchProviderName = Name,
ProviderIds = new Dictionary<string, string>
{
{ "SRF", chapter.Urn }
}
});
}
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error searching for episode: {Name}", searchInfo.Name);
}
return results;
}
/// <inheritdoc />
public async Task<MetadataResult<Episode>> GetMetadata(EpisodeInfo info, CancellationToken cancellationToken)
{
var result = new MetadataResult<Episode>();
try
{
// Check if we have a URN
if (!info.ProviderIds.TryGetValue("SRF", out var urn) || string.IsNullOrEmpty(urn))
{
_logger.LogDebug("No SRF URN found for episode: {Name}", info.Name);
return result;
}
_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);
}
}
if (mediaComposition?.ChapterList == null || mediaComposition.ChapterList.Count == 0)
{
_logger.LogWarning("No chapter information found for URN: {Urn}", urn);
return result;
}
// Get the first chapter (main video)
var chapter = mediaComposition.ChapterList[0];
result.Item = new Episode
{
Name = chapter.Title,
Overview = chapter.Description ?? chapter.Lead,
ProviderIds = new Dictionary<string, string>
{
{ "SRF", chapter.Urn }
}
};
// Set episode and season numbers if available
if (chapter.EpisodeNumber.HasValue)
{
result.Item.IndexNumber = chapter.EpisodeNumber;
}
if (chapter.SeasonNumber.HasValue)
{
result.Item.ParentIndexNumber = chapter.SeasonNumber;
}
// Set premiere date if available
if (chapter.Date.HasValue)
{
result.Item.PremiereDate = chapter.Date;
}
// Set runtime (convert from milliseconds to ticks)
if (chapter.Duration > 0)
{
result.Item.RunTimeTicks = TimeSpan.FromMilliseconds(chapter.Duration).Ticks;
}
// Set series information if available
if (mediaComposition.Show != null)
{
result.Item.SeriesName = mediaComposition.Show.Title;
// Set series provider ID on the episode
if (!string.IsNullOrEmpty(mediaComposition.Show.Urn))
{
result.Item.SetProviderId("SRF_Series", mediaComposition.Show.Urn);
}
}
result.HasMetadata = true;
_logger.LogDebug("Successfully fetched metadata for episode: {Title}", chapter.Title);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error fetching metadata for episode: {Name}", info.Name);
}
return result;
}
/// <inheritdoc />
public Task<HttpResponseMessage> GetImageResponse(string url, CancellationToken cancellationToken)
{
throw new NotImplementedException("Image handling is done by SRFImageProvider");
}
}
@@ -0,0 +1,152 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Plugin.SRFPlay.Api;
using MediaBrowser.Common.Net;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.Providers;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.SRFPlay.Providers;
/// <summary>
/// Provides images for SRF Play content.
/// </summary>
public class SRFImageProvider : IRemoteImageProvider, IHasOrder
{
private readonly IHttpClientFactory _httpClientFactory;
private readonly ILogger<SRFImageProvider> _logger;
private readonly ILoggerFactory _loggerFactory;
/// <summary>
/// Initializes a new instance of the <see cref="SRFImageProvider"/> class.
/// </summary>
/// <param name="httpClientFactory">The HTTP client factory.</param>
/// <param name="loggerFactory">The logger factory.</param>
public SRFImageProvider(IHttpClientFactory httpClientFactory, ILoggerFactory loggerFactory)
{
_httpClientFactory = httpClientFactory;
_loggerFactory = loggerFactory;
_logger = loggerFactory.CreateLogger<SRFImageProvider>();
}
/// <inheritdoc />
public string Name => "SRF Play";
/// <inheritdoc />
public int Order => 0;
/// <inheritdoc />
public bool Supports(BaseItem item)
{
// Support movies and episodes for now
return item is MediaBrowser.Controller.Entities.Movies.Movie ||
item is MediaBrowser.Controller.Entities.TV.Episode ||
item is MediaBrowser.Controller.Entities.TV.Series;
}
/// <inheritdoc />
public IEnumerable<ImageType> GetSupportedImages(BaseItem item)
{
return new List<ImageType>
{
ImageType.Primary,
ImageType.Backdrop,
ImageType.Thumb
};
}
/// <inheritdoc />
public async Task<IEnumerable<RemoteImageInfo>> GetImages(BaseItem item, CancellationToken cancellationToken)
{
var list = new List<RemoteImageInfo>();
try
{
// Check if item has SRF URN in provider IDs
if (!item.ProviderIds.TryGetValue("SRF", out var urn) || string.IsNullOrEmpty(urn))
{
_logger.LogDebug("No SRF URN found for item: {ItemName}", item.Name);
return list;
}
_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);
if (mediaComposition == null)
{
_logger.LogWarning("Failed to fetch media composition for URN: {Urn}", urn);
return list;
}
// Extract images from chapters
if (mediaComposition.ChapterList != null && mediaComposition.ChapterList.Count > 0)
{
var chapter = mediaComposition.ChapterList[0];
if (!string.IsNullOrEmpty(chapter.ImageUrl))
{
list.Add(new RemoteImageInfo
{
Url = chapter.ImageUrl,
Type = ImageType.Primary,
ProviderName = Name
});
list.Add(new RemoteImageInfo
{
Url = chapter.ImageUrl,
Type = ImageType.Thumb,
ProviderName = Name
});
}
}
// Extract images from show
if (mediaComposition.Show != null)
{
if (!string.IsNullOrEmpty(mediaComposition.Show.ImageUrl))
{
list.Add(new RemoteImageInfo
{
Url = mediaComposition.Show.ImageUrl,
Type = ImageType.Primary,
ProviderName = Name
});
}
if (!string.IsNullOrEmpty(mediaComposition.Show.BannerImageUrl))
{
list.Add(new RemoteImageInfo
{
Url = mediaComposition.Show.BannerImageUrl,
Type = ImageType.Backdrop,
ProviderName = Name
});
}
}
_logger.LogDebug("Found {Count} images for URN: {Urn}", list.Count, urn);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error fetching images for item: {ItemName}", item.Name);
}
return list;
}
/// <inheritdoc />
public Task<HttpResponseMessage> GetImageResponse(string url, CancellationToken cancellationToken)
{
var httpClient = _httpClientFactory.CreateClient(NamedClient.Default);
return httpClient.GetAsync(new Uri(url), cancellationToken);
}
}
@@ -0,0 +1,184 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Plugin.SRFPlay.Api;
using Jellyfin.Plugin.SRFPlay.Services;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Library;
using MediaBrowser.Model.Dto;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.MediaInfo;
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 ILoggerFactory _loggerFactory;
private readonly MetadataCache _metadataCache;
private readonly StreamUrlResolver _streamResolver;
/// <summary>
/// Initializes a new instance of the <see cref="SRFMediaProvider"/> class.
/// </summary>
/// <param name="loggerFactory">The logger factory.</param>
/// <param name="metadataCache">The metadata cache.</param>
/// <param name="streamResolver">The stream URL resolver.</param>
public SRFMediaProvider(
ILoggerFactory loggerFactory,
MetadataCache metadataCache,
StreamUrlResolver streamResolver)
{
_loggerFactory = loggerFactory;
_logger = loggerFactory.CreateLogger<SRFMediaProvider>();
_metadataCache = metadataCache;
_streamResolver = streamResolver;
}
/// <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 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 Task.FromResult<IEnumerable<MediaSourceInfo>>(sources);
}
_logger.LogDebug("Getting media sources for URN: {Urn}", urn);
var config = Plugin.Instance?.Configuration;
if (config == null)
{
return Task.FromResult<IEnumerable<MediaSourceInfo>>(sources);
}
// 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 = apiClient.GetMediaCompositionByUrnAsync(urn, cancellationToken).GetAwaiter().GetResult();
if (mediaComposition != null)
{
_metadataCache.SetMediaComposition(urn, mediaComposition);
}
}
if (mediaComposition?.ChapterList == null || mediaComposition.ChapterList.Count == 0)
{
_logger.LogWarning("No chapters found for URN: {Urn}", urn);
return Task.FromResult<IEnumerable<MediaSourceInfo>>(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 Task.FromResult<IEnumerable<MediaSourceInfo>>(sources);
}
// Check if content has playable streams
if (!_streamResolver.HasPlayableContent(chapter))
{
_logger.LogWarning("No playable content found for URN: {Urn}", urn);
return Task.FromResult<IEnumerable<MediaSourceInfo>>(sources);
}
// Get stream URL based on quality preference
var streamUrl = _streamResolver.GetStreamUrl(chapter, config.QualityPreference);
if (string.IsNullOrEmpty(streamUrl))
{
_logger.LogWarning("Could not resolve stream URL for URN: {Urn}", urn);
return Task.FromResult<IEnumerable<MediaSourceInfo>>(sources);
}
// Create media source
var mediaSource = new MediaSourceInfo
{
Id = urn,
Name = chapter.Title,
Path = streamUrl,
Protocol = MediaProtocol.Http,
Container = "m3u8",
SupportsDirectStream = true,
SupportsDirectPlay = true,
SupportsTranscoding = true,
IsRemote = true,
Type = MediaSourceType.Default,
RunTimeTicks = chapter.Duration > 0 ? TimeSpan.FromMilliseconds(chapter.Duration).Ticks : null,
VideoType = VideoType.VideoFile,
IsInfiniteStream = false,
RequiresOpening = false,
RequiresClosing = false,
SupportsProbing = true
};
// Add video stream info
mediaSource.MediaStreams = new List<MediaStream>
{
new MediaStream
{
Type = MediaStreamType.Video,
Codec = "h264",
IsInterlaced = false,
IsDefault = true
}
};
sources.Add(mediaSource);
_logger.LogInformation("Resolved stream URL for {Title}: {Url}", chapter.Title, streamUrl);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error getting media sources for item: {Name}", item.Name);
}
return Task.FromResult<IEnumerable<MediaSourceInfo>>(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)
{
// Not needed for HTTP streams
return Task.FromResult<IDirectStreamProvider?>(null);
}
/// <inheritdoc />
public Task<ILiveStream> OpenMediaSource(string openToken, List<ILiveStream> currentLiveStreams, CancellationToken cancellationToken)
{
// Not needed for static HTTP streams
throw new NotImplementedException();
}
}
@@ -0,0 +1,189 @@
using System;
using System.Collections.Generic;
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 MediaBrowser.Controller.Entities.TV;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.Providers;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.SRFPlay.Providers;
/// <summary>
/// Provides metadata for SRF Play series/shows.
/// </summary>
public class SRFSeriesProvider : IRemoteMetadataProvider<Series, SeriesInfo>
{
private readonly ILogger<SRFSeriesProvider> _logger;
private readonly ILoggerFactory _loggerFactory;
private readonly IHttpClientFactory _httpClientFactory;
private readonly MetadataCache _metadataCache;
/// <summary>
/// Initializes a new instance of the <see cref="SRFSeriesProvider"/> class.
/// </summary>
/// <param name="logger">The logger instance.</param>
/// <param name="loggerFactory">The logger factory.</param>
/// <param name="httpClientFactory">The HTTP client factory.</param>
/// <param name="metadataCache">The metadata cache.</param>
public SRFSeriesProvider(
ILogger<SRFSeriesProvider> logger,
ILoggerFactory loggerFactory,
IHttpClientFactory httpClientFactory,
MetadataCache metadataCache)
{
_logger = logger;
_loggerFactory = loggerFactory;
_httpClientFactory = httpClientFactory;
_metadataCache = metadataCache;
}
/// <inheritdoc />
public string Name => "SRF Play";
/// <inheritdoc />
public async Task<IEnumerable<RemoteSearchResult>> GetSearchResults(SeriesInfo searchInfo, CancellationToken cancellationToken)
{
var results = new List<RemoteSearchResult>();
try
{
// Check if we have a URN to search with
if (searchInfo.ProviderIds.TryGetValue("SRF", out var urn) && !string.IsNullOrEmpty(urn))
{
_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);
}
}
if (mediaComposition?.Show != null)
{
var show = mediaComposition.Show;
results.Add(new RemoteSearchResult
{
Name = show.Title,
Overview = show.Description ?? show.Lead,
ImageUrl = show.ImageUrl,
SearchProviderName = Name,
ProviderIds = new Dictionary<string, string>
{
{ "SRF", show.Urn ?? urn }
}
});
}
}
else if (!string.IsNullOrEmpty(searchInfo.Name))
{
_logger.LogDebug("Name-based search not yet implemented for: {Name}", searchInfo.Name);
// TODO: Implement name-based search when SRF provides search API
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error searching for series: {Name}", searchInfo.Name);
}
return results;
}
/// <inheritdoc />
public async Task<MetadataResult<Series>> GetMetadata(SeriesInfo info, CancellationToken cancellationToken)
{
var result = new MetadataResult<Series>();
try
{
// Check if we have a URN
if (!info.ProviderIds.TryGetValue("SRF", out var urn) || string.IsNullOrEmpty(urn))
{
_logger.LogDebug("No SRF URN found for series: {Name}", info.Name);
return result;
}
_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);
}
}
if (mediaComposition?.Show == null)
{
_logger.LogWarning("No show information found for URN: {Urn}", urn);
return result;
}
var show = mediaComposition.Show;
result.Item = new Series
{
Name = show.Title,
Overview = show.Description ?? show.Lead,
ProviderIds = new Dictionary<string, string>
{
{ "SRF", show.Urn ?? urn }
}
};
// Set additional metadata if available
if (!string.IsNullOrEmpty(show.Vendor))
{
result.Item.Studios = new[] { show.Vendor };
}
result.HasMetadata = true;
_logger.LogDebug("Successfully fetched metadata for series: {Title}", show.Title);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error fetching metadata for series: {Name}", info.Name);
}
return result;
}
/// <inheritdoc />
public Task<HttpResponseMessage> GetImageResponse(string url, CancellationToken cancellationToken)
{
throw new NotImplementedException("Image handling is done by SRFImageProvider");
}
}