first commit
This commit is contained in:
@@ -0,0 +1,206 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Plugin.SRFPlay.Api;
|
||||
using Jellyfin.Plugin.SRFPlay.Api.Models;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Jellyfin.Plugin.SRFPlay.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service for managing topic/category data and filtering.
|
||||
/// </summary>
|
||||
public class CategoryService
|
||||
{
|
||||
private readonly ILogger _logger;
|
||||
private readonly ILoggerFactory _loggerFactory;
|
||||
private readonly TimeSpan _topicsCacheDuration = TimeSpan.FromHours(24);
|
||||
private Dictionary<string, PlayV3Topic>? _topicsCache;
|
||||
private DateTime _topicsCacheExpiry = DateTime.MinValue;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="CategoryService"/> class.
|
||||
/// </summary>
|
||||
/// <param name="loggerFactory">The logger factory.</param>
|
||||
public CategoryService(ILoggerFactory loggerFactory)
|
||||
{
|
||||
_loggerFactory = loggerFactory;
|
||||
_logger = loggerFactory.CreateLogger<CategoryService>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets all topics for a business unit.
|
||||
/// </summary>
|
||||
/// <param name="businessUnit">The business unit.</param>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <returns>List of topics.</returns>
|
||||
public async Task<List<PlayV3Topic>> GetTopicsAsync(string businessUnit, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Return cached topics if still valid
|
||||
if (_topicsCache != null && DateTime.UtcNow < _topicsCacheExpiry)
|
||||
{
|
||||
_logger.LogDebug("Returning cached topics for business unit: {BusinessUnit}", businessUnit);
|
||||
return _topicsCache.Values.ToList();
|
||||
}
|
||||
|
||||
_logger.LogInformation("Fetching topics for business unit: {BusinessUnit}", businessUnit);
|
||||
using var apiClient = new SRFApiClient(_loggerFactory);
|
||||
var topics = await apiClient.GetAllTopicsAsync(businessUnit, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (topics != null && topics.Count > 0)
|
||||
{
|
||||
// Cache topics by ID for quick lookups
|
||||
_topicsCache = topics
|
||||
.Where(t => !string.IsNullOrEmpty(t.Id))
|
||||
.ToDictionary(t => t.Id!, t => t);
|
||||
_topicsCacheExpiry = DateTime.UtcNow.Add(_topicsCacheDuration);
|
||||
|
||||
_logger.LogInformation("Cached {Count} topics for business unit: {BusinessUnit}", _topicsCache.Count, businessUnit);
|
||||
}
|
||||
|
||||
return topics ?? new List<PlayV3Topic>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a topic by ID.
|
||||
/// </summary>
|
||||
/// <param name="topicId">The topic ID.</param>
|
||||
/// <param name="businessUnit">The business unit.</param>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <returns>The topic, or null if not found.</returns>
|
||||
public async Task<PlayV3Topic?> GetTopicByIdAsync(string topicId, string businessUnit, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Ensure topics are loaded
|
||||
if (_topicsCache == null || DateTime.UtcNow >= _topicsCacheExpiry)
|
||||
{
|
||||
await GetTopicsAsync(businessUnit, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return _topicsCache?.GetValueOrDefault(topicId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Filters shows by topic ID.
|
||||
/// </summary>
|
||||
/// <param name="shows">The shows to filter.</param>
|
||||
/// <param name="topicId">The topic ID to filter by.</param>
|
||||
/// <returns>Filtered list of shows.</returns>
|
||||
public IReadOnlyList<PlayV3Show> FilterShowsByTopic(IReadOnlyList<PlayV3Show> shows, string topicId)
|
||||
{
|
||||
if (string.IsNullOrEmpty(topicId))
|
||||
{
|
||||
return shows;
|
||||
}
|
||||
|
||||
return shows
|
||||
.Where(s => s.TopicList != null && s.TopicList.Contains(topicId))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Groups shows by their topics.
|
||||
/// </summary>
|
||||
/// <param name="shows">The shows to group.</param>
|
||||
/// <returns>Dictionary mapping topic IDs to shows.</returns>
|
||||
public IReadOnlyDictionary<string, List<PlayV3Show>> GroupShowsByTopics(IReadOnlyList<PlayV3Show> shows)
|
||||
{
|
||||
var groupedShows = new Dictionary<string, List<PlayV3Show>>();
|
||||
|
||||
foreach (var show in shows)
|
||||
{
|
||||
if (show.TopicList == null || show.TopicList.Count == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var topicId in show.TopicList)
|
||||
{
|
||||
if (!groupedShows.TryGetValue(topicId, out var showList))
|
||||
{
|
||||
showList = new List<PlayV3Show>();
|
||||
groupedShows[topicId] = showList;
|
||||
}
|
||||
|
||||
showList.Add(show);
|
||||
}
|
||||
}
|
||||
|
||||
return groupedShows;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets shows for a specific topic, sorted by number of episodes.
|
||||
/// </summary>
|
||||
/// <param name="topicId">The topic ID.</param>
|
||||
/// <param name="businessUnit">The business unit.</param>
|
||||
/// <param name="maxResults">Maximum number of results to return.</param>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <returns>List of shows for the topic.</returns>
|
||||
public async Task<List<PlayV3Show>> GetShowsByTopicAsync(
|
||||
string topicId,
|
||||
string businessUnit,
|
||||
int maxResults = 50,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var apiClient = new SRFApiClient(_loggerFactory);
|
||||
var allShows = await apiClient.GetAllShowsAsync(businessUnit, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (allShows == null || allShows.Count == 0)
|
||||
{
|
||||
_logger.LogWarning("No shows available for business unit: {BusinessUnit}", businessUnit);
|
||||
return new List<PlayV3Show>();
|
||||
}
|
||||
|
||||
var filteredShows = FilterShowsByTopic(allShows, topicId)
|
||||
.Where(s => s.NumberOfEpisodes > 0)
|
||||
.OrderByDescending(s => s.NumberOfEpisodes)
|
||||
.Take(maxResults)
|
||||
.ToList();
|
||||
|
||||
_logger.LogDebug("Found {Count} shows for topic {TopicId}", filteredShows.Count, topicId);
|
||||
return filteredShows;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets video count for each topic.
|
||||
/// </summary>
|
||||
/// <param name="shows">The shows to analyze.</param>
|
||||
/// <returns>Dictionary mapping topic IDs to video counts.</returns>
|
||||
public IReadOnlyDictionary<string, int> GetVideoCountByTopic(IReadOnlyList<PlayV3Show> shows)
|
||||
{
|
||||
var topicCounts = new Dictionary<string, int>();
|
||||
|
||||
foreach (var show in shows)
|
||||
{
|
||||
if (show.TopicList == null || show.TopicList.Count == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var topicId in show.TopicList)
|
||||
{
|
||||
if (!topicCounts.TryGetValue(topicId, out var count))
|
||||
{
|
||||
count = 0;
|
||||
}
|
||||
|
||||
topicCounts[topicId] = count + show.NumberOfEpisodes;
|
||||
}
|
||||
}
|
||||
|
||||
return topicCounts;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clears the topics cache.
|
||||
/// </summary>
|
||||
public void ClearCache()
|
||||
{
|
||||
_topicsCache = null;
|
||||
_topicsCacheExpiry = DateTime.MinValue;
|
||||
_logger.LogInformation("Topics cache cleared");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Plugin.SRFPlay.Api;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Model.IO;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Jellyfin.Plugin.SRFPlay.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service for managing content expiration.
|
||||
/// </summary>
|
||||
public class ContentExpirationService
|
||||
{
|
||||
private readonly ILogger<ContentExpirationService> _logger;
|
||||
private readonly ILoggerFactory _loggerFactory;
|
||||
private readonly ILibraryManager _libraryManager;
|
||||
private readonly StreamUrlResolver _streamResolver;
|
||||
private readonly MetadataCache _metadataCache;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ContentExpirationService"/> class.
|
||||
/// </summary>
|
||||
/// <param name="loggerFactory">The logger factory.</param>
|
||||
/// <param name="libraryManager">The library manager.</param>
|
||||
/// <param name="streamResolver">The stream URL resolver.</param>
|
||||
/// <param name="metadataCache">The metadata cache.</param>
|
||||
public ContentExpirationService(
|
||||
ILoggerFactory loggerFactory,
|
||||
ILibraryManager libraryManager,
|
||||
StreamUrlResolver streamResolver,
|
||||
MetadataCache metadataCache)
|
||||
{
|
||||
_loggerFactory = loggerFactory;
|
||||
_logger = loggerFactory.CreateLogger<ContentExpirationService>();
|
||||
_libraryManager = libraryManager;
|
||||
_streamResolver = streamResolver;
|
||||
_metadataCache = metadataCache;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks for expired content and removes it from the library.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <returns>The number of items removed.</returns>
|
||||
public async Task<int> CheckAndRemoveExpiredContentAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogInformation("Starting content expiration check");
|
||||
var removedCount = 0;
|
||||
|
||||
try
|
||||
{
|
||||
// Get all items with SRF provider ID
|
||||
var query = new InternalItemsQuery
|
||||
{
|
||||
HasAnyProviderId = new Dictionary<string, string> { { "SRF", string.Empty } },
|
||||
IsVirtualItem = false
|
||||
};
|
||||
|
||||
var items = _libraryManager.GetItemList(query);
|
||||
_logger.LogDebug("Found {Count} SRF items to check for expiration", items.Count);
|
||||
|
||||
foreach (var item in items)
|
||||
{
|
||||
if (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (await IsItemExpiredAsync(item, cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
_logger.LogInformation("Removing expired item: {Name} (URN: {Urn})", item.Name, item.ProviderIds.GetValueOrDefault("SRF"));
|
||||
|
||||
// Delete the item from library
|
||||
_libraryManager.DeleteItem(
|
||||
item,
|
||||
new DeleteOptions
|
||||
{
|
||||
DeleteFileLocation = false
|
||||
},
|
||||
false);
|
||||
|
||||
removedCount++;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error checking expiration for item: {Name}", item.Name);
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation("Content expiration check completed. Removed {Count} expired items", removedCount);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error during content expiration check");
|
||||
}
|
||||
|
||||
return removedCount;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if an item is expired.
|
||||
/// </summary>
|
||||
/// <param name="item">The item to check.</param>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <returns>True if the item is expired.</returns>
|
||||
private async Task<bool> IsItemExpiredAsync(BaseItem item, CancellationToken cancellationToken)
|
||||
{
|
||||
var urn = item.ProviderIds.GetValueOrDefault("SRF");
|
||||
if (string.IsNullOrEmpty(urn))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var config = Plugin.Instance?.Configuration;
|
||||
if (config == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// 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)
|
||||
{
|
||||
// If we can't fetch the content, consider it expired
|
||||
_logger.LogWarning("Could not fetch media composition for URN: {Urn}, treating as expired", urn);
|
||||
return true;
|
||||
}
|
||||
|
||||
var chapter = mediaComposition.ChapterList[0];
|
||||
var isExpired = _streamResolver.IsContentExpired(chapter);
|
||||
|
||||
if (isExpired)
|
||||
{
|
||||
_logger.LogDebug("Item {Name} is expired (ValidTo: {ValidTo})", item.Name, chapter.ValidTo);
|
||||
}
|
||||
|
||||
return isExpired;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets statistics about content expiration.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <returns>Tuple with total count, expired count, and items expiring soon.</returns>
|
||||
public async Task<(int Total, int Expired, int ExpiringSoon)> GetExpirationStatisticsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var total = 0;
|
||||
var expired = 0;
|
||||
var expiringSoon = 0;
|
||||
var soonThreshold = DateTime.UtcNow.AddDays(7); // Items expiring within 7 days
|
||||
|
||||
try
|
||||
{
|
||||
var query = new InternalItemsQuery
|
||||
{
|
||||
HasAnyProviderId = new Dictionary<string, string> { { "SRF", string.Empty } },
|
||||
IsVirtualItem = false
|
||||
};
|
||||
|
||||
var items = _libraryManager.GetItemList(query);
|
||||
total = items.Count;
|
||||
|
||||
foreach (var item in items)
|
||||
{
|
||||
if (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var urn = item.ProviderIds.GetValueOrDefault("SRF");
|
||||
if (string.IsNullOrEmpty(urn))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var config = Plugin.Instance?.Configuration;
|
||||
if (config == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var mediaComposition = _metadataCache.GetMediaComposition(urn, config.CacheDurationMinutes);
|
||||
|
||||
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];
|
||||
|
||||
if (_streamResolver.IsContentExpired(chapter))
|
||||
{
|
||||
expired++;
|
||||
}
|
||||
else if (chapter.ValidTo.HasValue && chapter.ValidTo.Value.ToUniversalTime() <= soonThreshold)
|
||||
{
|
||||
expiringSoon++;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error checking expiration statistics for item: {Name}", item.Name);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error getting expiration statistics");
|
||||
}
|
||||
|
||||
return (total, expired, expiringSoon);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
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.Api.Models;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Jellyfin.Plugin.SRFPlay.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service for refreshing content from SRF API.
|
||||
/// </summary>
|
||||
public class ContentRefreshService
|
||||
{
|
||||
private readonly ILogger<ContentRefreshService> _logger;
|
||||
private readonly ILoggerFactory _loggerFactory;
|
||||
private readonly MetadataCache _metadataCache;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ContentRefreshService"/> class.
|
||||
/// </summary>
|
||||
/// <param name="loggerFactory">The logger factory.</param>
|
||||
/// <param name="metadataCache">The metadata cache.</param>
|
||||
public ContentRefreshService(
|
||||
ILoggerFactory loggerFactory,
|
||||
MetadataCache metadataCache)
|
||||
{
|
||||
_loggerFactory = loggerFactory;
|
||||
_logger = loggerFactory.CreateLogger<ContentRefreshService>();
|
||||
_metadataCache = metadataCache;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Refreshes latest content from SRF API using Play v3.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <returns>List of URNs for new content.</returns>
|
||||
public async Task<List<string>> RefreshLatestContentAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var urns = new List<string>();
|
||||
|
||||
try
|
||||
{
|
||||
var config = Plugin.Instance?.Configuration;
|
||||
if (config == null || !config.EnableLatestContent)
|
||||
{
|
||||
_logger.LogDebug("Latest content refresh is disabled");
|
||||
return urns;
|
||||
}
|
||||
|
||||
_logger.LogInformation("Refreshing latest content for business unit: {BusinessUnit}", config.BusinessUnit);
|
||||
|
||||
using var apiClient = new SRFApiClient(_loggerFactory);
|
||||
var businessUnit = config.BusinessUnit.ToString().ToLowerInvariant();
|
||||
|
||||
// Get all shows from Play v3 API
|
||||
var shows = await apiClient.GetAllShowsAsync(businessUnit, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (shows == null || shows.Count == 0)
|
||||
{
|
||||
_logger.LogWarning("No shows found for business unit: {BusinessUnit}", config.BusinessUnit);
|
||||
return urns;
|
||||
}
|
||||
|
||||
_logger.LogInformation("Found {Count} shows, fetching latest episodes from each", shows.Count);
|
||||
|
||||
// Get latest episodes from each show (limit to 20 shows to avoid overwhelming)
|
||||
var showsToFetch = shows.Where(s => s.NumberOfEpisodes > 0)
|
||||
.OrderByDescending(s => s.NumberOfEpisodes)
|
||||
.Take(20)
|
||||
.ToList();
|
||||
|
||||
foreach (var show in showsToFetch)
|
||||
{
|
||||
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("Show {Show} ({ShowId}): Found {Count} videos", show.Title, show.Id, videos.Count);
|
||||
|
||||
// Filter to videos that are actually published (validFrom in the past)
|
||||
var now = DateTime.UtcNow;
|
||||
var publishedVideos = videos.Where(v =>
|
||||
v.ValidFrom == null || v.ValidFrom.Value.ToUniversalTime() <= now).ToList();
|
||||
|
||||
_logger.LogDebug("Show {Show}: {PublishedCount} published out of {TotalCount} videos", show.Title, publishedVideos.Count, videos.Count);
|
||||
|
||||
if (publishedVideos.Count > 0)
|
||||
{
|
||||
// Take only the most recent published video from each show
|
||||
var latestVideo = publishedVideos.OrderByDescending(v => v.Date).FirstOrDefault();
|
||||
if (latestVideo?.Urn != null)
|
||||
{
|
||||
urns.Add(latestVideo.Urn);
|
||||
_logger.LogInformation(
|
||||
"Added latest video from show {Show}: {Title} (URN: {Urn}, Date: {Date}, ValidFrom: {ValidFrom}, ValidTo: {ValidTo})",
|
||||
show.Title,
|
||||
latestVideo.Title,
|
||||
latestVideo.Urn,
|
||||
latestVideo.Date,
|
||||
latestVideo.ValidFrom,
|
||||
latestVideo.ValidTo);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning("Show {Show}: Latest video has null URN", show.Title);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogDebug("Show {Show} has no published videos yet", show.Title);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogDebug("Show {Show} ({ShowId}): No videos returned from API", show.Title, show.Id);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Error fetching videos for show {ShowId}", show.Id);
|
||||
}
|
||||
|
||||
// Respect cancellation
|
||||
if (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation("Refreshed {Count} latest content items from {ShowCount} shows", urns.Count, showsToFetch.Count);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error refreshing latest content");
|
||||
}
|
||||
|
||||
return urns;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Refreshes trending content from SRF API using Play v3.
|
||||
/// Gets videos from shows with the most episodes.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <returns>List of URNs for trending content.</returns>
|
||||
public async Task<List<string>> RefreshTrendingContentAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var urns = new List<string>();
|
||||
|
||||
try
|
||||
{
|
||||
var config = Plugin.Instance?.Configuration;
|
||||
if (config == null || !config.EnableTrendingContent)
|
||||
{
|
||||
_logger.LogDebug("Trending content refresh is disabled");
|
||||
return urns;
|
||||
}
|
||||
|
||||
_logger.LogInformation("Refreshing trending content for business unit: {BusinessUnit}", config.BusinessUnit);
|
||||
|
||||
using var apiClient = new SRFApiClient(_loggerFactory);
|
||||
var businessUnit = config.BusinessUnit.ToString().ToLowerInvariant();
|
||||
|
||||
// Get all shows from Play v3 API
|
||||
var shows = await apiClient.GetAllShowsAsync(businessUnit, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (shows == null || shows.Count == 0)
|
||||
{
|
||||
_logger.LogWarning("No shows found for business unit: {BusinessUnit}", config.BusinessUnit);
|
||||
return urns;
|
||||
}
|
||||
|
||||
_logger.LogInformation("Found {Count} shows, fetching popular content", shows.Count);
|
||||
|
||||
// Get videos from popular shows (those with many episodes)
|
||||
var popularShows = shows.Where(s => s.NumberOfEpisodes > 10)
|
||||
.OrderByDescending(s => s.NumberOfEpisodes)
|
||||
.Take(15)
|
||||
.ToList();
|
||||
|
||||
foreach (var show in popularShows)
|
||||
{
|
||||
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("Show {Show} ({ShowId}): Found {Count} videos for trending", show.Title, show.Id, videos.Count);
|
||||
|
||||
// Filter to videos that are actually published (validFrom in the past)
|
||||
var now = DateTime.UtcNow;
|
||||
var publishedVideos = videos.Where(v =>
|
||||
v.ValidFrom == null || v.ValidFrom.Value.ToUniversalTime() <= now).ToList();
|
||||
|
||||
_logger.LogDebug("Show {Show}: {PublishedCount} published out of {TotalCount} videos for trending", show.Title, publishedVideos.Count, videos.Count);
|
||||
|
||||
if (publishedVideos.Count > 0)
|
||||
{
|
||||
// Take 2 recent published videos from each popular show
|
||||
var recentVideos = publishedVideos.OrderByDescending(v => v.Date).Take(2);
|
||||
foreach (var video in recentVideos)
|
||||
{
|
||||
if (video.Urn != null)
|
||||
{
|
||||
urns.Add(video.Urn);
|
||||
_logger.LogInformation(
|
||||
"Added trending video from show {Show}: {Title} (URN: {Urn}, Date: {Date}, ValidFrom: {ValidFrom}, ValidTo: {ValidTo})",
|
||||
show.Title,
|
||||
video.Title,
|
||||
video.Urn,
|
||||
video.Date,
|
||||
video.ValidFrom,
|
||||
video.ValidTo);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning("Show {Show}: Trending video has null URN - {Title}", show.Title, video.Title);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogDebug("Show {Show} ({ShowId}): No videos returned from API for trending", show.Title, show.Id);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Error fetching videos for show {ShowId}", show.Id);
|
||||
}
|
||||
|
||||
// Respect cancellation
|
||||
if (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation("Refreshed {Count} trending content items from {ShowCount} shows", urns.Count, popularShows.Count);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error refreshing trending content");
|
||||
}
|
||||
|
||||
return urns;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Refreshes all content (latest and trending).
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <returns>Tuple with counts of latest and trending items.</returns>
|
||||
public async Task<(int LatestCount, int TrendingCount)> RefreshAllContentAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogInformation("Starting full content refresh");
|
||||
|
||||
var latestUrns = await RefreshLatestContentAsync(cancellationToken).ConfigureAwait(false);
|
||||
var trendingUrns = await RefreshTrendingContentAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var latestCount = latestUrns.Count;
|
||||
var trendingCount = trendingUrns.Count;
|
||||
|
||||
_logger.LogInformation(
|
||||
"Content refresh completed. Latest: {LatestCount}, Trending: {TrendingCount}",
|
||||
latestCount,
|
||||
trendingCount);
|
||||
|
||||
return (latestCount, trendingCount);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets content recommendations (combines latest and trending).
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <returns>List of recommended URNs.</returns>
|
||||
public async Task<List<string>> GetRecommendedContentAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var recommendations = new HashSet<string>();
|
||||
|
||||
var latestUrns = await RefreshLatestContentAsync(cancellationToken).ConfigureAwait(false);
|
||||
var trendingUrns = await RefreshTrendingContentAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
foreach (var urn in latestUrns.Concat(trendingUrns))
|
||||
{
|
||||
recommendations.Add(urn);
|
||||
}
|
||||
|
||||
_logger.LogInformation("Generated {Count} content recommendations", recommendations.Count);
|
||||
return recommendations.ToList();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Threading;
|
||||
using Jellyfin.Plugin.SRFPlay.Api.Models;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Jellyfin.Plugin.SRFPlay.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service for caching metadata from SRF API.
|
||||
/// </summary>
|
||||
public sealed class MetadataCache : IDisposable
|
||||
{
|
||||
private readonly ILogger<MetadataCache> _logger;
|
||||
private readonly ConcurrentDictionary<string, CacheEntry<MediaComposition>> _mediaCompositionCache;
|
||||
private readonly ReaderWriterLockSlim _lock;
|
||||
private bool _disposed;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="MetadataCache"/> class.
|
||||
/// </summary>
|
||||
/// <param name="logger">The logger instance.</param>
|
||||
public MetadataCache(ILogger<MetadataCache> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
_mediaCompositionCache = new ConcurrentDictionary<string, CacheEntry<MediaComposition>>();
|
||||
_lock = new ReaderWriterLockSlim();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Disposes resources.
|
||||
/// </summary>
|
||||
public void Dispose()
|
||||
{
|
||||
if (!_disposed)
|
||||
{
|
||||
_lock?.Dispose();
|
||||
_disposed = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets cached media composition by URN.
|
||||
/// </summary>
|
||||
/// <param name="urn">The URN.</param>
|
||||
/// <param name="cacheDurationMinutes">The cache duration in minutes.</param>
|
||||
/// <returns>The cached media composition, or null if not found or expired.</returns>
|
||||
public MediaComposition? GetMediaComposition(string urn, int cacheDurationMinutes)
|
||||
{
|
||||
if (string.IsNullOrEmpty(urn))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
_lock.EnterReadLock();
|
||||
try
|
||||
{
|
||||
if (_mediaCompositionCache.TryGetValue(urn, out var entry))
|
||||
{
|
||||
if (entry.IsValid(cacheDurationMinutes))
|
||||
{
|
||||
_logger.LogDebug("Cache hit for URN: {Urn}", urn);
|
||||
return entry.Value;
|
||||
}
|
||||
|
||||
_logger.LogDebug("Cache entry expired for URN: {Urn}", urn);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_lock.ExitReadLock();
|
||||
}
|
||||
}
|
||||
catch (ObjectDisposedException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets media composition in cache.
|
||||
/// </summary>
|
||||
/// <param name="urn">The URN.</param>
|
||||
/// <param name="mediaComposition">The media composition to cache.</param>
|
||||
public void SetMediaComposition(string urn, MediaComposition mediaComposition)
|
||||
{
|
||||
if (string.IsNullOrEmpty(urn) || mediaComposition == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
_lock.EnterWriteLock();
|
||||
try
|
||||
{
|
||||
var entry = new CacheEntry<MediaComposition>(mediaComposition);
|
||||
_mediaCompositionCache.AddOrUpdate(urn, entry, (key, oldValue) => entry);
|
||||
_logger.LogDebug("Cached media composition for URN: {Urn}", urn);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_lock.ExitWriteLock();
|
||||
}
|
||||
}
|
||||
catch (ObjectDisposedException)
|
||||
{
|
||||
// Cache is disposed, ignore
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes media composition from cache.
|
||||
/// </summary>
|
||||
/// <param name="urn">The URN.</param>
|
||||
public void RemoveMediaComposition(string urn)
|
||||
{
|
||||
if (string.IsNullOrEmpty(urn))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
_lock.EnterWriteLock();
|
||||
try
|
||||
{
|
||||
if (_mediaCompositionCache.TryRemove(urn, out _))
|
||||
{
|
||||
_logger.LogDebug("Removed cached media composition for URN: {Urn}", urn);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_lock.ExitWriteLock();
|
||||
}
|
||||
}
|
||||
catch (ObjectDisposedException)
|
||||
{
|
||||
// Cache is disposed, ignore
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clears all cached data.
|
||||
/// </summary>
|
||||
public void Clear()
|
||||
{
|
||||
try
|
||||
{
|
||||
_lock.EnterWriteLock();
|
||||
try
|
||||
{
|
||||
_mediaCompositionCache.Clear();
|
||||
_logger.LogInformation("Cleared metadata cache");
|
||||
}
|
||||
finally
|
||||
{
|
||||
_lock.ExitWriteLock();
|
||||
}
|
||||
}
|
||||
catch (ObjectDisposedException)
|
||||
{
|
||||
// Cache is disposed, ignore
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the cache statistics.
|
||||
/// </summary>
|
||||
/// <returns>A tuple with cache count and size estimate.</returns>
|
||||
public (int Count, long SizeEstimate) GetStatistics()
|
||||
{
|
||||
try
|
||||
{
|
||||
_lock.EnterReadLock();
|
||||
try
|
||||
{
|
||||
var count = _mediaCompositionCache.Count;
|
||||
// Rough estimate: average 50KB per entry
|
||||
var sizeEstimate = count * 50L * 1024;
|
||||
return (count, sizeEstimate);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_lock.ExitReadLock();
|
||||
}
|
||||
}
|
||||
catch (ObjectDisposedException)
|
||||
{
|
||||
return (0, 0);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a cached entry with timestamp.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The type of cached value.</typeparam>
|
||||
private sealed class CacheEntry<T>
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="CacheEntry{T}"/> class.
|
||||
/// </summary>
|
||||
/// <param name="value">The value to cache.</param>
|
||||
public CacheEntry(T value)
|
||||
{
|
||||
Value = value;
|
||||
Timestamp = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the cached value.
|
||||
/// </summary>
|
||||
public T Value { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the timestamp when the entry was created.
|
||||
/// </summary>
|
||||
public DateTime Timestamp { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Checks if the cache entry is still valid.
|
||||
/// </summary>
|
||||
/// <param name="cacheDurationMinutes">The cache duration in minutes.</param>
|
||||
/// <returns>True if the entry is still valid.</returns>
|
||||
public bool IsValid(int cacheDurationMinutes)
|
||||
{
|
||||
var expirationTime = Timestamp.AddMinutes(cacheDurationMinutes);
|
||||
return DateTime.UtcNow < expirationTime;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using Jellyfin.Plugin.SRFPlay.Api.Models;
|
||||
using Jellyfin.Plugin.SRFPlay.Configuration;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Jellyfin.Plugin.SRFPlay.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service for resolving stream URLs from media composition resources.
|
||||
/// </summary>
|
||||
public class StreamUrlResolver
|
||||
{
|
||||
private readonly ILogger<StreamUrlResolver> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="StreamUrlResolver"/> class.
|
||||
/// </summary>
|
||||
/// <param name="logger">The logger instance.</param>
|
||||
public StreamUrlResolver(ILogger<StreamUrlResolver> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the best stream URL from a chapter based on quality preference.
|
||||
/// </summary>
|
||||
/// <param name="chapter">The chapter containing resources.</param>
|
||||
/// <param name="qualityPreference">The quality preference.</param>
|
||||
/// <returns>The stream URL, or null if no suitable stream found.</returns>
|
||||
public string? GetStreamUrl(Chapter chapter, QualityPreference qualityPreference)
|
||||
{
|
||||
if (chapter?.ResourceList == null || chapter.ResourceList.Count == 0)
|
||||
{
|
||||
_logger.LogWarning("No resources found for chapter: {ChapterId}", chapter?.Id);
|
||||
return null;
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"Processing chapter {ChapterId} with {ResourceCount} resources",
|
||||
chapter.Id,
|
||||
chapter.ResourceList.Count);
|
||||
|
||||
// Filter out DRM-protected content
|
||||
var nonDrmResources = chapter.ResourceList
|
||||
.Where(r => r.DrmList == null || r.DrmList.ToString() == "[]")
|
||||
.ToList();
|
||||
|
||||
_logger.LogInformation(
|
||||
"Chapter {ChapterId}: Total resources={Total}, Non-DRM resources={NonDrm}",
|
||||
chapter.Id,
|
||||
chapter.ResourceList.Count,
|
||||
nonDrmResources.Count);
|
||||
|
||||
if (nonDrmResources.Count == 0)
|
||||
{
|
||||
_logger.LogWarning("All resources for chapter {ChapterId} require DRM", chapter.Id);
|
||||
// Log what DRM types are present
|
||||
foreach (var resource in chapter.ResourceList)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"DRM resource: Protocol={Protocol}, Streaming={Streaming}, DRM={Drm}",
|
||||
resource.Protocol,
|
||||
resource.Streaming,
|
||||
resource.DrmList);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Prefer HLS protocol
|
||||
var hlsResources = nonDrmResources
|
||||
.Where(r => string.Equals(r.Protocol, "HLS", StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(r.Streaming, "HLS", StringComparison.OrdinalIgnoreCase) ||
|
||||
r.Url.Contains(".m3u8", StringComparison.OrdinalIgnoreCase))
|
||||
.ToList();
|
||||
|
||||
_logger.LogInformation(
|
||||
"Chapter {ChapterId}: HLS resources found={HlsCount}",
|
||||
chapter.Id,
|
||||
hlsResources.Count);
|
||||
|
||||
if (hlsResources.Count == 0)
|
||||
{
|
||||
_logger.LogWarning("No HLS resources found for chapter: {ChapterId}", chapter.Id);
|
||||
// Log available protocols
|
||||
foreach (var resource in nonDrmResources)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Non-HLS resource: Protocol={Protocol}, Streaming={Streaming}, URL={Url}",
|
||||
resource.Protocol,
|
||||
resource.Streaming,
|
||||
resource.Url);
|
||||
}
|
||||
|
||||
// Fallback to any available non-DRM resource
|
||||
var fallbackResource = nonDrmResources.FirstOrDefault();
|
||||
if (fallbackResource != null)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Using fallback resource for chapter {ChapterId}: {Url}",
|
||||
chapter.Id,
|
||||
fallbackResource.Url);
|
||||
}
|
||||
|
||||
return fallbackResource?.Url;
|
||||
}
|
||||
|
||||
// Select based on quality preference
|
||||
Resource? selectedResource = qualityPreference switch
|
||||
{
|
||||
QualityPreference.HD => SelectHDResource(hlsResources) ?? SelectBestAvailableResource(hlsResources),
|
||||
QualityPreference.SD => SelectSDResource(hlsResources) ?? SelectBestAvailableResource(hlsResources),
|
||||
QualityPreference.Auto => SelectBestAvailableResource(hlsResources),
|
||||
_ => SelectBestAvailableResource(hlsResources)
|
||||
};
|
||||
|
||||
if (selectedResource != null)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Selected stream for chapter {ChapterId}: Quality={Quality}, Protocol={Protocol}, URL={Url}",
|
||||
chapter.Id,
|
||||
selectedResource.Quality,
|
||||
selectedResource.Protocol,
|
||||
selectedResource.Url);
|
||||
return selectedResource.Url;
|
||||
}
|
||||
|
||||
_logger.LogWarning("Could not select appropriate stream for chapter: {ChapterId}", chapter.Id);
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if a chapter has non-DRM playable content.
|
||||
/// </summary>
|
||||
/// <param name="chapter">The chapter to check.</param>
|
||||
/// <returns>True if playable content is available.</returns>
|
||||
public bool HasPlayableContent(Chapter chapter)
|
||||
{
|
||||
if (chapter?.ResourceList == null || chapter.ResourceList.Count == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return chapter.ResourceList.Any(r => r.DrmList == null || r.DrmList.ToString() == "[]");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if content is expired based on ValidTo date.
|
||||
/// </summary>
|
||||
/// <param name="chapter">The chapter to check.</param>
|
||||
/// <returns>True if the content is expired.</returns>
|
||||
public bool IsContentExpired(Chapter chapter)
|
||||
{
|
||||
if (chapter?.ValidTo == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return DateTime.UtcNow > chapter.ValidTo.Value.ToUniversalTime();
|
||||
}
|
||||
|
||||
private Resource? SelectHDResource(System.Collections.Generic.List<Resource> resources)
|
||||
{
|
||||
return resources.FirstOrDefault(r =>
|
||||
string.Equals(r.Quality, "HD", StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(r.Quality, "1080", StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(r.Quality, "720", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
private Resource? SelectSDResource(System.Collections.Generic.List<Resource> resources)
|
||||
{
|
||||
return resources.FirstOrDefault(r =>
|
||||
string.Equals(r.Quality, "SD", StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(r.Quality, "480", StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(r.Quality, "360", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
private Resource? SelectBestAvailableResource(System.Collections.Generic.List<Resource> resources)
|
||||
{
|
||||
// Try HD first
|
||||
var hdResource = SelectHDResource(resources);
|
||||
if (hdResource != null)
|
||||
{
|
||||
return hdResource;
|
||||
}
|
||||
|
||||
// Fall back to SD
|
||||
var sdResource = SelectSDResource(resources);
|
||||
if (sdResource != null)
|
||||
{
|
||||
return sdResource;
|
||||
}
|
||||
|
||||
// Return first available
|
||||
return resources.FirstOrDefault();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user