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.
628 lines
25 KiB
C#
628 lines
25 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Globalization;
|
|
using System.Linq;
|
|
using System.Text;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
using Jellyfin.Plugin.SRFPlay.Api;
|
|
using Jellyfin.Plugin.SRFPlay.Constants;
|
|
using Jellyfin.Plugin.SRFPlay.Services.Interfaces;
|
|
using Jellyfin.Plugin.SRFPlay.Utilities;
|
|
using MediaBrowser.Controller;
|
|
using MediaBrowser.Controller.Channels;
|
|
using MediaBrowser.Controller.Providers;
|
|
using MediaBrowser.Model.Channels;
|
|
using MediaBrowser.Model.Dto;
|
|
using MediaBrowser.Model.Entities;
|
|
using Microsoft.Extensions.Logging;
|
|
|
|
namespace Jellyfin.Plugin.SRFPlay.Channels;
|
|
|
|
/// <summary>
|
|
/// SRF Play channel for browsing and playing content.
|
|
/// </summary>
|
|
public class SRFPlayChannel : IChannel, IHasCacheKey
|
|
{
|
|
private readonly ILogger<SRFPlayChannel> _logger;
|
|
private readonly IContentRefreshService _contentRefreshService;
|
|
private readonly IStreamUrlResolver _streamResolver;
|
|
private readonly IMediaSourceFactory _mediaSourceFactory;
|
|
private readonly ICategoryService? _categoryService;
|
|
private readonly ISRFApiClientFactory _apiClientFactory;
|
|
|
|
/// <summary>
|
|
/// Initializes a new instance of the <see cref="SRFPlayChannel"/> class.
|
|
/// </summary>
|
|
/// <param name="loggerFactory">The logger factory.</param>
|
|
/// <param name="contentRefreshService">The content refresh service.</param>
|
|
/// <param name="streamResolver">The stream resolver.</param>
|
|
/// <param name="mediaSourceFactory">The media source factory.</param>
|
|
/// <param name="categoryService">The category service (optional).</param>
|
|
/// <param name="apiClientFactory">The API client factory.</param>
|
|
public SRFPlayChannel(
|
|
ILoggerFactory loggerFactory,
|
|
IContentRefreshService contentRefreshService,
|
|
IStreamUrlResolver streamResolver,
|
|
IMediaSourceFactory mediaSourceFactory,
|
|
ICategoryService? categoryService,
|
|
ISRFApiClientFactory apiClientFactory)
|
|
{
|
|
_logger = loggerFactory.CreateLogger<SRFPlayChannel>();
|
|
_contentRefreshService = contentRefreshService;
|
|
_streamResolver = streamResolver;
|
|
_mediaSourceFactory = mediaSourceFactory;
|
|
_categoryService = categoryService;
|
|
_apiClientFactory = apiClientFactory;
|
|
|
|
if (_categoryService == null)
|
|
{
|
|
_logger.LogWarning("CategoryService not available - category folders will be disabled");
|
|
}
|
|
|
|
_logger.LogDebug("SRFPlayChannel initialized");
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public string Name => "SRF Play";
|
|
|
|
/// <inheritdoc />
|
|
public string Description => "Swiss Radio and Television video-on-demand content";
|
|
|
|
/// <inheritdoc />
|
|
public string DataVersion => "2.0"; // Back to authenticating at channel refresh with auto-refresh for fresh tokens
|
|
|
|
/// <inheritdoc />
|
|
public string HomePageUrl => ApiEndpoints.SrfPlayHomepage;
|
|
|
|
/// <inheritdoc />
|
|
public ChannelParentalRating ParentalRating => ChannelParentalRating.GeneralAudience;
|
|
|
|
/// <inheritdoc />
|
|
public InternalChannelFeatures GetChannelFeatures()
|
|
{
|
|
_logger.LogDebug("GetChannelFeatures called");
|
|
|
|
return new InternalChannelFeatures
|
|
{
|
|
ContentTypes = new List<ChannelMediaContentType>
|
|
{
|
|
ChannelMediaContentType.Episode,
|
|
ChannelMediaContentType.Movie
|
|
},
|
|
MediaTypes = new List<ChannelMediaType>
|
|
{
|
|
ChannelMediaType.Video
|
|
},
|
|
SupportsSortOrderToggle = false,
|
|
DefaultSortFields = new List<ChannelItemSortField>
|
|
{
|
|
ChannelItemSortField.DateCreated,
|
|
ChannelItemSortField.Name
|
|
},
|
|
MaxPageSize = 50
|
|
};
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public Task<DynamicImageResponse> GetChannelImage(ImageType type, CancellationToken cancellationToken)
|
|
{
|
|
var assembly = GetType().Assembly;
|
|
var resourceStream = assembly.GetManifestResourceStream("Jellyfin.Plugin.SRFPlay.Images.logo.png");
|
|
|
|
if (resourceStream == null)
|
|
{
|
|
return Task.FromResult(new DynamicImageResponse { HasImage = false });
|
|
}
|
|
|
|
return Task.FromResult(new DynamicImageResponse
|
|
{
|
|
HasImage = true,
|
|
Stream = resourceStream,
|
|
Format = MediaBrowser.Model.Drawing.ImageFormat.Png
|
|
});
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public IEnumerable<ImageType> GetSupportedChannelImages()
|
|
{
|
|
return new List<ImageType>
|
|
{
|
|
ImageType.Primary,
|
|
ImageType.Thumb
|
|
};
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task<ChannelItemResult> GetChannelItems(InternalChannelItemQuery query, CancellationToken cancellationToken)
|
|
{
|
|
_logger.LogDebug("GetChannelItems called for folder {FolderId}", query.FolderId);
|
|
|
|
try
|
|
{
|
|
var items = await GetFolderItemsAsync(query.FolderId, cancellationToken).ConfigureAwait(false);
|
|
_logger.LogDebug("Returning {Count} channel items for folder {FolderId}", items.Count, query.FolderId);
|
|
|
|
return new ChannelItemResult
|
|
{
|
|
Items = items,
|
|
TotalRecordCount = items.Count
|
|
};
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Error getting channel items for folder {FolderId}", query.FolderId);
|
|
return new ChannelItemResult { Items = new List<ChannelItemInfo>(), TotalRecordCount = 0 };
|
|
}
|
|
}
|
|
|
|
private async Task<List<ChannelItemInfo>> GetFolderItemsAsync(string? folderId, CancellationToken cancellationToken)
|
|
{
|
|
// Root level - show folder list
|
|
if (string.IsNullOrEmpty(folderId))
|
|
{
|
|
return await GetRootFoldersAsync(cancellationToken).ConfigureAwait(false);
|
|
}
|
|
|
|
// Handle known folder types
|
|
return folderId switch
|
|
{
|
|
"latest" => await GetLatestVideosAsync(cancellationToken).ConfigureAwait(false),
|
|
"trending" => await GetTrendingVideosAsync(cancellationToken).ConfigureAwait(false),
|
|
"live_sports" => await GetLiveSportsAsync(cancellationToken).ConfigureAwait(false),
|
|
_ when folderId.StartsWith("category_", StringComparison.Ordinal) => await GetCategoryVideosAsync(folderId, cancellationToken).ConfigureAwait(false),
|
|
_ => new List<ChannelItemInfo>()
|
|
};
|
|
}
|
|
|
|
private async Task<List<ChannelItemInfo>> GetRootFoldersAsync(CancellationToken cancellationToken)
|
|
{
|
|
var items = new List<ChannelItemInfo>
|
|
{
|
|
CreateFolder("latest", "Latest Videos"),
|
|
CreateFolder("trending", "Trending Videos"),
|
|
CreateFolder("live_sports", "Live Sports & Events")
|
|
};
|
|
|
|
// Add category folders if enabled
|
|
var config = Plugin.Instance?.Configuration;
|
|
if (config?.EnableCategoryFolders == true && _categoryService != null)
|
|
{
|
|
try
|
|
{
|
|
var businessUnit = config.BusinessUnit.ToLowerString();
|
|
var topics = await _categoryService.GetTopicsAsync(businessUnit, cancellationToken).ConfigureAwait(false);
|
|
|
|
foreach (var topic in topics.Where(t => !string.IsNullOrEmpty(t.Id)))
|
|
{
|
|
if (config.EnabledTopics != null && config.EnabledTopics.Count > 0 && !config.EnabledTopics.Contains(topic.Id!))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
// Generate placeholder image for topic
|
|
var placeholderUrl = CreatePlaceholderImageUrl(topic.Title ?? topic.Id!, _mediaSourceFactory.GetServerBaseUrl());
|
|
items.Add(CreateFolder($"category_{topic.Id}", topic.Title ?? topic.Id!, topic.Lead, placeholderUrl));
|
|
}
|
|
|
|
_logger.LogInformation("Added {Count} category folders", topics.Count);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Failed to load category folders - continuing without categories");
|
|
}
|
|
}
|
|
|
|
return items;
|
|
}
|
|
|
|
private static ChannelItemInfo CreateFolder(string id, string name, string? overview = null, string? imageUrl = null)
|
|
{
|
|
return new ChannelItemInfo
|
|
{
|
|
Id = id,
|
|
Name = name,
|
|
Type = ChannelItemType.Folder,
|
|
FolderType = ChannelFolderType.Container,
|
|
ImageUrl = imageUrl,
|
|
Overview = overview
|
|
};
|
|
}
|
|
|
|
private async Task<List<ChannelItemInfo>> GetLatestVideosAsync(CancellationToken cancellationToken)
|
|
{
|
|
var urns = await _contentRefreshService.RefreshLatestContentAsync(cancellationToken).ConfigureAwait(false);
|
|
return await ConvertUrnsToChannelItems(urns, cancellationToken).ConfigureAwait(false);
|
|
}
|
|
|
|
private async Task<List<ChannelItemInfo>> GetTrendingVideosAsync(CancellationToken cancellationToken)
|
|
{
|
|
var urns = await _contentRefreshService.RefreshTrendingContentAsync(cancellationToken).ConfigureAwait(false);
|
|
return await ConvertUrnsToChannelItems(urns, cancellationToken).ConfigureAwait(false);
|
|
}
|
|
|
|
private async Task<List<ChannelItemInfo>> GetLiveSportsAsync(CancellationToken cancellationToken)
|
|
{
|
|
var items = new List<ChannelItemInfo>();
|
|
var config = Plugin.Instance?.Configuration;
|
|
|
|
try
|
|
{
|
|
var businessUnit = config?.BusinessUnit.ToLowerString() ?? "srf";
|
|
|
|
using var apiClient = _apiClientFactory.CreateClient();
|
|
var scheduledLivestreams = await apiClient.GetScheduledLivestreamsAsync(businessUnit, "SPORT", cancellationToken).ConfigureAwait(false);
|
|
|
|
if (scheduledLivestreams == null)
|
|
{
|
|
return items;
|
|
}
|
|
|
|
// Filter for upcoming/current events (within next 7 days)
|
|
var now = DateTime.UtcNow;
|
|
var weekFromNow = now.AddDays(7);
|
|
|
|
var upcomingEvents = scheduledLivestreams
|
|
.Where(p => p.Urn != null &&
|
|
!string.IsNullOrEmpty(p.Title) &&
|
|
p.ValidFrom != null &&
|
|
p.ValidFrom.Value.ToUniversalTime() <= weekFromNow &&
|
|
(p.ValidTo == null || p.ValidTo.Value.ToUniversalTime() > now))
|
|
.OrderBy(p => p.ValidFrom)
|
|
.ToList();
|
|
|
|
_logger.LogInformation("Found {Count} scheduled live sports events", upcomingEvents.Count);
|
|
|
|
var urns = upcomingEvents.Select(e => e.Urn!).ToList();
|
|
items = await ConvertUrnsToChannelItems(urns, cancellationToken).ConfigureAwait(false);
|
|
|
|
// Enhance items with scheduled time information
|
|
foreach (var item in items)
|
|
{
|
|
var matchingEvent = upcomingEvents.FirstOrDefault(e => item.ProviderIds.ContainsKey("SRF") && item.ProviderIds["SRF"] == e.Urn);
|
|
if (matchingEvent?.ValidFrom != null)
|
|
{
|
|
var eventTime = matchingEvent.ValidFrom.Value;
|
|
item.Name = $"[{eventTime:dd.MM HH:mm}] {matchingEvent.Title}";
|
|
item.PremiereDate = eventTime;
|
|
}
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Failed to load live sports events");
|
|
}
|
|
|
|
return items;
|
|
}
|
|
|
|
private async Task<List<ChannelItemInfo>> GetCategoryVideosAsync(string folderId, CancellationToken cancellationToken)
|
|
{
|
|
var items = new List<ChannelItemInfo>();
|
|
|
|
if (_categoryService == null)
|
|
{
|
|
_logger.LogWarning("CategoryService not available - cannot display category folder");
|
|
return items;
|
|
}
|
|
|
|
try
|
|
{
|
|
var config = Plugin.Instance?.Configuration;
|
|
var topicId = folderId.Substring("category_".Length);
|
|
var businessUnit = config?.BusinessUnit.ToLowerString() ?? "srf";
|
|
|
|
var shows = await _categoryService.GetShowsByTopicAsync(topicId, businessUnit, 20, cancellationToken).ConfigureAwait(false);
|
|
var urns = new List<string>();
|
|
|
|
using var apiClient = _apiClientFactory.CreateClient();
|
|
|
|
foreach (var show in shows)
|
|
{
|
|
if (show.Id == null || cancellationToken.IsCancellationRequested)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
try
|
|
{
|
|
var latestUrn = await GetLatestVideoUrnForShowAsync(apiClient, businessUnit, show, topicId, cancellationToken).ConfigureAwait(false);
|
|
if (latestUrn != null)
|
|
{
|
|
urns.Add(latestUrn);
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogWarning(ex, "Error fetching videos for show {ShowId} in category {TopicId}", show.Id, topicId);
|
|
}
|
|
}
|
|
|
|
items = await ConvertUrnsToChannelItems(urns, cancellationToken).ConfigureAwait(false);
|
|
_logger.LogInformation("Found {Count} videos for category {TopicId}", items.Count, topicId);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Failed to load category videos");
|
|
}
|
|
|
|
return items;
|
|
}
|
|
|
|
private async Task<string?> GetLatestVideoUrnForShowAsync(
|
|
Api.SRFApiClient apiClient,
|
|
string businessUnit,
|
|
Api.Models.PlayV3Show show,
|
|
string topicId,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var videos = await apiClient.GetVideosForShowAsync(businessUnit, show.Id!, cancellationToken).ConfigureAwait(false);
|
|
if (videos == null || videos.Count == 0)
|
|
{
|
|
_logger.LogDebug("Category {TopicId}, Show {Show} ({ShowId}): No videos returned from API", topicId, show.Title, show.Id);
|
|
return null;
|
|
}
|
|
|
|
_logger.LogDebug("Category {TopicId}, Show {Show} ({ShowId}): Found {Count} videos", topicId, show.Title, show.Id, videos.Count);
|
|
|
|
// Filter to available videos
|
|
var now = DateTime.UtcNow;
|
|
var availableVideos = videos.Where(v =>
|
|
(v.ValidFrom == null || v.ValidFrom.Value.ToUniversalTime() <= now) &&
|
|
(v.ValidTo == null || v.ValidTo.Value.ToUniversalTime() > now)).ToList();
|
|
|
|
_logger.LogDebug("Category {TopicId}, Show {Show}: {AvailableCount} available out of {TotalCount} videos", topicId, show.Title, availableVideos.Count, videos.Count);
|
|
|
|
if (availableVideos.Count == 0)
|
|
{
|
|
_logger.LogDebug("Category {TopicId}, Show {Show}: No available videos (all expired or not yet published)", topicId, show.Title);
|
|
return null;
|
|
}
|
|
|
|
var latestVideo = availableVideos.OrderByDescending(v => v.Date).FirstOrDefault();
|
|
if (latestVideo?.Urn == null)
|
|
{
|
|
_logger.LogWarning("Category {TopicId}, Show {Show}: Latest available video has null URN", topicId, show.Title);
|
|
return null;
|
|
}
|
|
|
|
_logger.LogInformation(
|
|
"Category {TopicId}: Added video from show {Show}: {Title} (URN: {Urn}, Date: {Date})",
|
|
topicId,
|
|
show.Title,
|
|
latestVideo.Title,
|
|
latestVideo.Urn,
|
|
latestVideo.Date);
|
|
|
|
return latestVideo.Urn;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public string? GetCacheKey(string? userId)
|
|
{
|
|
var config = Plugin.Instance?.Configuration;
|
|
var enabledTopics = config?.EnabledTopics != null && config.EnabledTopics.Count > 0
|
|
? string.Join(",", config.EnabledTopics)
|
|
: "all";
|
|
|
|
// Use 15-minute time buckets for cache key so live content refreshes frequently
|
|
// This ensures livestream folders update as programs start/end throughout the day
|
|
var now = DateTime.Now;
|
|
var timeBucket = new DateTime(now.Year, now.Month, now.Day, now.Hour, (now.Minute / 15) * 15, 0);
|
|
var timeKey = timeBucket.ToString("yyyy-MM-dd-HH-mm", CultureInfo.InvariantCulture);
|
|
|
|
return $"{config?.BusinessUnit}_{config?.EnableLatestContent}_{config?.EnableTrendingContent}_{config?.EnableCategoryFolders}_{enabledTopics}_{timeKey}";
|
|
}
|
|
|
|
private async Task<List<ChannelItemInfo>> ConvertUrnsToChannelItems(List<string> urns, CancellationToken cancellationToken)
|
|
{
|
|
var items = new List<ChannelItemInfo>();
|
|
var config = Plugin.Instance?.Configuration;
|
|
|
|
if (config == null)
|
|
{
|
|
_logger.LogWarning("Plugin configuration is null");
|
|
return items;
|
|
}
|
|
|
|
_logger.LogInformation("Converting {Count} URNs to channel items", urns.Count);
|
|
|
|
using var apiClient = _apiClientFactory.CreateClient();
|
|
int successCount = 0;
|
|
int failedCount = 0;
|
|
int expiredCount = 0;
|
|
int noStreamCount = 0;
|
|
|
|
foreach (var urn in urns.Take(50)) // Limit to 50 items per request
|
|
{
|
|
try
|
|
{
|
|
_logger.LogDebug("Processing URN: {Urn}", urn);
|
|
var mediaComposition = await apiClient.GetMediaCompositionByUrnAsync(urn, cancellationToken).ConfigureAwait(false);
|
|
|
|
if (mediaComposition?.HasChapters != true)
|
|
{
|
|
_logger.LogWarning("URN {Urn}: No media composition or chapters found", urn);
|
|
failedCount++;
|
|
continue;
|
|
}
|
|
|
|
var chapter = mediaComposition.ChapterList[0];
|
|
|
|
// Check if content is expired
|
|
if (_streamResolver.IsContentExpired(chapter))
|
|
{
|
|
_logger.LogDebug("URN {Urn}: Content expired (ValidTo: {ValidTo})", urn, chapter.ValidTo);
|
|
expiredCount++;
|
|
continue;
|
|
}
|
|
|
|
// Check if content has playable streams
|
|
if (!_streamResolver.HasPlayableContent(chapter))
|
|
{
|
|
_logger.LogWarning("URN {Urn}: No playable content (likely DRM protected)", urn);
|
|
noStreamCount++;
|
|
continue;
|
|
}
|
|
|
|
// Generate deterministic GUID from URN
|
|
var itemId = UrnHelper.ToGuid(urn);
|
|
|
|
// Use factory to create MediaSourceInfo (handles stream URL, auth, proxy registration)
|
|
var mediaSource = await _mediaSourceFactory.CreateMediaSourceAsync(
|
|
chapter,
|
|
itemId,
|
|
urn,
|
|
config.QualityPreference,
|
|
cancellationToken).ConfigureAwait(false);
|
|
|
|
// Skip items without a valid media source (no stream URL available)
|
|
if (mediaSource == null)
|
|
{
|
|
if (chapter.Type == "SCHEDULED_LIVESTREAM")
|
|
{
|
|
_logger.LogDebug(
|
|
"URN {Urn}: Skipping upcoming livestream '{Title}' - stream not yet available (starts at {ValidFrom})",
|
|
urn,
|
|
chapter.Title,
|
|
chapter.ValidFrom);
|
|
}
|
|
else
|
|
{
|
|
_logger.LogWarning(
|
|
"URN {Urn}: Skipping '{Title}' - no valid stream URL available",
|
|
urn,
|
|
chapter.Title);
|
|
noStreamCount++;
|
|
}
|
|
|
|
continue;
|
|
}
|
|
|
|
// Build overview
|
|
var overview = chapter.Description ?? chapter.Lead;
|
|
|
|
// Determine image URL based on configuration
|
|
string? imageUrl;
|
|
var generateTitleCards = config.GenerateTitleCards;
|
|
|
|
if (generateTitleCards)
|
|
{
|
|
// Generate title card with content name
|
|
_logger.LogDebug("URN {Urn}: Generating title card for '{Title}'", urn, chapter.Title);
|
|
imageUrl = CreatePlaceholderImageUrl(chapter.Title ?? "SRF Play", _mediaSourceFactory.GetServerBaseUrl());
|
|
}
|
|
else
|
|
{
|
|
// Use SRF-provided images - prefer chapter image, fall back to show image
|
|
var originalImageUrl = chapter.ImageUrl;
|
|
if (string.IsNullOrEmpty(originalImageUrl) && mediaComposition.Show != null)
|
|
{
|
|
originalImageUrl = mediaComposition.Show.ImageUrl;
|
|
_logger.LogDebug("URN {Urn}: Using show image as fallback", urn);
|
|
}
|
|
|
|
if (string.IsNullOrEmpty(originalImageUrl))
|
|
{
|
|
_logger.LogDebug("URN {Urn}: No image URL available for '{Title}', using placeholder", urn, chapter.Title);
|
|
imageUrl = CreatePlaceholderImageUrl(chapter.Title ?? "SRF Play", _mediaSourceFactory.GetServerBaseUrl());
|
|
}
|
|
else
|
|
{
|
|
imageUrl = CreateProxiedImageUrl(originalImageUrl, _mediaSourceFactory.GetServerBaseUrl());
|
|
}
|
|
}
|
|
|
|
// Use ValidFrom for premiere date if this is a scheduled livestream, otherwise use Date
|
|
var premiereDate = chapter.Type == "SCHEDULED_LIVESTREAM" ? chapter.ValidFrom?.ToUniversalTime() : chapter.Date?.ToUniversalTime();
|
|
|
|
// Store authenticated URL - tokens refresh automatically via scheduled channel scans
|
|
var item = new ChannelItemInfo
|
|
{
|
|
Id = itemId,
|
|
Name = chapter.Title,
|
|
Overview = overview,
|
|
ImageUrl = imageUrl,
|
|
Type = ChannelItemType.Media,
|
|
ContentType = ChannelMediaContentType.Episode,
|
|
MediaType = ChannelMediaType.Video,
|
|
DateCreated = chapter.Date?.ToUniversalTime(),
|
|
PremiereDate = premiereDate,
|
|
ProductionYear = chapter.Date?.Year,
|
|
RunTimeTicks = chapter.Duration > 0 ? TimeSpan.FromMilliseconds(chapter.Duration).Ticks : null,
|
|
ProviderIds = new Dictionary<string, string>
|
|
{
|
|
{ "SRF", urn }
|
|
},
|
|
MediaSources = new List<MediaSourceInfo> { mediaSource }
|
|
};
|
|
|
|
// Add series info if available
|
|
if (mediaComposition.Show != null)
|
|
{
|
|
item.SeriesName = mediaComposition.Show.Title;
|
|
}
|
|
|
|
items.Add(item);
|
|
successCount++;
|
|
_logger.LogInformation("URN {Urn}: Successfully converted to channel item - {Title}", urn, chapter.Title);
|
|
_logger.LogDebug(
|
|
"URN {Urn}: MediaSource created via factory - DirectPlay={DirectPlay}, Transcoding={Transcoding}",
|
|
urn,
|
|
mediaSource.SupportsDirectPlay,
|
|
mediaSource.SupportsTranscoding);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Error converting URN {Urn} to channel item", urn);
|
|
failedCount++;
|
|
}
|
|
}
|
|
|
|
_logger.LogInformation(
|
|
"Conversion complete: {Success} successful, {Failed} failed, {Expired} expired, {NoStream} no stream",
|
|
successCount,
|
|
failedCount,
|
|
expiredCount,
|
|
noStreamCount);
|
|
|
|
return items;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Creates a proxy URL for an image to fix Content-Type headers from SRF CDN.
|
|
/// </summary>
|
|
/// <param name="originalUrl">The original SRF image URL.</param>
|
|
/// <param name="serverUrl">The server base URL.</param>
|
|
/// <returns>The proxied image URL, or null if original is null.</returns>
|
|
private static string? CreateProxiedImageUrl(string? originalUrl, string serverUrl)
|
|
{
|
|
if (string.IsNullOrEmpty(originalUrl))
|
|
{
|
|
return null;
|
|
}
|
|
|
|
// Encode the original URL as base64 for safe transport
|
|
var encodedUrl = Convert.ToBase64String(Encoding.UTF8.GetBytes(originalUrl));
|
|
return $"{serverUrl}/Plugins/SRFPlay/Proxy/Image?url={encodedUrl}";
|
|
}
|
|
|
|
/// <summary>
|
|
/// Creates a placeholder image URL with the given text.
|
|
/// </summary>
|
|
/// <param name="text">The text to display on the placeholder.</param>
|
|
/// <param name="serverUrl">The server base URL.</param>
|
|
/// <returns>The placeholder image URL.</returns>
|
|
private static string CreatePlaceholderImageUrl(string text, string serverUrl)
|
|
{
|
|
var encodedText = Convert.ToBase64String(Encoding.UTF8.GetBytes(text));
|
|
return $"{serverUrl}/Plugins/SRFPlay/Proxy/Placeholder?text={encodedText}";
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public bool IsEnabledFor(string userId)
|
|
{
|
|
return true;
|
|
}
|
|
}
|