626 lines
25 KiB
C#
626 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.ToString().ToLowerInvariant();
|
|
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;
|
|
}
|
|
|
|
items.Add(CreateFolder($"category_{topic.Id}", topic.Title ?? topic.Id!, topic.Lead));
|
|
}
|
|
|
|
_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)
|
|
{
|
|
return new ChannelItemInfo
|
|
{
|
|
Id = id,
|
|
Name = name,
|
|
Type = ChannelItemType.Folder,
|
|
FolderType = ChannelFolderType.Container,
|
|
ImageUrl = null,
|
|
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.ToString().ToLowerInvariant() ?? "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.ToString().ToLowerInvariant() ?? "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?.ChapterList == null || mediaComposition.ChapterList.Count == 0)
|
|
{
|
|
_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;
|
|
}
|
|
}
|