Compare commits

...

2 Commits

Author SHA1 Message Date
7a719ee4ac Feature: All bussiness units get a tile
All checks were successful
🏗️ Build Plugin / build (push) Successful in 43s
Latest Release / latest-release (push) Successful in 42s
🧪 Test Plugin / test (push) Successful in 29s
2026-06-27 11:00:07 +02:00
5667c2e12f Cache manageent for bussines units 2026-06-27 09:34:07 +02:00
21 changed files with 429 additions and 73 deletions

View File

@ -20,6 +20,13 @@ public class RecordingEntry
[JsonPropertyName("urn")] [JsonPropertyName("urn")]
public string Urn { get; set; } = string.Empty; public string Urn { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the business unit this recording belongs to (e.g. "srf", "rts").
/// Used to show recordings under the matching unit's channel.
/// </summary>
[JsonPropertyName("businessUnit")]
public string BusinessUnit { get; set; } = string.Empty;
/// <summary> /// <summary>
/// Gets or sets the title. /// Gets or sets the title.
/// </summary> /// </summary>

View File

@ -0,0 +1,43 @@
using Jellyfin.Plugin.SRFPlay.Api;
using Jellyfin.Plugin.SRFPlay.Configuration;
using Jellyfin.Plugin.SRFPlay.Services.Interfaces;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.SRFPlay.Channels;
/// <summary>
/// Radiotelevisione svizzera — Swiss Italian channel.
/// </summary>
public sealed class RsiChannel : SrgChannelBase
{
/// <summary>
/// Initializes a new instance of the <see cref="RsiChannel"/> 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.</param>
/// <param name="apiClientFactory">The API client factory.</param>
/// <param name="recordingService">The recording service.</param>
public RsiChannel(
ILoggerFactory loggerFactory,
IContentRefreshService contentRefreshService,
IStreamUrlResolver streamResolver,
IMediaSourceFactory mediaSourceFactory,
ICategoryService? categoryService,
ISRFApiClientFactory apiClientFactory,
IRecordingService recordingService)
: base(loggerFactory, contentRefreshService, streamResolver, mediaSourceFactory, categoryService, apiClientFactory, recordingService)
{
}
/// <inheritdoc />
protected override BusinessUnit Unit => BusinessUnit.RSI;
/// <inheritdoc />
protected override string LogoResourceName => "Jellyfin.Plugin.SRFPlay.Images.rsi-logo.png";
/// <inheritdoc />
public override string Description => "Radiotelevisione svizzera — Swiss Italian video-on-demand content";
}

View File

@ -0,0 +1,43 @@
using Jellyfin.Plugin.SRFPlay.Api;
using Jellyfin.Plugin.SRFPlay.Configuration;
using Jellyfin.Plugin.SRFPlay.Services.Interfaces;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.SRFPlay.Channels;
/// <summary>
/// Radiotelevisiun Svizra Rumantscha — Swiss Romansh channel.
/// </summary>
public sealed class RtrChannel : SrgChannelBase
{
/// <summary>
/// Initializes a new instance of the <see cref="RtrChannel"/> 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.</param>
/// <param name="apiClientFactory">The API client factory.</param>
/// <param name="recordingService">The recording service.</param>
public RtrChannel(
ILoggerFactory loggerFactory,
IContentRefreshService contentRefreshService,
IStreamUrlResolver streamResolver,
IMediaSourceFactory mediaSourceFactory,
ICategoryService? categoryService,
ISRFApiClientFactory apiClientFactory,
IRecordingService recordingService)
: base(loggerFactory, contentRefreshService, streamResolver, mediaSourceFactory, categoryService, apiClientFactory, recordingService)
{
}
/// <inheritdoc />
protected override BusinessUnit Unit => BusinessUnit.RTR;
/// <inheritdoc />
protected override string LogoResourceName => "Jellyfin.Plugin.SRFPlay.Images.rtr-logo.png";
/// <inheritdoc />
public override string Description => "Radiotelevisiun Svizra Rumantscha — Swiss Romansh video-on-demand content";
}

View File

@ -0,0 +1,43 @@
using Jellyfin.Plugin.SRFPlay.Api;
using Jellyfin.Plugin.SRFPlay.Configuration;
using Jellyfin.Plugin.SRFPlay.Services.Interfaces;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.SRFPlay.Channels;
/// <summary>
/// Radio Télévision Suisse — Swiss French channel.
/// </summary>
public sealed class RtsChannel : SrgChannelBase
{
/// <summary>
/// Initializes a new instance of the <see cref="RtsChannel"/> 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.</param>
/// <param name="apiClientFactory">The API client factory.</param>
/// <param name="recordingService">The recording service.</param>
public RtsChannel(
ILoggerFactory loggerFactory,
IContentRefreshService contentRefreshService,
IStreamUrlResolver streamResolver,
IMediaSourceFactory mediaSourceFactory,
ICategoryService? categoryService,
ISRFApiClientFactory apiClientFactory,
IRecordingService recordingService)
: base(loggerFactory, contentRefreshService, streamResolver, mediaSourceFactory, categoryService, apiClientFactory, recordingService)
{
}
/// <inheritdoc />
protected override BusinessUnit Unit => BusinessUnit.RTS;
/// <inheritdoc />
protected override string LogoResourceName => "Jellyfin.Plugin.SRFPlay.Images.rts-logo.png";
/// <inheritdoc />
public override string Description => "Radio Télévision Suisse — Swiss French video-on-demand content";
}

View File

@ -0,0 +1,43 @@
using Jellyfin.Plugin.SRFPlay.Api;
using Jellyfin.Plugin.SRFPlay.Configuration;
using Jellyfin.Plugin.SRFPlay.Services.Interfaces;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.SRFPlay.Channels;
/// <summary>
/// Schweizer Radio und Fernsehen — Swiss German channel.
/// </summary>
public sealed class SrfChannel : SrgChannelBase
{
/// <summary>
/// Initializes a new instance of the <see cref="SrfChannel"/> 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.</param>
/// <param name="apiClientFactory">The API client factory.</param>
/// <param name="recordingService">The recording service.</param>
public SrfChannel(
ILoggerFactory loggerFactory,
IContentRefreshService contentRefreshService,
IStreamUrlResolver streamResolver,
IMediaSourceFactory mediaSourceFactory,
ICategoryService? categoryService,
ISRFApiClientFactory apiClientFactory,
IRecordingService recordingService)
: base(loggerFactory, contentRefreshService, streamResolver, mediaSourceFactory, categoryService, apiClientFactory, recordingService)
{
}
/// <inheritdoc />
protected override BusinessUnit Unit => BusinessUnit.SRF;
/// <inheritdoc />
protected override string LogoResourceName => "Jellyfin.Plugin.SRFPlay.Images.srf-logo.png";
/// <inheritdoc />
public override string Description => "Schweizer Radio und Fernsehen — Swiss German video-on-demand content";
}

View File

@ -7,6 +7,7 @@ using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using Jellyfin.Plugin.SRFPlay.Api; using Jellyfin.Plugin.SRFPlay.Api;
using Jellyfin.Plugin.SRFPlay.Api.Models; using Jellyfin.Plugin.SRFPlay.Api.Models;
using Jellyfin.Plugin.SRFPlay.Configuration;
using Jellyfin.Plugin.SRFPlay.Constants; using Jellyfin.Plugin.SRFPlay.Constants;
using Jellyfin.Plugin.SRFPlay.Services.Interfaces; using Jellyfin.Plugin.SRFPlay.Services.Interfaces;
using Jellyfin.Plugin.SRFPlay.Utilities; using Jellyfin.Plugin.SRFPlay.Utilities;
@ -22,11 +23,12 @@ using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.SRFPlay.Channels; namespace Jellyfin.Plugin.SRFPlay.Channels;
/// <summary> /// <summary>
/// SRF Play channel for browsing and playing content. /// 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> /// </summary>
public class SRFPlayChannel : IChannel, IHasCacheKey public abstract class SrgChannelBase : IChannel, IHasCacheKey
{ {
private readonly ILogger<SRFPlayChannel> _logger; private readonly ILogger<SrgChannelBase> _logger;
private readonly IContentRefreshService _contentRefreshService; private readonly IContentRefreshService _contentRefreshService;
private readonly IStreamUrlResolver _streamResolver; private readonly IStreamUrlResolver _streamResolver;
private readonly IMediaSourceFactory _mediaSourceFactory; private readonly IMediaSourceFactory _mediaSourceFactory;
@ -35,7 +37,7 @@ public class SRFPlayChannel : IChannel, IHasCacheKey
private readonly IRecordingService _recordingService; private readonly IRecordingService _recordingService;
/// <summary> /// <summary>
/// Initializes a new instance of the <see cref="SRFPlayChannel"/> class. /// Initializes a new instance of the <see cref="SrgChannelBase"/> class.
/// </summary> /// </summary>
/// <param name="loggerFactory">The logger factory.</param> /// <param name="loggerFactory">The logger factory.</param>
/// <param name="contentRefreshService">The content refresh service.</param> /// <param name="contentRefreshService">The content refresh service.</param>
@ -44,7 +46,7 @@ public class SRFPlayChannel : IChannel, IHasCacheKey
/// <param name="categoryService">The category service (optional).</param> /// <param name="categoryService">The category service (optional).</param>
/// <param name="apiClientFactory">The API client factory.</param> /// <param name="apiClientFactory">The API client factory.</param>
/// <param name="recordingService">The recording service.</param> /// <param name="recordingService">The recording service.</param>
public SRFPlayChannel( protected SrgChannelBase(
ILoggerFactory loggerFactory, ILoggerFactory loggerFactory,
IContentRefreshService contentRefreshService, IContentRefreshService contentRefreshService,
IStreamUrlResolver streamResolver, IStreamUrlResolver streamResolver,
@ -53,7 +55,7 @@ public class SRFPlayChannel : IChannel, IHasCacheKey
ISRFApiClientFactory apiClientFactory, ISRFApiClientFactory apiClientFactory,
IRecordingService recordingService) IRecordingService recordingService)
{ {
_logger = loggerFactory.CreateLogger<SRFPlayChannel>(); _logger = loggerFactory.CreateLogger<SrgChannelBase>();
_contentRefreshService = contentRefreshService; _contentRefreshService = contentRefreshService;
_streamResolver = streamResolver; _streamResolver = streamResolver;
_mediaSourceFactory = mediaSourceFactory; _mediaSourceFactory = mediaSourceFactory;
@ -66,14 +68,24 @@ public class SRFPlayChannel : IChannel, IHasCacheKey
_logger.LogWarning("CategoryService not available - category folders will be disabled"); _logger.LogWarning("CategoryService not available - category folders will be disabled");
} }
_logger.LogDebug("SRFPlayChannel initialized"); _logger.LogDebug("SrgChannelBase initialized");
} }
/// <inheritdoc /> /// <summary>
public string Name => "SRF Play"; /// 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 /> /// <inheritdoc />
public string Description => "Swiss Radio and Television video-on-demand content"; public string Name => $"{Unit} Play";
/// <inheritdoc />
public abstract string Description { get; }
/// <inheritdoc /> /// <inheritdoc />
public string DataVersion => "2.0"; // Back to authenticating at channel refresh with auto-refresh for fresh tokens public string DataVersion => "2.0"; // Back to authenticating at channel refresh with auto-refresh for fresh tokens
@ -114,7 +126,8 @@ public class SRFPlayChannel : IChannel, IHasCacheKey
public Task<DynamicImageResponse> GetChannelImage(ImageType type, CancellationToken cancellationToken) public Task<DynamicImageResponse> GetChannelImage(ImageType type, CancellationToken cancellationToken)
{ {
var assembly = GetType().Assembly; var assembly = GetType().Assembly;
var resourceStream = assembly.GetManifestResourceStream("Jellyfin.Plugin.SRFPlay.Images.logo.png"); var resourceStream = assembly.GetManifestResourceStream(LogoResourceName)
?? assembly.GetManifestResourceStream("Jellyfin.Plugin.SRFPlay.Images.logo.png");
if (resourceStream == null) if (resourceStream == null)
{ {
@ -162,8 +175,21 @@ public class SRFPlayChannel : IChannel, IHasCacheKey
} }
} }
private bool IsUnitEnabled()
{
var config = Plugin.Instance?.Configuration;
return config == null || config.ResolveEnabledUnits().Contains(Unit);
}
private async Task<List<ChannelItemInfo>> GetFolderItemsAsync(string? folderId, CancellationToken cancellationToken) private async Task<List<ChannelItemInfo>> GetFolderItemsAsync(string? folderId, CancellationToken cancellationToken)
{ {
// If this unit is not enabled in configuration, show nothing (empty tile).
if (!IsUnitEnabled())
{
_logger.LogDebug("Business unit {Unit} is not enabled - returning no items", Unit);
return new List<ChannelItemInfo>();
}
// Root level - show folder list // Root level - show folder list
if (string.IsNullOrEmpty(folderId)) if (string.IsNullOrEmpty(folderId))
{ {
@ -198,7 +224,7 @@ public class SRFPlayChannel : IChannel, IHasCacheKey
{ {
try try
{ {
var businessUnit = config.BusinessUnit.ToLowerString(); var businessUnit = Unit.ToLowerString();
var topics = await _categoryService.GetTopicsAsync(businessUnit, cancellationToken).ConfigureAwait(false); var topics = await _categoryService.GetTopicsAsync(businessUnit, cancellationToken).ConfigureAwait(false);
foreach (var topic in topics.Where(t => !string.IsNullOrEmpty(t.Id))) foreach (var topic in topics.Where(t => !string.IsNullOrEmpty(t.Id)))
@ -239,24 +265,23 @@ public class SRFPlayChannel : IChannel, IHasCacheKey
private async Task<List<ChannelItemInfo>> GetLatestVideosAsync(CancellationToken cancellationToken) private async Task<List<ChannelItemInfo>> GetLatestVideosAsync(CancellationToken cancellationToken)
{ {
var urns = await _contentRefreshService.RefreshLatestContentAsync(cancellationToken).ConfigureAwait(false); var urns = await _contentRefreshService.RefreshLatestContentAsync(Unit.ToLowerString(), cancellationToken).ConfigureAwait(false);
return await ConvertUrnsToChannelItems(urns, cancellationToken).ConfigureAwait(false); return await ConvertUrnsToChannelItems(urns, cancellationToken).ConfigureAwait(false);
} }
private async Task<List<ChannelItemInfo>> GetTrendingVideosAsync(CancellationToken cancellationToken) private async Task<List<ChannelItemInfo>> GetTrendingVideosAsync(CancellationToken cancellationToken)
{ {
var urns = await _contentRefreshService.RefreshTrendingContentAsync(cancellationToken).ConfigureAwait(false); var urns = await _contentRefreshService.RefreshTrendingContentAsync(Unit.ToLowerString(), cancellationToken).ConfigureAwait(false);
return await ConvertUrnsToChannelItems(urns, cancellationToken).ConfigureAwait(false); return await ConvertUrnsToChannelItems(urns, cancellationToken).ConfigureAwait(false);
} }
private async Task<List<ChannelItemInfo>> GetLiveSportsAsync(CancellationToken cancellationToken) private async Task<List<ChannelItemInfo>> GetLiveSportsAsync(CancellationToken cancellationToken)
{ {
var items = new List<ChannelItemInfo>(); var items = new List<ChannelItemInfo>();
var config = Plugin.Instance?.Configuration;
try try
{ {
var businessUnit = config?.BusinessUnit.ToLowerString() ?? "srf"; var businessUnit = Unit.ToLowerString();
using var apiClient = _apiClientFactory.CreateClient(); using var apiClient = _apiClientFactory.CreateClient();
var scheduledLivestreams = await apiClient.GetScheduledLivestreamsAsync(businessUnit, "SPORT", cancellationToken).ConfigureAwait(false); var scheduledLivestreams = await apiClient.GetScheduledLivestreamsAsync(businessUnit, "SPORT", cancellationToken).ConfigureAwait(false);
@ -307,6 +332,7 @@ public class SRFPlayChannel : IChannel, IHasCacheKey
private List<ChannelItemInfo> GetRecordingItems() private List<ChannelItemInfo> GetRecordingItems()
{ {
var items = new List<ChannelItemInfo>(); var items = new List<ChannelItemInfo>();
var unit = Unit.ToLowerString();
var recordings = _recordingService.GetRecordings(RecordingState.Completed); var recordings = _recordingService.GetRecordings(RecordingState.Completed);
foreach (var recording in recordings) foreach (var recording in recordings)
@ -316,6 +342,15 @@ public class SRFPlayChannel : IChannel, IHasCacheKey
continue; continue;
} }
// 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 fileInfo = new System.IO.FileInfo(recording.OutputPath); var fileInfo = new System.IO.FileInfo(recording.OutputPath);
var itemId = $"recording_{recording.Id}"; var itemId = $"recording_{recording.Id}";
@ -368,9 +403,8 @@ public class SRFPlayChannel : IChannel, IHasCacheKey
try try
{ {
var config = Plugin.Instance?.Configuration;
var topicId = folderId.Substring("category_".Length); var topicId = folderId.Substring("category_".Length);
var businessUnit = config?.BusinessUnit.ToLowerString() ?? "srf"; var businessUnit = Unit.ToLowerString();
var shows = await _categoryService.GetShowsByTopicAsync(topicId, businessUnit, 20, cancellationToken).ConfigureAwait(false); var shows = await _categoryService.GetShowsByTopicAsync(topicId, businessUnit, 20, cancellationToken).ConfigureAwait(false);
var urns = new List<string>(); var urns = new List<string>();
@ -472,7 +506,7 @@ public class SRFPlayChannel : IChannel, IHasCacheKey
var timeKey = timeBucket.ToString("yyyy-MM-dd-HH-mm", CultureInfo.InvariantCulture); var timeKey = timeBucket.ToString("yyyy-MM-dd-HH-mm", CultureInfo.InvariantCulture);
var recordingCount = _recordingService.GetRecordings(RecordingState.Completed).Count; var recordingCount = _recordingService.GetRecordings(RecordingState.Completed).Count;
return $"{config?.BusinessUnit}_{config?.EnableLatestContent}_{config?.EnableTrendingContent}_{config?.EnableCategoryFolders}_{enabledTopics}_{timeKey}_rec{recordingCount}"; return $"{Unit}_{config?.EnableLatestContent}_{config?.EnableTrendingContent}_{config?.EnableCategoryFolders}_{enabledTopics}_{timeKey}_rec{recordingCount}";
} }
private async Task<List<ChannelItemInfo>> ConvertUrnsToChannelItems(List<string> urns, CancellationToken cancellationToken) private async Task<List<ChannelItemInfo>> ConvertUrnsToChannelItems(List<string> urns, CancellationToken cancellationToken)

View File

@ -0,0 +1,43 @@
using Jellyfin.Plugin.SRFPlay.Api;
using Jellyfin.Plugin.SRFPlay.Configuration;
using Jellyfin.Plugin.SRFPlay.Services.Interfaces;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.SRFPlay.Channels;
/// <summary>
/// SWI swissinfo.ch — Swiss international channel.
/// </summary>
public sealed class SwiChannel : SrgChannelBase
{
/// <summary>
/// Initializes a new instance of the <see cref="SwiChannel"/> 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.</param>
/// <param name="apiClientFactory">The API client factory.</param>
/// <param name="recordingService">The recording service.</param>
public SwiChannel(
ILoggerFactory loggerFactory,
IContentRefreshService contentRefreshService,
IStreamUrlResolver streamResolver,
IMediaSourceFactory mediaSourceFactory,
ICategoryService? categoryService,
ISRFApiClientFactory apiClientFactory,
IRecordingService recordingService)
: base(loggerFactory, contentRefreshService, streamResolver, mediaSourceFactory, categoryService, apiClientFactory, recordingService)
{
}
/// <inheritdoc />
protected override BusinessUnit Unit => BusinessUnit.SWI;
/// <inheritdoc />
protected override string LogoResourceName => "Jellyfin.Plugin.SRFPlay.Images.swi-logo.png";
/// <inheritdoc />
public override string Description => "SWI swissinfo.ch — Swiss international video-on-demand content";
}

View File

@ -66,6 +66,7 @@ public class PluginConfiguration : BasePluginConfiguration
{ {
// Set default options // Set default options
BusinessUnit = BusinessUnit.SRF; BusinessUnit = BusinessUnit.SRF;
EnabledBusinessUnits = new System.Collections.Generic.List<BusinessUnit> { BusinessUnit.SRF };
QualityPreference = QualityPreference.Auto; QualityPreference = QualityPreference.Auto;
ContentRefreshIntervalHours = 6; ContentRefreshIntervalHours = 6;
ExpirationCheckIntervalHours = 24; ExpirationCheckIntervalHours = 24;
@ -78,10 +79,20 @@ public class PluginConfiguration : BasePluginConfiguration
} }
/// <summary> /// <summary>
/// Gets or sets the business unit to fetch content from. /// Gets or sets the legacy single business unit. Retained for backwards compatibility and
/// migration into <see cref="EnabledBusinessUnits"/>; new code should use the list instead.
/// </summary> /// </summary>
public BusinessUnit BusinessUnit { get; set; } public BusinessUnit BusinessUnit { get; set; }
/// <summary>
/// Gets or sets the list of business units to expose as channels. One channel tile is shown
/// per enabled unit, so polylingual households can browse e.g. SRF (German) and RTS (French)
/// at the same time.
/// </summary>
[System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "CA2227:Collection properties should be read only", Justification = "Required for configuration serialization")]
[System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "CA1002:Do not expose generic lists", Justification = "Configuration DTO")]
public System.Collections.Generic.List<BusinessUnit> EnabledBusinessUnits { get; set; }
/// <summary> /// <summary>
/// Gets or sets the preferred video quality. /// Gets or sets the preferred video quality.
/// </summary> /// </summary>
@ -161,4 +172,20 @@ public class PluginConfiguration : BasePluginConfiguration
/// Gets or sets the output directory for sport livestream recordings. /// Gets or sets the output directory for sport livestream recordings.
/// </summary> /// </summary>
public string RecordingOutputPath { get; set; } = string.Empty; public string RecordingOutputPath { get; set; } = string.Empty;
/// <summary>
/// Resolves the effective set of enabled business units, migrating from the legacy single
/// <see cref="BusinessUnit"/> value when the list has not been populated yet.
/// </summary>
/// <returns>The business units that should have channels.</returns>
public System.Collections.Generic.IReadOnlyList<BusinessUnit> ResolveEnabledUnits()
{
if (EnabledBusinessUnits != null && EnabledBusinessUnits.Count > 0)
{
return EnabledBusinessUnits;
}
// Legacy installs only had a single BusinessUnit; honour it so they keep working.
return new System.Collections.Generic.List<BusinessUnit> { BusinessUnit };
}
} }

View File

@ -2,16 +2,29 @@
<div data-role="content"> <div data-role="content">
<div class="content-primary"> <div class="content-primary">
<form id="SRFPlayConfigForm"> <form id="SRFPlayConfigForm">
<div class="selectContainer"> <div class="checkboxContainer checkboxContainer-withDescription">
<label class="selectLabel" for="BusinessUnit">Business Unit</label> <h3>Channels (Business Units)</h3>
<select is="emby-select" id="BusinessUnit" name="BusinessUnit" class="emby-select-withcolor emby-select"> <div class="fieldDescription" style="margin-bottom: 0.5em;">Enable a channel tile for each Swiss broadcaster you want. A separate tile appears per enabled unit, so polylingual households can browse e.g. SRF (German) and RTS (French) at the same time.</div>
<option id="optSRF" value="SRF">SRF (German)</option> <label class="emby-checkbox-label">
<option id="optRTS" value="RTS">RTS (French)</option> <input id="buSRF" type="checkbox" is="emby-checkbox" data-bu="SRF" />
<option id="optRSI" value="RSI">RSI (Italian)</option> <span>SRF — Schweizer Radio und Fernsehen (German)</span>
<option id="optRTR" value="RTR">RTR (Romansh)</option> </label>
<option id="optSWI" value="SWI">SWI (International)</option> <label class="emby-checkbox-label">
</select> <input id="buRTS" type="checkbox" is="emby-checkbox" data-bu="RTS" />
<div class="fieldDescription">Select the Swiss broadcasting unit to fetch content from</div> <span>RTS — Radio Télévision Suisse (French)</span>
</label>
<label class="emby-checkbox-label">
<input id="buRSI" type="checkbox" is="emby-checkbox" data-bu="RSI" />
<span>RSI — Radiotelevisione svizzera (Italian)</span>
</label>
<label class="emby-checkbox-label">
<input id="buRTR" type="checkbox" is="emby-checkbox" data-bu="RTR" />
<span>RTR — Radiotelevisiun Svizra Rumantscha (Romansh)</span>
</label>
<label class="emby-checkbox-label">
<input id="buSWI" type="checkbox" is="emby-checkbox" data-bu="SWI" />
<span>SWI — swissinfo.ch (International)</span>
</label>
</div> </div>
<div class="selectContainer"> <div class="selectContainer">
<label class="selectLabel" for="QualityPreference">Quality Preference</label> <label class="selectLabel" for="QualityPreference">Quality Preference</label>
@ -142,7 +155,12 @@
.addEventListener('pageshow', function() { .addEventListener('pageshow', function() {
Dashboard.showLoadingMsg(); Dashboard.showLoadingMsg();
ApiClient.getPluginConfiguration(SRFPlayConfig.pluginUniqueId).then(function (config) { ApiClient.getPluginConfiguration(SRFPlayConfig.pluginUniqueId).then(function (config) {
document.querySelector('#BusinessUnit').value = config.BusinessUnit; var enabled = (config.EnabledBusinessUnits && config.EnabledBusinessUnits.length)
? config.EnabledBusinessUnits
: [config.BusinessUnit]; // migrate legacy single value
['SRF','RTS','RSI','RTR','SWI'].forEach(function(bu) {
document.querySelector('#bu' + bu).checked = enabled.indexOf(bu) !== -1;
});
document.querySelector('#QualityPreference').value = config.QualityPreference; document.querySelector('#QualityPreference').value = config.QualityPreference;
document.querySelector('#ContentRefreshIntervalHours').value = config.ContentRefreshIntervalHours; document.querySelector('#ContentRefreshIntervalHours').value = config.ContentRefreshIntervalHours;
document.querySelector('#ExpirationCheckIntervalHours').value = config.ExpirationCheckIntervalHours; document.querySelector('#ExpirationCheckIntervalHours').value = config.ExpirationCheckIntervalHours;
@ -168,7 +186,11 @@
.addEventListener('submit', function(e) { .addEventListener('submit', function(e) {
Dashboard.showLoadingMsg(); Dashboard.showLoadingMsg();
ApiClient.getPluginConfiguration(SRFPlayConfig.pluginUniqueId).then(function (config) { ApiClient.getPluginConfiguration(SRFPlayConfig.pluginUniqueId).then(function (config) {
config.BusinessUnit = document.querySelector('#BusinessUnit').value; config.EnabledBusinessUnits = ['SRF','RTS','RSI','RTR','SWI'].filter(function(bu) {
return document.querySelector('#bu' + bu).checked;
});
// Keep legacy field in sync with the first enabled unit for backwards compat.
config.BusinessUnit = config.EnabledBusinessUnits[0] || 'SRF';
config.QualityPreference = document.querySelector('#QualityPreference').value; config.QualityPreference = document.querySelector('#QualityPreference').value;
config.ContentRefreshIntervalHours = parseInt(document.querySelector('#ContentRefreshIntervalHours').value); config.ContentRefreshIntervalHours = parseInt(document.querySelector('#ContentRefreshIntervalHours').value);
config.ExpirationCheckIntervalHours = parseInt(document.querySelector('#ExpirationCheckIntervalHours').value); config.ExpirationCheckIntervalHours = parseInt(document.querySelector('#ExpirationCheckIntervalHours').value);

View File

@ -33,6 +33,11 @@
<ItemGroup> <ItemGroup>
<None Remove="..\assests\main logo.png" /> <None Remove="..\assests\main logo.png" />
<EmbeddedResource Include="..\assests\main logo.png" LogicalName="Jellyfin.Plugin.SRFPlay.Images.logo.png" /> <EmbeddedResource Include="..\assests\main logo.png" LogicalName="Jellyfin.Plugin.SRFPlay.Images.logo.png" />
<EmbeddedResource Include="..\assests\units\srf-logo.png" LogicalName="Jellyfin.Plugin.SRFPlay.Images.srf-logo.png" />
<EmbeddedResource Include="..\assests\units\rts-logo.png" LogicalName="Jellyfin.Plugin.SRFPlay.Images.rts-logo.png" />
<EmbeddedResource Include="..\assests\units\rsi-logo.png" LogicalName="Jellyfin.Plugin.SRFPlay.Images.rsi-logo.png" />
<EmbeddedResource Include="..\assests\units\rtr-logo.png" LogicalName="Jellyfin.Plugin.SRFPlay.Images.rtr-logo.png" />
<EmbeddedResource Include="..\assests\units\swi-logo.png" LogicalName="Jellyfin.Plugin.SRFPlay.Images.swi-logo.png" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View File

@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using Jellyfin.Plugin.SRFPlay.Services.Interfaces; using Jellyfin.Plugin.SRFPlay.Services.Interfaces;
using Jellyfin.Plugin.SRFPlay.Utilities;
using MediaBrowser.Model.Tasks; using MediaBrowser.Model.Tasks;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
@ -63,20 +64,25 @@ public class ContentRefreshTask : IScheduledTask
return; return;
} }
// Refresh latest content // Refresh content for every enabled business unit (one channel per unit).
var units = config.ResolveEnabledUnits();
foreach (var unit in units)
{
var businessUnit = unit.ToLowerString();
if (config.EnableLatestContent) if (config.EnableLatestContent)
{ {
_logger.LogInformation("Refreshing latest content"); _logger.LogInformation("Refreshing latest content for {BusinessUnit}", businessUnit);
progress?.Report(25); progress?.Report(25);
await _contentRefreshService.RefreshLatestContentAsync(cancellationToken).ConfigureAwait(false); await _contentRefreshService.RefreshLatestContentAsync(businessUnit, cancellationToken).ConfigureAwait(false);
} }
// Refresh trending content
if (config.EnableTrendingContent) if (config.EnableTrendingContent)
{ {
_logger.LogInformation("Refreshing trending content"); _logger.LogInformation("Refreshing trending content for {BusinessUnit}", businessUnit);
progress?.Report(75); progress?.Report(75);
await _contentRefreshService.RefreshTrendingContentAsync(cancellationToken).ConfigureAwait(false); await _contentRefreshService.RefreshTrendingContentAsync(businessUnit, cancellationToken).ConfigureAwait(false);
}
} }
progress?.Report(100); progress?.Report(100);

View File

@ -49,7 +49,13 @@ public class ServiceRegistrator : IPluginServiceRegistrator
serviceCollection.AddSingleton<IScheduledTask, ExpirationCheckTask>(); serviceCollection.AddSingleton<IScheduledTask, ExpirationCheckTask>();
serviceCollection.AddSingleton<IScheduledTask, RecordingSchedulerTask>(); serviceCollection.AddSingleton<IScheduledTask, RecordingSchedulerTask>();
// Register channel - must register as IChannel interface for Jellyfin to discover it // Register one channel (tile) per SRG business unit. Each must be registered as IChannel
serviceCollection.AddSingleton<IChannel, SRFPlayChannel>(); // for Jellyfin to discover it. A unit that is not in EnabledBusinessUnits returns no
// content (its tile appears empty) - see SrgChannelBase.
serviceCollection.AddSingleton<IChannel, SrfChannel>();
serviceCollection.AddSingleton<IChannel, RtsChannel>();
serviceCollection.AddSingleton<IChannel, RsiChannel>();
serviceCollection.AddSingleton<IChannel, RtrChannel>();
serviceCollection.AddSingleton<IChannel, SwiChannel>();
} }
} }

View File

@ -18,8 +18,10 @@ public class CategoryService : ICategoryService
private readonly ILogger<CategoryService> _logger; private readonly ILogger<CategoryService> _logger;
private readonly ISRFApiClientFactory _apiClientFactory; private readonly ISRFApiClientFactory _apiClientFactory;
private readonly TimeSpan _topicsCacheDuration = TimeSpan.FromHours(24); private readonly TimeSpan _topicsCacheDuration = TimeSpan.FromHours(24);
private Dictionary<string, PlayV3Topic>? _topicsCache;
private DateTime _topicsCacheExpiry = DateTime.MinValue; // Cache topics per business unit. A single shared cache would return one unit's
// topics (e.g. German SRF) even after the user switched to another (e.g. French RTS).
private readonly Dictionary<string, (Dictionary<string, PlayV3Topic> Topics, DateTime Expiry)> _topicsCacheByUnit = new(StringComparer.OrdinalIgnoreCase);
/// <summary> /// <summary>
/// Initializes a new instance of the <see cref="CategoryService"/> class. /// Initializes a new instance of the <see cref="CategoryService"/> class.
@ -35,11 +37,11 @@ public class CategoryService : ICategoryService
/// <inheritdoc /> /// <inheritdoc />
public async Task<List<PlayV3Topic>> GetTopicsAsync(string businessUnit, CancellationToken cancellationToken = default) public async Task<List<PlayV3Topic>> GetTopicsAsync(string businessUnit, CancellationToken cancellationToken = default)
{ {
// Return cached topics if still valid // Return cached topics for this business unit if still valid
if (_topicsCache != null && DateTime.UtcNow < _topicsCacheExpiry) if (_topicsCacheByUnit.TryGetValue(businessUnit, out var cached) && DateTime.UtcNow < cached.Expiry)
{ {
_logger.LogDebug("Returning cached topics for business unit: {BusinessUnit}", businessUnit); _logger.LogDebug("Returning cached topics for business unit: {BusinessUnit}", businessUnit);
return _topicsCache.Values.ToList(); return cached.Topics.Values.ToList();
} }
_logger.LogInformation("Fetching topics for business unit: {BusinessUnit}", businessUnit); _logger.LogInformation("Fetching topics for business unit: {BusinessUnit}", businessUnit);
@ -48,13 +50,13 @@ public class CategoryService : ICategoryService
if (topics != null && topics.Count > 0) if (topics != null && topics.Count > 0)
{ {
// Cache topics by ID for quick lookups // Cache topics by ID for quick lookups, scoped to this business unit
_topicsCache = topics var byId = topics
.Where(t => !string.IsNullOrEmpty(t.Id)) .Where(t => !string.IsNullOrEmpty(t.Id))
.ToDictionary(t => t.Id!, t => t); .ToDictionary(t => t.Id!, t => t);
_topicsCacheExpiry = DateTime.UtcNow.Add(_topicsCacheDuration); _topicsCacheByUnit[businessUnit] = (byId, DateTime.UtcNow.Add(_topicsCacheDuration));
_logger.LogInformation("Cached {Count} topics for business unit: {BusinessUnit}", _topicsCache.Count, businessUnit); _logger.LogInformation("Cached {Count} topics for business unit: {BusinessUnit}", byId.Count, businessUnit);
} }
return topics ?? new List<PlayV3Topic>(); return topics ?? new List<PlayV3Topic>();
@ -63,13 +65,14 @@ public class CategoryService : ICategoryService
/// <inheritdoc /> /// <inheritdoc />
public async Task<PlayV3Topic?> GetTopicByIdAsync(string topicId, string businessUnit, CancellationToken cancellationToken = default) public async Task<PlayV3Topic?> GetTopicByIdAsync(string topicId, string businessUnit, CancellationToken cancellationToken = default)
{ {
// Ensure topics are loaded // Ensure topics are loaded for this business unit
if (_topicsCache == null || DateTime.UtcNow >= _topicsCacheExpiry) if (!_topicsCacheByUnit.TryGetValue(businessUnit, out var cached) || DateTime.UtcNow >= cached.Expiry)
{ {
await GetTopicsAsync(businessUnit, cancellationToken).ConfigureAwait(false); await GetTopicsAsync(businessUnit, cancellationToken).ConfigureAwait(false);
_topicsCacheByUnit.TryGetValue(businessUnit, out cached);
} }
return _topicsCache?.GetValueOrDefault(topicId); return cached.Topics?.GetValueOrDefault(topicId);
} }
/// <inheritdoc /> /// <inheritdoc />
@ -101,8 +104,7 @@ public class CategoryService : ICategoryService
/// <inheritdoc /> /// <inheritdoc />
public void ClearCache() public void ClearCache()
{ {
_topicsCache = null; _topicsCacheByUnit.Clear();
_topicsCacheExpiry = DateTime.MinValue;
_logger.LogInformation("Topics cache cleared"); _logger.LogInformation("Topics cache cleared");
} }

View File

@ -34,9 +34,10 @@ public class ContentRefreshService : IContentRefreshService
/// <summary> /// <summary>
/// Refreshes latest content from SRF API using Play v3. /// Refreshes latest content from SRF API using Play v3.
/// </summary> /// </summary>
/// <param name="businessUnit">The business unit to fetch content for (e.g. "srf", "rts").</param>
/// <param name="cancellationToken">The cancellation token.</param> /// <param name="cancellationToken">The cancellation token.</param>
/// <returns>List of URNs for new content.</returns> /// <returns>List of URNs for new content.</returns>
public async Task<List<string>> RefreshLatestContentAsync(CancellationToken cancellationToken) public async Task<List<string>> RefreshLatestContentAsync(string businessUnit, CancellationToken cancellationToken)
{ {
var config = Plugin.Instance?.Configuration; var config = Plugin.Instance?.Configuration;
if (config == null || !config.EnableLatestContent) if (config == null || !config.EnableLatestContent)
@ -46,7 +47,7 @@ public class ContentRefreshService : IContentRefreshService
} }
return await FetchVideosFromShowsAsync( return await FetchVideosFromShowsAsync(
config.BusinessUnit.ToLowerString(), businessUnit,
minEpisodeCount: 0, minEpisodeCount: 0,
maxShows: 20, maxShows: 20,
videosPerShow: 1, videosPerShow: 1,
@ -58,9 +59,10 @@ public class ContentRefreshService : IContentRefreshService
/// Refreshes trending content from SRF API using Play v3. /// Refreshes trending content from SRF API using Play v3.
/// Gets videos from shows with the most episodes. /// Gets videos from shows with the most episodes.
/// </summary> /// </summary>
/// <param name="businessUnit">The business unit to fetch content for (e.g. "srf", "rts").</param>
/// <param name="cancellationToken">The cancellation token.</param> /// <param name="cancellationToken">The cancellation token.</param>
/// <returns>List of URNs for trending content.</returns> /// <returns>List of URNs for trending content.</returns>
public async Task<List<string>> RefreshTrendingContentAsync(CancellationToken cancellationToken) public async Task<List<string>> RefreshTrendingContentAsync(string businessUnit, CancellationToken cancellationToken)
{ {
var config = Plugin.Instance?.Configuration; var config = Plugin.Instance?.Configuration;
if (config == null || !config.EnableTrendingContent) if (config == null || !config.EnableTrendingContent)
@ -70,7 +72,7 @@ public class ContentRefreshService : IContentRefreshService
} }
return await FetchVideosFromShowsAsync( return await FetchVideosFromShowsAsync(
config.BusinessUnit.ToLowerString(), businessUnit,
minEpisodeCount: 10, minEpisodeCount: 10,
maxShows: 15, maxShows: 15,
videosPerShow: 2, videosPerShow: 2,

View File

@ -12,14 +12,16 @@ public interface IContentRefreshService
/// <summary> /// <summary>
/// Refreshes latest content from SRF API using Play v3. /// Refreshes latest content from SRF API using Play v3.
/// </summary> /// </summary>
/// <param name="businessUnit">The business unit to fetch content for (e.g. "srf", "rts").</param>
/// <param name="cancellationToken">The cancellation token.</param> /// <param name="cancellationToken">The cancellation token.</param>
/// <returns>List of URNs for new content.</returns> /// <returns>List of URNs for new content.</returns>
Task<List<string>> RefreshLatestContentAsync(CancellationToken cancellationToken); Task<List<string>> RefreshLatestContentAsync(string businessUnit, CancellationToken cancellationToken);
/// <summary> /// <summary>
/// Refreshes trending content from SRF API using Play v3. /// Refreshes trending content from SRF API using Play v3.
/// </summary> /// </summary>
/// <param name="businessUnit">The business unit to fetch content for (e.g. "srf", "rts").</param>
/// <param name="cancellationToken">The cancellation token.</param> /// <param name="cancellationToken">The cancellation token.</param>
/// <returns>List of URNs for trending content.</returns> /// <returns>List of URNs for trending content.</returns>
Task<List<string>> RefreshTrendingContentAsync(CancellationToken cancellationToken); Task<List<string>> RefreshTrendingContentAsync(string businessUnit, CancellationToken cancellationToken);
} }

View File

@ -148,18 +148,25 @@ public class RecordingService : IRecordingService, IDisposable
public async Task<IReadOnlyList<PlayV3TvProgram>> GetUpcomingScheduleAsync(CancellationToken cancellationToken) public async Task<IReadOnlyList<PlayV3TvProgram>> GetUpcomingScheduleAsync(CancellationToken cancellationToken)
{ {
var config = Plugin.Instance?.Configuration; var config = Plugin.Instance?.Configuration;
var businessUnit = (config?.BusinessUnit ?? Configuration.BusinessUnit.SRF).ToString().ToLowerInvariant(); var units = config?.ResolveEnabledUnits() ?? new[] { Configuration.BusinessUnit.SRF };
using var apiClient = _apiClientFactory.CreateClient(); using var apiClient = _apiClientFactory.CreateClient();
var livestreams = await apiClient.GetScheduledLivestreamsAsync(businessUnit, "SPORT", cancellationToken).ConfigureAwait(false);
if (livestreams == null) // Aggregate sport livestreams across every enabled business unit so the recordings
// page shows events from all enabled languages.
var all = new List<PlayV3TvProgram>();
foreach (var unit in units)
{ {
return Array.Empty<PlayV3TvProgram>(); var businessUnit = unit.ToString().ToLowerInvariant();
var livestreams = await apiClient.GetScheduledLivestreamsAsync(businessUnit, "SPORT", cancellationToken).ConfigureAwait(false);
if (livestreams != null)
{
all.AddRange(livestreams);
}
} }
// Filter to only future/current livestreams that aren't blocked // Filter to only future/current livestreams that aren't blocked
return livestreams return all
.Where(ls => ls.Blocked != true && (ls.ValidTo == null || ls.ValidTo.Value.ToUniversalTime() > DateTime.UtcNow)) .Where(ls => ls.Blocked != true && (ls.ValidTo == null || ls.ValidTo.Value.ToUniversalTime() > DateTime.UtcNow))
.OrderBy(ls => ls.ValidFrom) .OrderBy(ls => ls.ValidFrom)
.ToList(); .ToList();
@ -178,9 +185,11 @@ public class RecordingService : IRecordingService, IDisposable
return existing; return existing;
} }
// Fetch metadata for the URN // Fetch metadata for the URN. The unit is encoded in the URN itself, so use that to
// query the right schedule regardless of which units are enabled.
var config = Plugin.Instance?.Configuration; var config = Plugin.Instance?.Configuration;
var businessUnit = (config?.BusinessUnit ?? Configuration.BusinessUnit.SRF).ToString().ToLowerInvariant(); var businessUnit = ParseBusinessUnitFromUrn(urn)
?? (config?.BusinessUnit ?? Configuration.BusinessUnit.SRF).ToString().ToLowerInvariant();
using var apiClient = _apiClientFactory.CreateClient(); using var apiClient = _apiClientFactory.CreateClient();
var livestreams = await apiClient.GetScheduledLivestreamsAsync(businessUnit, "SPORT", cancellationToken).ConfigureAwait(false); var livestreams = await apiClient.GetScheduledLivestreamsAsync(businessUnit, "SPORT", cancellationToken).ConfigureAwait(false);
@ -190,6 +199,7 @@ public class RecordingService : IRecordingService, IDisposable
{ {
Id = Guid.NewGuid().ToString("N"), Id = Guid.NewGuid().ToString("N"),
Urn = urn, Urn = urn,
BusinessUnit = ParseBusinessUnitFromUrn(urn) ?? businessUnit,
Title = program?.Title ?? urn, Title = program?.Title ?? urn,
Description = program?.Lead ?? program?.Description, Description = program?.Lead ?? program?.Description,
ImageUrl = program?.ImageUrl, ImageUrl = program?.ImageUrl,
@ -206,6 +216,24 @@ public class RecordingService : IRecordingService, IDisposable
return entry; return entry;
} }
/// <summary>
/// Extracts the business unit from an SRF URN of the form "urn:&lt;bu&gt;:&lt;type&gt;:...".
/// </summary>
/// <param name="urn">The URN.</param>
/// <returns>The lowercase business unit, or null if it cannot be determined.</returns>
private static string? ParseBusinessUnitFromUrn(string urn)
{
if (string.IsNullOrEmpty(urn))
{
return null;
}
var parts = urn.Split(':');
return parts.Length >= 2 && !string.IsNullOrEmpty(parts[1])
? parts[1].ToLowerInvariant()
: null;
}
/// <inheritdoc /> /// <inheritdoc />
public bool CancelRecording(string recordingId) public bool CancelRecording(string recordingId)
{ {

BIN
assests/units/rsi-logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

BIN
assests/units/rtr-logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

BIN
assests/units/rts-logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

BIN
assests/units/srf-logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

BIN
assests/units/swi-logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB