A recording of a live event crashed partway through: when its short Akamai token expired, the proxy's stale-alias check swapped the recording's mapping to the most recently registered deferred stream, of any content. Segment requests were then built against the wrong CDN path, returned 403, and ffmpeg exited, splitting the recording into several files. Proxy: - Never swap URN-backed livestreams; they refresh themselves from the API. Only consider swap candidates with the same URN. Recording lifecycle: - Complete a recording once its stream has been gone from the API for 5 minutes (broadcasts can end before ValidTo). - Fail a recording still waiting for its stream after ValidTo (or 12h after ValidFrom) instead of retrying forever. - Wait for a DVR window whose start= is still in the future instead of starting ffmpeg against a 400/404. - Keep the original start time across restarts; don't mark a recording failed on shutdown cancellation. Growing .ts recordings: - ffmpeg writes MPEG-TS (-c copy -copyts) to stdout and the plugin appends it to one file per recording, so restarts continue the same file and timeline. - The Recordings folder lists any recording with a file on disk, not only completed ones. - In-progress recordings are exposed as infinite streams reading through a new endpoint that follows the growing file (same pattern as Jellyfin's DVR), so they can be watched from the start like a livestream buffer. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
737 lines
29 KiB
C#
737 lines
29 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.Api.Models;
|
|
using Jellyfin.Plugin.SRFPlay.Configuration;
|
|
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 MediaBrowser.Model.MediaInfo;
|
|
using Microsoft.Extensions.Logging;
|
|
|
|
namespace Jellyfin.Plugin.SRFPlay.Channels;
|
|
|
|
/// <summary>
|
|
/// Base channel for browsing and playing content from a single SRG SSR business unit.
|
|
/// One concrete subclass exists per unit (SRF, RTS, RSI, RTR, SWI), each shown as its own tile.
|
|
/// </summary>
|
|
public abstract class SrgChannelBase : IChannel, IHasCacheKey
|
|
{
|
|
private const string InProgressPrefix = "● REC · ";
|
|
|
|
private readonly ILogger<SrgChannelBase> _logger;
|
|
private readonly IContentRefreshService _contentRefreshService;
|
|
private readonly IStreamUrlResolver _streamResolver;
|
|
private readonly IMediaSourceFactory _mediaSourceFactory;
|
|
private readonly ICategoryService? _categoryService;
|
|
private readonly ISRFApiClientFactory _apiClientFactory;
|
|
private readonly IRecordingService _recordingService;
|
|
|
|
/// <summary>
|
|
/// Initializes a new instance of the <see cref="SrgChannelBase"/> 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>
|
|
/// <param name="recordingService">The recording service.</param>
|
|
protected SrgChannelBase(
|
|
ILoggerFactory loggerFactory,
|
|
IContentRefreshService contentRefreshService,
|
|
IStreamUrlResolver streamResolver,
|
|
IMediaSourceFactory mediaSourceFactory,
|
|
ICategoryService? categoryService,
|
|
ISRFApiClientFactory apiClientFactory,
|
|
IRecordingService recordingService)
|
|
{
|
|
_logger = loggerFactory.CreateLogger<SrgChannelBase>();
|
|
_contentRefreshService = contentRefreshService;
|
|
_streamResolver = streamResolver;
|
|
_mediaSourceFactory = mediaSourceFactory;
|
|
_categoryService = categoryService;
|
|
_apiClientFactory = apiClientFactory;
|
|
_recordingService = recordingService;
|
|
|
|
if (_categoryService == null)
|
|
{
|
|
_logger.LogWarning("CategoryService not available - category folders will be disabled");
|
|
}
|
|
|
|
_logger.LogDebug("SrgChannelBase initialized");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets the business unit this channel serves.
|
|
/// </summary>
|
|
protected abstract BusinessUnit Unit { get; }
|
|
|
|
/// <summary>
|
|
/// Gets the embedded resource name of this unit's logo PNG.
|
|
/// </summary>
|
|
protected abstract string LogoResourceName { get; }
|
|
|
|
/// <inheritdoc />
|
|
public string Name => $"{Unit} Play";
|
|
|
|
/// <inheritdoc />
|
|
public abstract string Description { get; }
|
|
|
|
/// <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(LogoResourceName)
|
|
?? 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),
|
|
"recordings" => GetRecordingItems(),
|
|
_ 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"),
|
|
CreateFolder("recordings", "Recordings")
|
|
};
|
|
|
|
// Add category folders if enabled
|
|
var config = Plugin.Instance?.Configuration;
|
|
if (config?.EnableCategoryFolders == true && _categoryService != null)
|
|
{
|
|
try
|
|
{
|
|
var businessUnit = Unit.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(Unit.ToLowerString(), cancellationToken).ConfigureAwait(false);
|
|
return await ConvertUrnsToChannelItems(urns, cancellationToken).ConfigureAwait(false);
|
|
}
|
|
|
|
private async Task<List<ChannelItemInfo>> GetTrendingVideosAsync(CancellationToken cancellationToken)
|
|
{
|
|
var urns = await _contentRefreshService.RefreshTrendingContentAsync(Unit.ToLowerString(), cancellationToken).ConfigureAwait(false);
|
|
return await ConvertUrnsToChannelItems(urns, cancellationToken).ConfigureAwait(false);
|
|
}
|
|
|
|
private async Task<List<ChannelItemInfo>> GetLiveSportsAsync(CancellationToken cancellationToken)
|
|
{
|
|
var items = new List<ChannelItemInfo>();
|
|
|
|
try
|
|
{
|
|
var businessUnit = Unit.ToLowerString();
|
|
|
|
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 = $"[{DisplayTime.From(eventTime):dd.MM HH:mm}] {matchingEvent.Title}";
|
|
item.PremiereDate = eventTime.ToUniversalTime();
|
|
}
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Failed to load live sports events");
|
|
}
|
|
|
|
return items;
|
|
}
|
|
|
|
private List<ChannelItemInfo> GetRecordingItems()
|
|
{
|
|
var items = new List<ChannelItemInfo>();
|
|
var unit = Unit.ToLowerString();
|
|
var serverBaseUrl = _mediaSourceFactory.GetServerBaseUrl();
|
|
|
|
foreach (var recording in _recordingService.GetPlayableRecordings())
|
|
{
|
|
// Only show recordings belonging to this unit. Recordings created before the
|
|
// BusinessUnit tag existed have an empty value and are shown everywhere so they
|
|
// are not lost.
|
|
if (!string.IsNullOrEmpty(recording.BusinessUnit) &&
|
|
!string.Equals(recording.BusinessUnit, unit, StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
var outputPath = recording.OutputPath!;
|
|
var itemId = $"recording_{recording.Id}";
|
|
var container = System.IO.Path.GetExtension(outputPath).TrimStart('.').ToLowerInvariant();
|
|
var name = recording.IsInProgress ? $"{InProgressPrefix}{recording.Title}" : recording.Title;
|
|
|
|
MediaSourceInfo mediaSource;
|
|
if (recording.IsInProgress)
|
|
{
|
|
// Same approach as Jellyfin's own DVR: the file is still growing, so treat it as a live
|
|
// stream and have ffmpeg read it through an endpoint that follows the file.
|
|
mediaSource = new MediaSourceInfo
|
|
{
|
|
Id = itemId,
|
|
Name = name,
|
|
Path = outputPath,
|
|
Protocol = MediaProtocol.File,
|
|
EncoderPath = $"{serverBaseUrl}/Plugins/SRFPlay/Recording/{recording.Id}/stream",
|
|
EncoderProtocol = MediaProtocol.Http,
|
|
Container = container,
|
|
SupportsDirectPlay = false,
|
|
SupportsDirectStream = true,
|
|
SupportsTranscoding = true,
|
|
IsInfiniteStream = true,
|
|
IgnoreDts = true,
|
|
IgnoreIndex = true,
|
|
IsRemote = false,
|
|
Type = MediaSourceType.Default
|
|
};
|
|
}
|
|
else
|
|
{
|
|
mediaSource = new MediaSourceInfo
|
|
{
|
|
Id = itemId,
|
|
Name = name,
|
|
Path = outputPath,
|
|
Protocol = MediaProtocol.File,
|
|
Container = container,
|
|
SupportsDirectPlay = true,
|
|
SupportsDirectStream = true,
|
|
SupportsTranscoding = true,
|
|
IsRemote = false,
|
|
Size = new System.IO.FileInfo(outputPath).Length,
|
|
Type = MediaSourceType.Default
|
|
};
|
|
}
|
|
|
|
var item = new ChannelItemInfo
|
|
{
|
|
Id = itemId,
|
|
Name = name,
|
|
Overview = recording.Description,
|
|
Type = ChannelItemType.Media,
|
|
ContentType = ChannelMediaContentType.Movie,
|
|
MediaType = ChannelMediaType.Video,
|
|
DateCreated = recording.RecordingStartedAt,
|
|
ImageUrl = !string.IsNullOrEmpty(recording.ImageUrl)
|
|
? CreateProxiedImageUrl(recording.ImageUrl, serverBaseUrl)
|
|
: CreatePlaceholderImageUrl(recording.Title, serverBaseUrl),
|
|
MediaSources = new List<MediaSourceInfo> { mediaSource }
|
|
};
|
|
|
|
items.Add(item);
|
|
}
|
|
|
|
_logger.LogInformation("Returning {Count} recordings as channel items", items.Count);
|
|
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 topicId = folderId.Substring("category_".Length);
|
|
var businessUnit = Unit.ToLowerString();
|
|
|
|
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);
|
|
|
|
// Include in-progress count so items switch from live to finished sources when a recording ends
|
|
var recordings = _recordingService.GetPlayableRecordings();
|
|
var recordingKey = $"{recordings.Count}_{recordings.Count(r => r.IsInProgress)}";
|
|
return $"{Unit}_{config?.EnableLatestContent}_{config?.EnableTrendingContent}_{config?.EnableCategoryFolders}_{enabledTopics}_{timeKey}_rec{recordingKey}";
|
|
}
|
|
|
|
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 }
|
|
}
|
|
};
|
|
|
|
// 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;
|
|
}
|
|
}
|