first commit
This commit is contained in:
@@ -0,0 +1,463 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Plugin.SRFPlay.Services;
|
||||
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 ILoggerFactory _loggerFactory;
|
||||
private readonly ContentRefreshService _contentRefreshService;
|
||||
private readonly StreamUrlResolver _streamResolver;
|
||||
private readonly CategoryService? _categoryService;
|
||||
|
||||
/// <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="categoryService">The category service (optional).</param>
|
||||
public SRFPlayChannel(
|
||||
ILoggerFactory loggerFactory,
|
||||
ContentRefreshService contentRefreshService,
|
||||
StreamUrlResolver streamResolver,
|
||||
CategoryService? categoryService = null)
|
||||
{
|
||||
_loggerFactory = loggerFactory;
|
||||
_logger = loggerFactory.CreateLogger<SRFPlayChannel>();
|
||||
_contentRefreshService = contentRefreshService;
|
||||
_streamResolver = streamResolver;
|
||||
_categoryService = categoryService;
|
||||
|
||||
if (_categoryService == null)
|
||||
{
|
||||
_logger.LogWarning("CategoryService not available - category folders will be disabled");
|
||||
}
|
||||
|
||||
_logger.LogInformation("=== SRFPlayChannel constructor called! Channel is being instantiated ===");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Name => "SRF Play";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Description => "Swiss Radio and Television video-on-demand content";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string DataVersion => "1.0";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string HomePageUrl => "https://www.srf.ch/play";
|
||||
|
||||
/// <inheritdoc />
|
||||
public ChannelParentalRating ParentalRating => ChannelParentalRating.GeneralAudience;
|
||||
|
||||
/// <inheritdoc />
|
||||
public InternalChannelFeatures GetChannelFeatures()
|
||||
{
|
||||
_logger.LogInformation("=== GetChannelFeatures called for SRF Play channel ===");
|
||||
|
||||
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)
|
||||
{
|
||||
// Could provide a channel logo here
|
||||
return Task.FromResult(new DynamicImageResponse
|
||||
{
|
||||
HasImage = false
|
||||
});
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IEnumerable<ImageType> GetSupportedChannelImages()
|
||||
{
|
||||
return new List<ImageType>
|
||||
{
|
||||
ImageType.Primary,
|
||||
ImageType.Thumb
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<ChannelItemResult> GetChannelItems(InternalChannelItemQuery query, CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogInformation("=== GetChannelItems called! FolderId: {FolderId} ===", query.FolderId);
|
||||
|
||||
var items = new List<ChannelItemInfo>();
|
||||
var config = Plugin.Instance?.Configuration;
|
||||
|
||||
try
|
||||
{
|
||||
// Root level - show categories
|
||||
if (string.IsNullOrEmpty(query.FolderId))
|
||||
{
|
||||
items.Add(new ChannelItemInfo
|
||||
{
|
||||
Id = "latest",
|
||||
Name = "Latest Videos",
|
||||
Type = ChannelItemType.Folder,
|
||||
FolderType = ChannelFolderType.Container,
|
||||
ImageUrl = null
|
||||
});
|
||||
|
||||
items.Add(new ChannelItemInfo
|
||||
{
|
||||
Id = "trending",
|
||||
Name = "Trending Videos",
|
||||
Type = ChannelItemType.Folder,
|
||||
FolderType = ChannelFolderType.Container,
|
||||
ImageUrl = null
|
||||
});
|
||||
|
||||
// Add category folders if enabled and CategoryService is available
|
||||
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)))
|
||||
{
|
||||
// Filter by enabled topics if configured
|
||||
if (config.EnabledTopics != null && config.EnabledTopics.Count > 0 && !config.EnabledTopics.Contains(topic.Id!))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
items.Add(new ChannelItemInfo
|
||||
{
|
||||
Id = $"category_{topic.Id}",
|
||||
Name = topic.Title ?? topic.Id!,
|
||||
Type = ChannelItemType.Folder,
|
||||
FolderType = ChannelFolderType.Container,
|
||||
ImageUrl = null,
|
||||
Overview = 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 new ChannelItemResult
|
||||
{
|
||||
Items = items,
|
||||
TotalRecordCount = items.Count
|
||||
};
|
||||
}
|
||||
|
||||
// Latest videos
|
||||
if (query.FolderId == "latest")
|
||||
{
|
||||
var urns = await _contentRefreshService.RefreshLatestContentAsync(cancellationToken).ConfigureAwait(false);
|
||||
items = await ConvertUrnsToChannelItems(urns, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// Trending videos
|
||||
else if (query.FolderId == "trending")
|
||||
{
|
||||
var urns = await _contentRefreshService.RefreshTrendingContentAsync(cancellationToken).ConfigureAwait(false);
|
||||
items = await ConvertUrnsToChannelItems(urns, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// Category folder - show videos for this category
|
||||
else if (query.FolderId?.StartsWith("category_", StringComparison.Ordinal) == true)
|
||||
{
|
||||
if (_categoryService == null)
|
||||
{
|
||||
_logger.LogWarning("CategoryService not available - cannot display category folder");
|
||||
}
|
||||
else
|
||||
{
|
||||
try
|
||||
{
|
||||
var topicId = query.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 = new Api.SRFApiClient(_loggerFactory);
|
||||
|
||||
foreach (var show in shows)
|
||||
{
|
||||
if (show.Id == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var videos = await apiClient.GetVideosForShowAsync(businessUnit, show.Id, cancellationToken).ConfigureAwait(false);
|
||||
if (videos != null && videos.Count > 0)
|
||||
{
|
||||
_logger.LogDebug("Category {TopicId}, Show {Show} ({ShowId}): Found {Count} videos", topicId, show.Title, show.Id, videos.Count);
|
||||
|
||||
// Filter to videos that are actually published and not expired
|
||||
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)
|
||||
{
|
||||
// Get most recent available video from this show
|
||||
var latestVideo = availableVideos.OrderByDescending(v => v.Date).FirstOrDefault();
|
||||
if (latestVideo?.Urn != null)
|
||||
{
|
||||
urns.Add(latestVideo.Urn);
|
||||
_logger.LogInformation(
|
||||
"Category {TopicId}: Added video from show {Show}: {Title} (URN: {Urn}, Date: {Date}, ValidFrom: {ValidFrom}, ValidTo: {ValidTo})",
|
||||
topicId,
|
||||
show.Title,
|
||||
latestVideo.Title,
|
||||
latestVideo.Urn,
|
||||
latestVideo.Date,
|
||||
latestVideo.ValidFrom,
|
||||
latestVideo.ValidTo);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning("Category {TopicId}, Show {Show}: Latest available video has null URN", topicId, show.Title);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogDebug("Category {TopicId}, Show {Show}: No available videos (all expired or not yet published)", topicId, show.Title);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogDebug("Category {TopicId}, Show {Show} ({ShowId}): No videos returned from API", topicId, show.Title, show.Id);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Error fetching videos for show {ShowId} in category {TopicId}", show.Id, topicId);
|
||||
}
|
||||
|
||||
if (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation("Returning {Count} channel items for folder {FolderId}", items.Count, query.FolderId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error getting channel items for folder {FolderId}", query.FolderId);
|
||||
}
|
||||
|
||||
return new ChannelItemResult
|
||||
{
|
||||
Items = items,
|
||||
TotalRecordCount = items.Count
|
||||
};
|
||||
}
|
||||
|
||||
/// <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";
|
||||
return $"{config?.BusinessUnit}_{config?.EnableLatestContent}_{config?.EnableTrendingContent}_{config?.EnableCategoryFolders}_{enabledTopics}";
|
||||
}
|
||||
|
||||
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 = new Api.SRFApiClient(_loggerFactory);
|
||||
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 = UrnToGuid(urn);
|
||||
|
||||
var item = new ChannelItemInfo
|
||||
{
|
||||
Id = itemId,
|
||||
Name = chapter.Title,
|
||||
Overview = chapter.Description ?? chapter.Lead,
|
||||
ImageUrl = chapter.ImageUrl,
|
||||
Type = ChannelItemType.Media,
|
||||
ContentType = ChannelMediaContentType.Episode,
|
||||
MediaType = ChannelMediaType.Video,
|
||||
DateCreated = chapter.Date?.ToUniversalTime(),
|
||||
PremiereDate = chapter.Date?.ToUniversalTime(),
|
||||
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>
|
||||
{
|
||||
new MediaSourceInfo
|
||||
{
|
||||
Id = itemId,
|
||||
Name = chapter.Title,
|
||||
Path = _streamResolver.GetStreamUrl(chapter, config.QualityPreference),
|
||||
Protocol = MediaBrowser.Model.MediaInfo.MediaProtocol.Http,
|
||||
Container = "m3u8",
|
||||
SupportsDirectStream = true,
|
||||
SupportsDirectPlay = true,
|
||||
SupportsTranscoding = true,
|
||||
IsRemote = true,
|
||||
Type = MediaBrowser.Model.Dto.MediaSourceType.Default,
|
||||
VideoType = VideoType.VideoFile
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 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);
|
||||
}
|
||||
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>
|
||||
/// Generates a deterministic GUID from a URN.
|
||||
/// This ensures the same URN always produces the same GUID.
|
||||
/// MD5 is used for non-cryptographic purposes only (generating IDs).
|
||||
/// </summary>
|
||||
/// <param name="urn">The URN to convert.</param>
|
||||
/// <returns>A deterministic GUID.</returns>
|
||||
#pragma warning disable CA5351 // MD5 is used for non-cryptographic purposes (ID generation)
|
||||
private static string UrnToGuid(string urn)
|
||||
{
|
||||
// Use MD5 to generate a deterministic hash from the URN
|
||||
var hash = MD5.HashData(Encoding.UTF8.GetBytes(urn));
|
||||
|
||||
// Convert the first 16 bytes to a GUID
|
||||
var guid = new Guid(hash);
|
||||
return guid.ToString();
|
||||
}
|
||||
#pragma warning restore CA5351
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool IsEnabledFor(string userId)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user