diff --git a/Jellyfin.Plugin.SRFPlay/Api/Models/RecordingEntry.cs b/Jellyfin.Plugin.SRFPlay/Api/Models/RecordingEntry.cs index 697974c..b1362a5 100644 --- a/Jellyfin.Plugin.SRFPlay/Api/Models/RecordingEntry.cs +++ b/Jellyfin.Plugin.SRFPlay/Api/Models/RecordingEntry.cs @@ -20,6 +20,13 @@ public class RecordingEntry [JsonPropertyName("urn")] public string Urn { get; set; } = string.Empty; + /// + /// Gets or sets the business unit this recording belongs to (e.g. "srf", "rts"). + /// Used to show recordings under the matching unit's channel. + /// + [JsonPropertyName("businessUnit")] + public string BusinessUnit { get; set; } = string.Empty; + /// /// Gets or sets the title. /// diff --git a/Jellyfin.Plugin.SRFPlay/Channels/RsiChannel.cs b/Jellyfin.Plugin.SRFPlay/Channels/RsiChannel.cs new file mode 100644 index 0000000..423fdab --- /dev/null +++ b/Jellyfin.Plugin.SRFPlay/Channels/RsiChannel.cs @@ -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; + +/// +/// Radiotelevisione svizzera — Swiss Italian channel. +/// +public sealed class RsiChannel : SrgChannelBase +{ + /// + /// Initializes a new instance of the class. + /// + /// The logger factory. + /// The content refresh service. + /// The stream resolver. + /// The media source factory. + /// The category service. + /// The API client factory. + /// The recording service. + public RsiChannel( + ILoggerFactory loggerFactory, + IContentRefreshService contentRefreshService, + IStreamUrlResolver streamResolver, + IMediaSourceFactory mediaSourceFactory, + ICategoryService? categoryService, + ISRFApiClientFactory apiClientFactory, + IRecordingService recordingService) + : base(loggerFactory, contentRefreshService, streamResolver, mediaSourceFactory, categoryService, apiClientFactory, recordingService) + { + } + + /// + protected override BusinessUnit Unit => BusinessUnit.RSI; + + /// + protected override string LogoResourceName => "Jellyfin.Plugin.SRFPlay.Images.rsi-logo.png"; + + /// + public override string Description => "Radiotelevisione svizzera — Swiss Italian video-on-demand content"; +} diff --git a/Jellyfin.Plugin.SRFPlay/Channels/RtrChannel.cs b/Jellyfin.Plugin.SRFPlay/Channels/RtrChannel.cs new file mode 100644 index 0000000..9ffe0df --- /dev/null +++ b/Jellyfin.Plugin.SRFPlay/Channels/RtrChannel.cs @@ -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; + +/// +/// Radiotelevisiun Svizra Rumantscha — Swiss Romansh channel. +/// +public sealed class RtrChannel : SrgChannelBase +{ + /// + /// Initializes a new instance of the class. + /// + /// The logger factory. + /// The content refresh service. + /// The stream resolver. + /// The media source factory. + /// The category service. + /// The API client factory. + /// The recording service. + public RtrChannel( + ILoggerFactory loggerFactory, + IContentRefreshService contentRefreshService, + IStreamUrlResolver streamResolver, + IMediaSourceFactory mediaSourceFactory, + ICategoryService? categoryService, + ISRFApiClientFactory apiClientFactory, + IRecordingService recordingService) + : base(loggerFactory, contentRefreshService, streamResolver, mediaSourceFactory, categoryService, apiClientFactory, recordingService) + { + } + + /// + protected override BusinessUnit Unit => BusinessUnit.RTR; + + /// + protected override string LogoResourceName => "Jellyfin.Plugin.SRFPlay.Images.rtr-logo.png"; + + /// + public override string Description => "Radiotelevisiun Svizra Rumantscha — Swiss Romansh video-on-demand content"; +} diff --git a/Jellyfin.Plugin.SRFPlay/Channels/RtsChannel.cs b/Jellyfin.Plugin.SRFPlay/Channels/RtsChannel.cs new file mode 100644 index 0000000..488c209 --- /dev/null +++ b/Jellyfin.Plugin.SRFPlay/Channels/RtsChannel.cs @@ -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; + +/// +/// Radio Télévision Suisse — Swiss French channel. +/// +public sealed class RtsChannel : SrgChannelBase +{ + /// + /// Initializes a new instance of the class. + /// + /// The logger factory. + /// The content refresh service. + /// The stream resolver. + /// The media source factory. + /// The category service. + /// The API client factory. + /// The recording service. + public RtsChannel( + ILoggerFactory loggerFactory, + IContentRefreshService contentRefreshService, + IStreamUrlResolver streamResolver, + IMediaSourceFactory mediaSourceFactory, + ICategoryService? categoryService, + ISRFApiClientFactory apiClientFactory, + IRecordingService recordingService) + : base(loggerFactory, contentRefreshService, streamResolver, mediaSourceFactory, categoryService, apiClientFactory, recordingService) + { + } + + /// + protected override BusinessUnit Unit => BusinessUnit.RTS; + + /// + protected override string LogoResourceName => "Jellyfin.Plugin.SRFPlay.Images.rts-logo.png"; + + /// + public override string Description => "Radio Télévision Suisse — Swiss French video-on-demand content"; +} diff --git a/Jellyfin.Plugin.SRFPlay/Channels/SrfChannel.cs b/Jellyfin.Plugin.SRFPlay/Channels/SrfChannel.cs new file mode 100644 index 0000000..c6e2530 --- /dev/null +++ b/Jellyfin.Plugin.SRFPlay/Channels/SrfChannel.cs @@ -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; + +/// +/// Schweizer Radio und Fernsehen — Swiss German channel. +/// +public sealed class SrfChannel : SrgChannelBase +{ + /// + /// Initializes a new instance of the class. + /// + /// The logger factory. + /// The content refresh service. + /// The stream resolver. + /// The media source factory. + /// The category service. + /// The API client factory. + /// The recording service. + public SrfChannel( + ILoggerFactory loggerFactory, + IContentRefreshService contentRefreshService, + IStreamUrlResolver streamResolver, + IMediaSourceFactory mediaSourceFactory, + ICategoryService? categoryService, + ISRFApiClientFactory apiClientFactory, + IRecordingService recordingService) + : base(loggerFactory, contentRefreshService, streamResolver, mediaSourceFactory, categoryService, apiClientFactory, recordingService) + { + } + + /// + protected override BusinessUnit Unit => BusinessUnit.SRF; + + /// + protected override string LogoResourceName => "Jellyfin.Plugin.SRFPlay.Images.srf-logo.png"; + + /// + public override string Description => "Schweizer Radio und Fernsehen — Swiss German video-on-demand content"; +} diff --git a/Jellyfin.Plugin.SRFPlay/Channels/SRFPlayChannel.cs b/Jellyfin.Plugin.SRFPlay/Channels/SrgChannelBase.cs similarity index 91% rename from Jellyfin.Plugin.SRFPlay/Channels/SRFPlayChannel.cs rename to Jellyfin.Plugin.SRFPlay/Channels/SrgChannelBase.cs index fdaf9cd..3117649 100644 --- a/Jellyfin.Plugin.SRFPlay/Channels/SRFPlayChannel.cs +++ b/Jellyfin.Plugin.SRFPlay/Channels/SrgChannelBase.cs @@ -7,6 +7,7 @@ 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; @@ -22,11 +23,12 @@ using Microsoft.Extensions.Logging; namespace Jellyfin.Plugin.SRFPlay.Channels; /// -/// 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. /// -public class SRFPlayChannel : IChannel, IHasCacheKey +public abstract class SrgChannelBase : IChannel, IHasCacheKey { - private readonly ILogger _logger; + private readonly ILogger _logger; private readonly IContentRefreshService _contentRefreshService; private readonly IStreamUrlResolver _streamResolver; private readonly IMediaSourceFactory _mediaSourceFactory; @@ -35,7 +37,7 @@ public class SRFPlayChannel : IChannel, IHasCacheKey private readonly IRecordingService _recordingService; /// - /// Initializes a new instance of the class. + /// Initializes a new instance of the class. /// /// The logger factory. /// The content refresh service. @@ -44,7 +46,7 @@ public class SRFPlayChannel : IChannel, IHasCacheKey /// The category service (optional). /// The API client factory. /// The recording service. - public SRFPlayChannel( + protected SrgChannelBase( ILoggerFactory loggerFactory, IContentRefreshService contentRefreshService, IStreamUrlResolver streamResolver, @@ -53,7 +55,7 @@ public class SRFPlayChannel : IChannel, IHasCacheKey ISRFApiClientFactory apiClientFactory, IRecordingService recordingService) { - _logger = loggerFactory.CreateLogger(); + _logger = loggerFactory.CreateLogger(); _contentRefreshService = contentRefreshService; _streamResolver = streamResolver; _mediaSourceFactory = mediaSourceFactory; @@ -66,14 +68,24 @@ public class SRFPlayChannel : IChannel, IHasCacheKey _logger.LogWarning("CategoryService not available - category folders will be disabled"); } - _logger.LogDebug("SRFPlayChannel initialized"); + _logger.LogDebug("SrgChannelBase initialized"); } - /// - public string Name => "SRF Play"; + /// + /// Gets the business unit this channel serves. + /// + protected abstract BusinessUnit Unit { get; } + + /// + /// Gets the embedded resource name of this unit's logo PNG. + /// + protected abstract string LogoResourceName { get; } /// - public string Description => "Swiss Radio and Television video-on-demand content"; + public string Name => $"{Unit} Play"; + + /// + public abstract string Description { get; } /// 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 GetChannelImage(ImageType type, CancellationToken cancellationToken) { 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) { @@ -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> 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(); + } + // Root level - show folder list if (string.IsNullOrEmpty(folderId)) { @@ -198,7 +224,7 @@ public class SRFPlayChannel : IChannel, IHasCacheKey { try { - var businessUnit = config.BusinessUnit.ToLowerString(); + var businessUnit = Unit.ToLowerString(); var topics = await _categoryService.GetTopicsAsync(businessUnit, cancellationToken).ConfigureAwait(false); foreach (var topic in topics.Where(t => !string.IsNullOrEmpty(t.Id))) @@ -239,24 +265,23 @@ public class SRFPlayChannel : IChannel, IHasCacheKey private async Task> 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); } private async Task> 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); } private async Task> GetLiveSportsAsync(CancellationToken cancellationToken) { var items = new List(); - var config = Plugin.Instance?.Configuration; try { - var businessUnit = config?.BusinessUnit.ToLowerString() ?? "srf"; + var businessUnit = Unit.ToLowerString(); using var apiClient = _apiClientFactory.CreateClient(); var scheduledLivestreams = await apiClient.GetScheduledLivestreamsAsync(businessUnit, "SPORT", cancellationToken).ConfigureAwait(false); @@ -307,6 +332,7 @@ public class SRFPlayChannel : IChannel, IHasCacheKey private List GetRecordingItems() { var items = new List(); + var unit = Unit.ToLowerString(); var recordings = _recordingService.GetRecordings(RecordingState.Completed); foreach (var recording in recordings) @@ -316,6 +342,15 @@ public class SRFPlayChannel : IChannel, IHasCacheKey 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 itemId = $"recording_{recording.Id}"; @@ -368,9 +403,8 @@ public class SRFPlayChannel : IChannel, IHasCacheKey try { - var config = Plugin.Instance?.Configuration; 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 urns = new List(); @@ -472,7 +506,7 @@ public class SRFPlayChannel : IChannel, IHasCacheKey var timeKey = timeBucket.ToString("yyyy-MM-dd-HH-mm", CultureInfo.InvariantCulture); 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> ConvertUrnsToChannelItems(List urns, CancellationToken cancellationToken) diff --git a/Jellyfin.Plugin.SRFPlay/Channels/SwiChannel.cs b/Jellyfin.Plugin.SRFPlay/Channels/SwiChannel.cs new file mode 100644 index 0000000..ff6270f --- /dev/null +++ b/Jellyfin.Plugin.SRFPlay/Channels/SwiChannel.cs @@ -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; + +/// +/// SWI swissinfo.ch — Swiss international channel. +/// +public sealed class SwiChannel : SrgChannelBase +{ + /// + /// Initializes a new instance of the class. + /// + /// The logger factory. + /// The content refresh service. + /// The stream resolver. + /// The media source factory. + /// The category service. + /// The API client factory. + /// The recording service. + public SwiChannel( + ILoggerFactory loggerFactory, + IContentRefreshService contentRefreshService, + IStreamUrlResolver streamResolver, + IMediaSourceFactory mediaSourceFactory, + ICategoryService? categoryService, + ISRFApiClientFactory apiClientFactory, + IRecordingService recordingService) + : base(loggerFactory, contentRefreshService, streamResolver, mediaSourceFactory, categoryService, apiClientFactory, recordingService) + { + } + + /// + protected override BusinessUnit Unit => BusinessUnit.SWI; + + /// + protected override string LogoResourceName => "Jellyfin.Plugin.SRFPlay.Images.swi-logo.png"; + + /// + public override string Description => "SWI swissinfo.ch — Swiss international video-on-demand content"; +} diff --git a/Jellyfin.Plugin.SRFPlay/Configuration/PluginConfiguration.cs b/Jellyfin.Plugin.SRFPlay/Configuration/PluginConfiguration.cs index b62c5a9..9929d06 100644 --- a/Jellyfin.Plugin.SRFPlay/Configuration/PluginConfiguration.cs +++ b/Jellyfin.Plugin.SRFPlay/Configuration/PluginConfiguration.cs @@ -66,6 +66,7 @@ public class PluginConfiguration : BasePluginConfiguration { // Set default options BusinessUnit = BusinessUnit.SRF; + EnabledBusinessUnits = new System.Collections.Generic.List { BusinessUnit.SRF }; QualityPreference = QualityPreference.Auto; ContentRefreshIntervalHours = 6; ExpirationCheckIntervalHours = 24; @@ -78,10 +79,20 @@ public class PluginConfiguration : BasePluginConfiguration } /// - /// 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 ; new code should use the list instead. /// public BusinessUnit BusinessUnit { get; set; } + /// + /// 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. + /// + [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 EnabledBusinessUnits { get; set; } + /// /// Gets or sets the preferred video quality. /// @@ -161,4 +172,20 @@ public class PluginConfiguration : BasePluginConfiguration /// Gets or sets the output directory for sport livestream recordings. /// public string RecordingOutputPath { get; set; } = string.Empty; + + /// + /// Resolves the effective set of enabled business units, migrating from the legacy single + /// value when the list has not been populated yet. + /// + /// The business units that should have channels. + public System.Collections.Generic.IReadOnlyList 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 }; + } } diff --git a/Jellyfin.Plugin.SRFPlay/Configuration/configPage.html b/Jellyfin.Plugin.SRFPlay/Configuration/configPage.html index 4f60fb7..521320e 100644 --- a/Jellyfin.Plugin.SRFPlay/Configuration/configPage.html +++ b/Jellyfin.Plugin.SRFPlay/Configuration/configPage.html @@ -2,16 +2,29 @@
-
- - -
Select the Swiss broadcasting unit to fetch content from
+
+

Channels (Business Units)

+
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.
+ + + + +
@@ -142,7 +155,12 @@ .addEventListener('pageshow', function() { Dashboard.showLoadingMsg(); 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('#ContentRefreshIntervalHours').value = config.ContentRefreshIntervalHours; document.querySelector('#ExpirationCheckIntervalHours').value = config.ExpirationCheckIntervalHours; @@ -168,7 +186,11 @@ .addEventListener('submit', function(e) { Dashboard.showLoadingMsg(); 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.ContentRefreshIntervalHours = parseInt(document.querySelector('#ContentRefreshIntervalHours').value); config.ExpirationCheckIntervalHours = parseInt(document.querySelector('#ExpirationCheckIntervalHours').value); diff --git a/Jellyfin.Plugin.SRFPlay/Jellyfin.Plugin.SRFPlay.csproj b/Jellyfin.Plugin.SRFPlay/Jellyfin.Plugin.SRFPlay.csproj index 5c74e22..c824c13 100644 --- a/Jellyfin.Plugin.SRFPlay/Jellyfin.Plugin.SRFPlay.csproj +++ b/Jellyfin.Plugin.SRFPlay/Jellyfin.Plugin.SRFPlay.csproj @@ -33,6 +33,11 @@ + + + + + diff --git a/Jellyfin.Plugin.SRFPlay/ScheduledTasks/ContentRefreshTask.cs b/Jellyfin.Plugin.SRFPlay/ScheduledTasks/ContentRefreshTask.cs index 84678ca..4d9ef92 100644 --- a/Jellyfin.Plugin.SRFPlay/ScheduledTasks/ContentRefreshTask.cs +++ b/Jellyfin.Plugin.SRFPlay/ScheduledTasks/ContentRefreshTask.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; using Jellyfin.Plugin.SRFPlay.Services.Interfaces; +using Jellyfin.Plugin.SRFPlay.Utilities; using MediaBrowser.Model.Tasks; using Microsoft.Extensions.Logging; @@ -63,20 +64,25 @@ public class ContentRefreshTask : IScheduledTask return; } - // Refresh latest content - if (config.EnableLatestContent) + // Refresh content for every enabled business unit (one channel per unit). + var units = config.ResolveEnabledUnits(); + foreach (var unit in units) { - _logger.LogInformation("Refreshing latest content"); - progress?.Report(25); - await _contentRefreshService.RefreshLatestContentAsync(cancellationToken).ConfigureAwait(false); - } + var businessUnit = unit.ToLowerString(); - // Refresh trending content - if (config.EnableTrendingContent) - { - _logger.LogInformation("Refreshing trending content"); - progress?.Report(75); - await _contentRefreshService.RefreshTrendingContentAsync(cancellationToken).ConfigureAwait(false); + if (config.EnableLatestContent) + { + _logger.LogInformation("Refreshing latest content for {BusinessUnit}", businessUnit); + progress?.Report(25); + await _contentRefreshService.RefreshLatestContentAsync(businessUnit, cancellationToken).ConfigureAwait(false); + } + + if (config.EnableTrendingContent) + { + _logger.LogInformation("Refreshing trending content for {BusinessUnit}", businessUnit); + progress?.Report(75); + await _contentRefreshService.RefreshTrendingContentAsync(businessUnit, cancellationToken).ConfigureAwait(false); + } } progress?.Report(100); diff --git a/Jellyfin.Plugin.SRFPlay/ServiceRegistrator.cs b/Jellyfin.Plugin.SRFPlay/ServiceRegistrator.cs index 0ef6988..d862cb8 100644 --- a/Jellyfin.Plugin.SRFPlay/ServiceRegistrator.cs +++ b/Jellyfin.Plugin.SRFPlay/ServiceRegistrator.cs @@ -49,7 +49,13 @@ public class ServiceRegistrator : IPluginServiceRegistrator serviceCollection.AddSingleton(); serviceCollection.AddSingleton(); - // Register channel - must register as IChannel interface for Jellyfin to discover it - serviceCollection.AddSingleton(); + // Register one channel (tile) per SRG business unit. Each must be registered as IChannel + // for Jellyfin to discover it. A unit that is not in EnabledBusinessUnits returns no + // content (its tile appears empty) - see SrgChannelBase. + serviceCollection.AddSingleton(); + serviceCollection.AddSingleton(); + serviceCollection.AddSingleton(); + serviceCollection.AddSingleton(); + serviceCollection.AddSingleton(); } } diff --git a/Jellyfin.Plugin.SRFPlay/Services/ContentRefreshService.cs b/Jellyfin.Plugin.SRFPlay/Services/ContentRefreshService.cs index 60194af..0b4a303 100644 --- a/Jellyfin.Plugin.SRFPlay/Services/ContentRefreshService.cs +++ b/Jellyfin.Plugin.SRFPlay/Services/ContentRefreshService.cs @@ -34,9 +34,10 @@ public class ContentRefreshService : IContentRefreshService /// /// Refreshes latest content from SRF API using Play v3. /// + /// The business unit to fetch content for (e.g. "srf", "rts"). /// The cancellation token. /// List of URNs for new content. - public async Task> RefreshLatestContentAsync(CancellationToken cancellationToken) + public async Task> RefreshLatestContentAsync(string businessUnit, CancellationToken cancellationToken) { var config = Plugin.Instance?.Configuration; if (config == null || !config.EnableLatestContent) @@ -46,7 +47,7 @@ public class ContentRefreshService : IContentRefreshService } return await FetchVideosFromShowsAsync( - config.BusinessUnit.ToLowerString(), + businessUnit, minEpisodeCount: 0, maxShows: 20, videosPerShow: 1, @@ -58,9 +59,10 @@ public class ContentRefreshService : IContentRefreshService /// Refreshes trending content from SRF API using Play v3. /// Gets videos from shows with the most episodes. /// + /// The business unit to fetch content for (e.g. "srf", "rts"). /// The cancellation token. /// List of URNs for trending content. - public async Task> RefreshTrendingContentAsync(CancellationToken cancellationToken) + public async Task> RefreshTrendingContentAsync(string businessUnit, CancellationToken cancellationToken) { var config = Plugin.Instance?.Configuration; if (config == null || !config.EnableTrendingContent) @@ -70,7 +72,7 @@ public class ContentRefreshService : IContentRefreshService } return await FetchVideosFromShowsAsync( - config.BusinessUnit.ToLowerString(), + businessUnit, minEpisodeCount: 10, maxShows: 15, videosPerShow: 2, diff --git a/Jellyfin.Plugin.SRFPlay/Services/Interfaces/IContentRefreshService.cs b/Jellyfin.Plugin.SRFPlay/Services/Interfaces/IContentRefreshService.cs index b0fd5c6..acaa66c 100644 --- a/Jellyfin.Plugin.SRFPlay/Services/Interfaces/IContentRefreshService.cs +++ b/Jellyfin.Plugin.SRFPlay/Services/Interfaces/IContentRefreshService.cs @@ -12,14 +12,16 @@ public interface IContentRefreshService /// /// Refreshes latest content from SRF API using Play v3. /// + /// The business unit to fetch content for (e.g. "srf", "rts"). /// The cancellation token. /// List of URNs for new content. - Task> RefreshLatestContentAsync(CancellationToken cancellationToken); + Task> RefreshLatestContentAsync(string businessUnit, CancellationToken cancellationToken); /// /// Refreshes trending content from SRF API using Play v3. /// + /// The business unit to fetch content for (e.g. "srf", "rts"). /// The cancellation token. /// List of URNs for trending content. - Task> RefreshTrendingContentAsync(CancellationToken cancellationToken); + Task> RefreshTrendingContentAsync(string businessUnit, CancellationToken cancellationToken); } diff --git a/Jellyfin.Plugin.SRFPlay/Services/RecordingService.cs b/Jellyfin.Plugin.SRFPlay/Services/RecordingService.cs index e8f1037..2b14467 100644 --- a/Jellyfin.Plugin.SRFPlay/Services/RecordingService.cs +++ b/Jellyfin.Plugin.SRFPlay/Services/RecordingService.cs @@ -148,18 +148,25 @@ public class RecordingService : IRecordingService, IDisposable public async Task> GetUpcomingScheduleAsync(CancellationToken cancellationToken) { 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(); - 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(); + foreach (var unit in units) { - return Array.Empty(); + 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 - return livestreams + return all .Where(ls => ls.Blocked != true && (ls.ValidTo == null || ls.ValidTo.Value.ToUniversalTime() > DateTime.UtcNow)) .OrderBy(ls => ls.ValidFrom) .ToList(); @@ -178,9 +185,11 @@ public class RecordingService : IRecordingService, IDisposable 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 businessUnit = (config?.BusinessUnit ?? Configuration.BusinessUnit.SRF).ToString().ToLowerInvariant(); + var businessUnit = ParseBusinessUnitFromUrn(urn) + ?? (config?.BusinessUnit ?? Configuration.BusinessUnit.SRF).ToString().ToLowerInvariant(); using var apiClient = _apiClientFactory.CreateClient(); var livestreams = await apiClient.GetScheduledLivestreamsAsync(businessUnit, "SPORT", cancellationToken).ConfigureAwait(false); @@ -190,6 +199,7 @@ public class RecordingService : IRecordingService, IDisposable { Id = Guid.NewGuid().ToString("N"), Urn = urn, + BusinessUnit = ParseBusinessUnitFromUrn(urn) ?? businessUnit, Title = program?.Title ?? urn, Description = program?.Lead ?? program?.Description, ImageUrl = program?.ImageUrl, @@ -206,6 +216,24 @@ public class RecordingService : IRecordingService, IDisposable return entry; } + /// + /// Extracts the business unit from an SRF URN of the form "urn:<bu>:<type>:...". + /// + /// The URN. + /// The lowercase business unit, or null if it cannot be determined. + 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; + } + /// public bool CancelRecording(string recordingId) { diff --git a/assests/units/rsi-logo.png b/assests/units/rsi-logo.png new file mode 100644 index 0000000..b7d3e8e Binary files /dev/null and b/assests/units/rsi-logo.png differ diff --git a/assests/units/rtr-logo.png b/assests/units/rtr-logo.png new file mode 100644 index 0000000..e9c6a99 Binary files /dev/null and b/assests/units/rtr-logo.png differ diff --git a/assests/units/rts-logo.png b/assests/units/rts-logo.png new file mode 100644 index 0000000..34070f4 Binary files /dev/null and b/assests/units/rts-logo.png differ diff --git a/assests/units/srf-logo.png b/assests/units/srf-logo.png new file mode 100644 index 0000000..86d4162 Binary files /dev/null and b/assests/units/srf-logo.png differ diff --git a/assests/units/swi-logo.png b/assests/units/swi-logo.png new file mode 100644 index 0000000..6f98cd2 Binary files /dev/null and b/assests/units/swi-logo.png differ