Compare commits

...
Author SHA1 Message Date
dtourolle 7a719ee4ac Feature: All bussiness units get a tile
🏗️ 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
dtourolle 5667c2e12f Cache manageent for bussines units 2026-06-27 09:34:07 +02:00
Gitea Actions fef1bd6b17 Update manifest.json for version 1.0.32 2026-06-27 07:16:38 +00:00
Gitea Actions 763217c43d Update manifest.json for latest build (c2feaae) 2026-06-27 07:15:27 +00:00
dtourolle c2feaae89c Fix really fix redirect
🏗️ Build Plugin / build (push) Successful in 29s
Latest Release / latest-release (push) Successful in 47s
🧪 Test Plugin / test (push) Successful in 28s
🚀 Release Plugin / build-and-release (push) Successful in 43s
2026-06-27 09:14:07 +02:00
Gitea Actions 35b103c056 Update manifest.json for version 1.0.31 2026-06-27 06:54:02 +00:00
Gitea Actions 7af1d9b8f4 Update manifest.json for latest build (10a76f8) 2026-06-27 06:52:52 +00:00
dtourolle 10a76f8d45 Fix settings no longer redirect.
🏗️ Build Plugin / build (push) Successful in 1m16s
Latest Release / latest-release (push) Successful in 42s
🧪 Test Plugin / test (push) Successful in 27s
🚀 Release Plugin / build-and-release (push) Successful in 42s
2026-06-27 08:50:49 +02:00
Gitea Actions a39a10636b Update manifest.json for version 1.0.30 2026-05-03 16:45:02 +00:00
Gitea Actions 029fb78049 Update manifest.json for latest build (729f82b) 2026-05-03 16:39:53 +00:00
dtourolle 729f82b26d More tizen based changes
🏗️ Build Plugin / build (push) Successful in 56s
Latest Release / latest-release (push) Successful in 2m28s
🧪 Test Plugin / test (push) Successful in 52s
🚀 Release Plugin / build-and-release (push) Successful in 1m1s
2026-05-03 18:36:26 +02:00
Gitea Actions aad0c21d4b Update manifest.json for version 1.0.29 2026-05-03 16:07:24 +00:00
Gitea Actions 7a52b3da3b Update manifest.json for latest build (80598ea) 2026-05-03 16:05:38 +00:00
dtourolle 80598ea8cb Fix CI
🏗️ Build Plugin / build (push) Successful in 2m40s
Latest Release / latest-release (push) Successful in 1m22s
🧪 Test Plugin / test (push) Successful in 46s
🚀 Release Plugin / build-and-release (push) Successful in 1m1s
2026-05-03 17:10:46 +02:00
30 changed files with 851 additions and 415 deletions
+7
View File
@@ -25,6 +25,13 @@ jobs:
with:
path: build-${{ github.run_id }}
- name: Cache NuGet packages
uses: actions/cache@v3
with:
path: ~/.nuget/packages
key: nuget-${{ hashFiles('**/Jellyfin.Plugin.SRFPlay.csproj', '**/Jellyfin.Plugin.SRFPlay.Tests.csproj') }}
restore-keys: nuget-
- name: Restore dependencies
working-directory: build-${{ github.run_id }}
run: dotnet restore Jellyfin.Plugin.SRFPlay.sln
+7
View File
@@ -42,6 +42,13 @@ jobs:
sed -i "s/^version:.*/version: \"${VERSION}\"/" build.yaml
cat build.yaml
- name: Cache NuGet packages
uses: actions/cache@v3
with:
path: ~/.nuget/packages
key: nuget-${{ hashFiles('**/Jellyfin.Plugin.SRFPlay.csproj', '**/Jellyfin.Plugin.SRFPlay.Tests.csproj') }}
restore-keys: nuget-
- name: Restore dependencies
working-directory: release-${{ github.run_id }}
run: dotnet restore Jellyfin.Plugin.SRFPlay.sln
+7
View File
@@ -27,6 +27,13 @@ jobs:
with:
path: test-${{ github.run_id }}
- name: Cache NuGet packages
uses: actions/cache@v3
with:
path: ~/.nuget/packages
key: nuget-${{ hashFiles('**/Jellyfin.Plugin.SRFPlay.csproj', '**/Jellyfin.Plugin.SRFPlay.Tests.csproj') }}
restore-keys: nuget-
- name: Restore dependencies
working-directory: test-${{ github.run_id }}
run: dotnet restore Jellyfin.Plugin.SRFPlay.sln
@@ -20,6 +20,13 @@ public class RecordingEntry
[JsonPropertyName("urn")]
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>
/// Gets or sets the title.
/// </summary>
@@ -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";
}
@@ -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";
}
@@ -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";
}
@@ -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";
}
@@ -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;
/// <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>
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 IStreamUrlResolver _streamResolver;
private readonly IMediaSourceFactory _mediaSourceFactory;
@@ -35,7 +37,7 @@ public class SRFPlayChannel : IChannel, IHasCacheKey
private readonly IRecordingService _recordingService;
/// <summary>
/// Initializes a new instance of the <see cref="SRFPlayChannel"/> class.
/// 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>
@@ -44,7 +46,7 @@ public class SRFPlayChannel : IChannel, IHasCacheKey
/// <param name="categoryService">The category service (optional).</param>
/// <param name="apiClientFactory">The API client factory.</param>
/// <param name="recordingService">The recording service.</param>
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<SRFPlayChannel>();
_logger = loggerFactory.CreateLogger<SrgChannelBase>();
_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");
}
/// <inheritdoc />
public string Name => "SRF Play";
/// <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 Description => "Swiss Radio and Television video-on-demand content";
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
@@ -114,7 +126,8 @@ public class SRFPlayChannel : IChannel, IHasCacheKey
public Task<DynamicImageResponse> 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<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
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<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);
}
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);
}
private async Task<List<ChannelItemInfo>> GetLiveSportsAsync(CancellationToken cancellationToken)
{
var items = new List<ChannelItemInfo>();
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<ChannelItemInfo> GetRecordingItems()
{
var items = new List<ChannelItemInfo>();
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<string>();
@@ -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<List<ChannelItemInfo>> ConvertUrnsToChannelItems(List<string> urns, CancellationToken cancellationToken)
@@ -614,8 +648,7 @@ public class SRFPlayChannel : IChannel, IHasCacheKey
ProviderIds = new Dictionary<string, string>
{
{ "SRF", urn }
},
MediaSources = new List<MediaSourceInfo> { mediaSource }
}
};
// Add series info if available
@@ -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";
}
@@ -66,6 +66,7 @@ public class PluginConfiguration : BasePluginConfiguration
{
// Set default options
BusinessUnit = BusinessUnit.SRF;
EnabledBusinessUnits = new System.Collections.Generic.List<BusinessUnit> { BusinessUnit.SRF };
QualityPreference = QualityPreference.Auto;
ContentRefreshIntervalHours = 6;
ExpirationCheckIntervalHours = 24;
@@ -78,10 +79,20 @@ public class PluginConfiguration : BasePluginConfiguration
}
/// <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>
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>
/// Gets or sets the preferred video quality.
/// </summary>
@@ -161,4 +172,20 @@ public class PluginConfiguration : BasePluginConfiguration
/// Gets or sets the output directory for sport livestream recordings.
/// </summary>
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 };
}
}
@@ -1,24 +1,30 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>SRF Play</title>
</head>
<body>
<div id="SRFPlayConfigPage" data-role="page" class="page type-interior pluginConfigurationPage" data-require="emby-input,emby-button,emby-select,emby-checkbox">
<div data-role="content">
<div class="content-primary">
<form id="SRFPlayConfigForm">
<div class="selectContainer">
<label class="selectLabel" for="BusinessUnit">Business Unit</label>
<select is="emby-select" id="BusinessUnit" name="BusinessUnit" class="emby-select-withcolor emby-select">
<option id="optSRF" value="SRF">SRF (German)</option>
<option id="optRTS" value="RTS">RTS (French)</option>
<option id="optRSI" value="RSI">RSI (Italian)</option>
<option id="optRTR" value="RTR">RTR (Romansh)</option>
<option id="optSWI" value="SWI">SWI (International)</option>
</select>
<div class="fieldDescription">Select the Swiss broadcasting unit to fetch content from</div>
<div class="checkboxContainer checkboxContainer-withDescription">
<h3>Channels (Business Units)</h3>
<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>
<label class="emby-checkbox-label">
<input id="buSRF" type="checkbox" is="emby-checkbox" data-bu="SRF" />
<span>SRF — Schweizer Radio und Fernsehen (German)</span>
</label>
<label class="emby-checkbox-label">
<input id="buRTS" type="checkbox" is="emby-checkbox" data-bu="RTS" />
<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 class="selectContainer">
<label class="selectLabel" for="QualityPreference">Quality Preference</label>
@@ -149,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;
@@ -175,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);
@@ -398,5 +413,3 @@
};
</script>
</div>
</body>
</html>
@@ -1,66 +1,59 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SRF Sport Recordings</title>
<div id="SRFPlayRecordingsPage" data-role="page" class="page type-interior pluginConfigurationPage"
data-require="emby-button,emby-input">
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #1a1a2e; color: #e0e0e0; padding: 20px; }
h1 { color: #fff; margin-bottom: 8px; font-size: 1.8em; }
h2 { color: #ccc; margin: 24px 0 12px; font-size: 1.3em; border-bottom: 1px solid #333; padding-bottom: 6px; }
.subtitle { color: #999; margin-bottom: 20px; font-size: 0.9em; }
table { width: 100%; border-collapse: collapse; margin: 10px 0 20px; }
th { text-align: left; padding: 10px 8px; border-bottom: 2px solid #444; color: #aaa; font-size: 0.85em; text-transform: uppercase; }
td { padding: 10px 8px; border-bottom: 1px solid #2a2a3e; }
tr:hover { background: #2a2a3e; }
.btn { padding: 6px 16px; border: none; border-radius: 4px; cursor: pointer; font-size: 0.85em; color: #fff; transition: opacity 0.2s; }
.btn:hover { opacity: 0.85; }
.btn-record { background: #4CAF50; }
.btn-stop { background: #FF9800; }
.btn-cancel { background: #9E9E9E; }
.btn-delete { background: #f44336; }
.btn-refresh { background: #2196F3; margin-bottom: 16px; }
.status { padding: 3px 8px; border-radius: 3px; font-size: 0.8em; font-weight: 600; }
.status-scheduled { background: #1565C0; }
.status-waiting { background: #E65100; }
.status-recording { background: #2E7D32; }
.status-failed { background: #C62828; }
.msg { padding: 12px; color: #999; font-style: italic; }
.error { color: #f44; }
.login-form { max-width: 400px; margin: 60px auto; }
.login-form input { width: 100%; padding: 10px; margin: 8px 0; background: #2a2a3e; border: 1px solid #444; color: #fff; border-radius: 4px; font-size: 1em; }
.login-form .btn { width: 100%; padding: 12px; font-size: 1em; margin-top: 12px; }
@media (max-width: 600px) { body { padding: 10px; } td, th { padding: 6px 4px; font-size: 0.85em; } }
#SRFPlayRecordingsPage .srfrec-wrap { padding: 0 1em; }
#SRFPlayRecordingsPage table { width: 100%; border-collapse: collapse; margin: 10px 0 20px; }
#SRFPlayRecordingsPage th { text-align: left; padding: 10px 8px; border-bottom: 2px solid #444; color: #aaa; font-size: 0.85em; text-transform: uppercase; }
#SRFPlayRecordingsPage td { padding: 10px 8px; border-bottom: 1px solid #2a2a3e; }
#SRFPlayRecordingsPage tr:hover { background: rgba(255,255,255,0.04); }
#SRFPlayRecordingsPage .btn { padding: 6px 16px; border: none; border-radius: 4px; cursor: pointer; font-size: 0.85em; color: #fff; transition: opacity 0.2s; }
#SRFPlayRecordingsPage .btn:hover { opacity: 0.85; }
#SRFPlayRecordingsPage .btn-record { background: #4CAF50; }
#SRFPlayRecordingsPage .btn-stop { background: #FF9800; }
#SRFPlayRecordingsPage .btn-cancel { background: #9E9E9E; }
#SRFPlayRecordingsPage .btn-delete { background: #f44336; }
#SRFPlayRecordingsPage .btn-refresh { background: #2196F3; margin-bottom: 16px; }
#SRFPlayRecordingsPage .status { padding: 3px 8px; border-radius: 3px; font-size: 0.8em; font-weight: 600; color: #fff; }
#SRFPlayRecordingsPage .status-scheduled { background: #1565C0; }
#SRFPlayRecordingsPage .status-waiting { background: #E65100; }
#SRFPlayRecordingsPage .status-recording { background: #2E7D32; }
#SRFPlayRecordingsPage .status-failed { background: #C62828; }
#SRFPlayRecordingsPage .srfrec-msg { padding: 12px; color: #999; font-style: italic; }
#SRFPlayRecordingsPage .srfrec-error { color: #f44; }
#SRFPlayRecordingsPage .login-form { max-width: 400px; margin: 40px auto; }
#SRFPlayRecordingsPage .login-form input { width: 100%; padding: 10px; margin: 8px 0; background: #2a2a3e; border: 1px solid #444; color: #fff; border-radius: 4px; font-size: 1em; }
#SRFPlayRecordingsPage .login-form .btn { width: 100%; padding: 12px; font-size: 1em; margin-top: 12px; }
</style>
</head>
<body>
<div id="loginSection" style="display:none;">
<div data-role="content">
<div class="content-primary srfrec-wrap">
<div id="srfrecLoginSection" style="display:none;">
<div class="login-form">
<h1>SRF Sport Recordings</h1>
<p class="subtitle">Sign in to your Jellyfin server</p>
<input type="text" id="loginServer" placeholder="Server URL (e.g. http://192.168.1.50:8096)" />
<input type="text" id="loginUser" placeholder="Username" />
<input type="password" id="loginPass" placeholder="Password" />
<button class="btn btn-record" onclick="SRFRec.login()">Sign In</button>
<p id="loginError" class="error" style="margin-top:10px;"></p>
<p class="fieldDescription">Sign in to your Jellyfin server</p>
<input type="text" id="srfrecLoginServer" placeholder="Server URL (e.g. http://192.168.1.50:8096)" />
<input type="text" id="srfrecLoginUser" placeholder="Username" />
<input type="password" id="srfrecLoginPass" placeholder="Password" />
<button type="button" class="btn btn-record" onclick="SRFRec.login()">Sign In</button>
<p id="srfrecLoginError" class="srfrec-error" style="margin-top:10px;"></p>
</div>
</div>
<div id="mainSection" style="display:none;">
<div id="srfrecMainSection" style="display:none;">
<h1>SRF Sport Recordings</h1>
<p class="subtitle">Browse upcoming sport livestreams and schedule recordings. <span id="userInfo"></span></p>
<p class="fieldDescription">Browse upcoming sport livestreams and schedule recordings. <span id="srfrecUserInfo"></span></p>
<h2>Upcoming Sport Livestreams</h2>
<button class="btn btn-refresh" onclick="SRFRec.loadSchedule()">Refresh Schedule</button>
<div id="scheduleContainer"><p class="msg">Loading schedule...</p></div>
<button type="button" class="btn btn-refresh" onclick="SRFRec.loadSchedule()">Refresh Schedule</button>
<div id="srfrecScheduleContainer"><p class="srfrec-msg">Loading schedule...</p></div>
<h2>Scheduled &amp; Active Recordings</h2>
<div id="activeRecordingsContainer"><p class="msg">Loading...</p></div>
<div id="srfrecActiveRecordingsContainer"><p class="srfrec-msg">Loading...</p></div>
<h2>Completed Recordings</h2>
<button class="btn btn-refresh" onclick="SRFRec.loadRecordings()">Refresh</button>
<div id="completedRecordingsContainer"><p class="msg">Loading...</p></div>
<button type="button" class="btn btn-refresh" onclick="SRFRec.loadRecordings()">Refresh</button>
<div id="srfrecCompletedRecordingsContainer"><p class="srfrec-msg">Loading...</p></div>
</div>
</div>
</div>
<script>
@@ -91,9 +84,9 @@
}
// Show login form
var serverInput = document.getElementById('loginServer');
var serverInput = document.getElementById('srfrecLoginServer');
serverInput.value = window.location.origin;
document.getElementById('loginSection').style.display = 'block';
document.getElementById('srfrecLoginSection').style.display = 'block';
},
verifySession: function(session) {
@@ -102,24 +95,24 @@
headers: { 'X-Emby-Token': this.token }
}).then(function(r) {
if (r.ok) {
document.getElementById('userInfo').textContent = '(Server: ' + self.serverUrl + ')';
document.getElementById('srfrecUserInfo').textContent = '(Server: ' + self.serverUrl + ')';
self.showMain();
} else {
localStorage.removeItem('srfRecSession');
document.getElementById('loginSection').style.display = 'block';
document.getElementById('srfrecLoginSection').style.display = 'block';
}
}).catch(function() {
localStorage.removeItem('srfRecSession');
document.getElementById('loginSection').style.display = 'block';
document.getElementById('srfrecLoginSection').style.display = 'block';
});
},
login: function() {
var self = this;
var server = document.getElementById('loginServer').value.replace(/\/+$/, '');
var user = document.getElementById('loginUser').value;
var pass = document.getElementById('loginPass').value;
var errorEl = document.getElementById('loginError');
var server = document.getElementById('srfrecLoginServer').value.replace(/\/+$/, '');
var user = document.getElementById('srfrecLoginUser').value;
var pass = document.getElementById('srfrecLoginPass').value;
var errorEl = document.getElementById('srfrecLoginError');
errorEl.textContent = '';
fetch(server + '/Users/AuthenticateByName', {
@@ -142,7 +135,7 @@
token: data.AccessToken,
userName: data.User.Name
}));
document.getElementById('userInfo').textContent = '(Signed in as ' + data.User.Name + ')';
document.getElementById('srfrecUserInfo').textContent = '(Signed in as ' + data.User.Name + ')';
self.showMain();
})
.catch(function(err) {
@@ -151,8 +144,8 @@
},
showMain: function() {
document.getElementById('loginSection').style.display = 'none';
document.getElementById('mainSection').style.display = 'block';
document.getElementById('srfrecLoginSection').style.display = 'none';
document.getElementById('srfrecMainSection').style.display = 'block';
this.loadSchedule();
this.loadRecordings();
},
@@ -175,14 +168,14 @@
},
loadSchedule: function() {
var container = document.getElementById('scheduleContainer');
container.innerHTML = '<p class="msg">Loading schedule...</p>';
var container = document.getElementById('srfrecScheduleContainer');
container.innerHTML = '<p class="srfrec-msg">Loading schedule...</p>';
fetch(this.serverUrl + '/Plugins/SRFPlay/Recording/Schedule', { headers: this.getHeaders() })
.then(function(r) { return r.json(); })
.then(function(programs) {
if (!programs || programs.length === 0) {
container.innerHTML = '<p class="msg">No upcoming sport livestreams found.</p>';
container.innerHTML = '<p class="srfrec-msg">No upcoming sport livestreams found.</p>';
return;
}
@@ -194,7 +187,7 @@
html += '<td>' + (p.title || 'Unknown') + '</td>';
html += '<td>' + SRFRec.formatDate(p.validFrom || p.date) + '</td>';
html += '<td>' + SRFRec.formatDate(p.validTo) + '</td>';
html += '<td><button class="btn btn-record" onclick="SRFRec.scheduleRecording(\'' + encodeURIComponent(p.urn) + '\')">Record</button></td>';
html += '<td><button type="button" class="btn btn-record" onclick="SRFRec.scheduleRecording(\'' + encodeURIComponent(p.urn) + '\')">Record</button></td>';
html += '</tr>';
});
@@ -202,7 +195,7 @@
container.innerHTML = html;
})
.catch(function(err) {
container.innerHTML = '<p class="error">Error loading schedule: ' + err.message + '</p>';
container.innerHTML = '<p class="srfrec-error">Error loading schedule: ' + err.message + '</p>';
});
},
@@ -229,8 +222,8 @@
},
loadActiveRecordings: function() {
var container = document.getElementById('activeRecordingsContainer');
container.innerHTML = '<p class="msg">Loading...</p>';
var container = document.getElementById('srfrecActiveRecordingsContainer');
container.innerHTML = '<p class="srfrec-msg">Loading...</p>';
fetch(this.serverUrl + '/Plugins/SRFPlay/Recording/All', { headers: this.getHeaders() })
.then(function(r) { return r.json(); })
@@ -241,7 +234,7 @@
});
if (active.length === 0) {
container.innerHTML = '<p class="msg">No scheduled or active recordings.</p>';
container.innerHTML = '<p class="srfrec-msg">No scheduled or active recordings.</p>';
return;
}
@@ -265,9 +258,9 @@
html += '<td>' + SRFRec.formatDate(r.validFrom) + '</td>';
html += '<td>';
if (r.state === 2 || r.state === 'Recording') {
html += '<button class="btn btn-stop" onclick="SRFRec.stopRecording(\'' + r.id + '\')">Stop</button>';
html += '<button type="button" class="btn btn-stop" onclick="SRFRec.stopRecording(\'' + r.id + '\')">Stop</button>';
} else {
html += '<button class="btn btn-cancel" onclick="SRFRec.cancelRecording(\'' + r.id + '\')">Cancel</button>';
html += '<button type="button" class="btn btn-cancel" onclick="SRFRec.cancelRecording(\'' + r.id + '\')">Cancel</button>';
}
html += '</td></tr>';
});
@@ -276,19 +269,19 @@
container.innerHTML = html;
})
.catch(function(err) {
container.innerHTML = '<p class="error">Error: ' + err.message + '</p>';
container.innerHTML = '<p class="srfrec-error">Error: ' + err.message + '</p>';
});
},
loadCompletedRecordings: function() {
var container = document.getElementById('completedRecordingsContainer');
container.innerHTML = '<p class="msg">Loading...</p>';
var container = document.getElementById('srfrecCompletedRecordingsContainer');
container.innerHTML = '<p class="srfrec-msg">Loading...</p>';
fetch(this.serverUrl + '/Plugins/SRFPlay/Recording/Completed', { headers: this.getHeaders() })
.then(function(r) { return r.json(); })
.then(function(recordings) {
if (!recordings || recordings.length === 0) {
container.innerHTML = '<p class="msg">No completed recordings.</p>';
container.innerHTML = '<p class="srfrec-msg">No completed recordings.</p>';
return;
}
@@ -300,7 +293,7 @@
html += '<td>' + (r.title || 'Unknown') + '</td>';
html += '<td>' + SRFRec.formatDate(r.recordingStartedAt) + '</td>';
html += '<td>' + SRFRec.formatSize(r.fileSizeBytes) + '</td>';
html += '<td><button class="btn btn-delete" onclick="SRFRec.deleteRecording(\'' + r.id + '\')">Delete</button></td>';
html += '<td><button type="button" class="btn btn-delete" onclick="SRFRec.deleteRecording(\'' + r.id + '\')">Delete</button></td>';
html += '</tr>';
});
@@ -308,7 +301,7 @@
container.innerHTML = html;
})
.catch(function(err) {
container.innerHTML = '<p class="error">Error: ' + err.message + '</p>';
container.innerHTML = '<p class="srfrec-error">Error: ' + err.message + '</p>';
});
},
@@ -338,7 +331,16 @@
}
};
(function() {
var page = document.querySelector('#SRFPlayRecordingsPage');
if (page && typeof page.addEventListener === 'function' && typeof ApiClient !== 'undefined') {
// Inside the Jellyfin dashboard: defer to the page lifecycle so we
// never run during the config page's view and never hijack routing.
page.addEventListener('pageshow', function() { SRFRec.init(); });
} else {
// Standalone (e.g. TV/browser): initialise immediately.
SRFRec.init();
}
})();
</script>
</body>
</html>
</div>
@@ -40,6 +40,7 @@ public class RecordingController : ControllerBase
/// </summary>
/// <returns>The recording manager HTML page.</returns>
[HttpGet("Page")]
[AllowAnonymous]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public IActionResult GetRecordingPage()
@@ -33,6 +33,11 @@
<ItemGroup>
<None Remove="..\assests\main 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>
</Project>
+6 -9
View File
@@ -45,16 +45,13 @@ public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
{
Name = Name,
EmbeddedResourcePath = string.Format(CultureInfo.InvariantCulture, "{0}.Configuration.configPage.html", GetType().Namespace)
},
new PluginPageInfo
{
Name = "SRF Play Recordings",
DisplayName = "SRF Sport Recordings",
EmbeddedResourcePath = string.Format(CultureInfo.InvariantCulture, "{0}.Configuration.recordingPage.html", GetType().Namespace),
EnableInMainMenu = true,
MenuSection = "Live TV",
MenuIcon = "fiber_smart_record"
}
// Note: the recordings manager is intentionally NOT registered as a plugin
// page. Plugin pages live under the admin Dashboard and are admin-gated, which
// both caused a redirect to the recordings page and made it inaccessible to
// normal users. Recordings are served as a standalone, user-accessible page at
// GET /Plugins/SRFPlay/Recording/Page (see RecordingController).
];
}
}
@@ -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
// 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)
{
_logger.LogInformation("Refreshing latest content");
_logger.LogInformation("Refreshing latest content for {BusinessUnit}", businessUnit);
progress?.Report(25);
await _contentRefreshService.RefreshLatestContentAsync(cancellationToken).ConfigureAwait(false);
await _contentRefreshService.RefreshLatestContentAsync(businessUnit, cancellationToken).ConfigureAwait(false);
}
// Refresh trending content
if (config.EnableTrendingContent)
{
_logger.LogInformation("Refreshing trending content");
_logger.LogInformation("Refreshing trending content for {BusinessUnit}", businessUnit);
progress?.Report(75);
await _contentRefreshService.RefreshTrendingContentAsync(cancellationToken).ConfigureAwait(false);
await _contentRefreshService.RefreshTrendingContentAsync(businessUnit, cancellationToken).ConfigureAwait(false);
}
}
progress?.Report(100);
@@ -49,7 +49,13 @@ public class ServiceRegistrator : IPluginServiceRegistrator
serviceCollection.AddSingleton<IScheduledTask, ExpirationCheckTask>();
serviceCollection.AddSingleton<IScheduledTask, RecordingSchedulerTask>();
// Register channel - must register as IChannel interface for Jellyfin to discover it
serviceCollection.AddSingleton<IChannel, SRFPlayChannel>();
// 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<IChannel, SrfChannel>();
serviceCollection.AddSingleton<IChannel, RtsChannel>();
serviceCollection.AddSingleton<IChannel, RsiChannel>();
serviceCollection.AddSingleton<IChannel, RtrChannel>();
serviceCollection.AddSingleton<IChannel, SwiChannel>();
}
}
@@ -18,8 +18,10 @@ public class CategoryService : ICategoryService
private readonly ILogger<CategoryService> _logger;
private readonly ISRFApiClientFactory _apiClientFactory;
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>
/// Initializes a new instance of the <see cref="CategoryService"/> class.
@@ -35,11 +37,11 @@ public class CategoryService : ICategoryService
/// <inheritdoc />
public async Task<List<PlayV3Topic>> GetTopicsAsync(string businessUnit, CancellationToken cancellationToken = default)
{
// Return cached topics if still valid
if (_topicsCache != null && DateTime.UtcNow < _topicsCacheExpiry)
// Return cached topics for this business unit if still valid
if (_topicsCacheByUnit.TryGetValue(businessUnit, out var cached) && DateTime.UtcNow < cached.Expiry)
{
_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);
@@ -48,13 +50,13 @@ public class CategoryService : ICategoryService
if (topics != null && topics.Count > 0)
{
// Cache topics by ID for quick lookups
_topicsCache = topics
// Cache topics by ID for quick lookups, scoped to this business unit
var byId = topics
.Where(t => !string.IsNullOrEmpty(t.Id))
.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>();
@@ -63,13 +65,14 @@ public class CategoryService : ICategoryService
/// <inheritdoc />
public async Task<PlayV3Topic?> GetTopicByIdAsync(string topicId, string businessUnit, CancellationToken cancellationToken = default)
{
// Ensure topics are loaded
if (_topicsCache == null || DateTime.UtcNow >= _topicsCacheExpiry)
// Ensure topics are loaded for this business unit
if (!_topicsCacheByUnit.TryGetValue(businessUnit, out var cached) || DateTime.UtcNow >= cached.Expiry)
{
await GetTopicsAsync(businessUnit, cancellationToken).ConfigureAwait(false);
_topicsCacheByUnit.TryGetValue(businessUnit, out cached);
}
return _topicsCache?.GetValueOrDefault(topicId);
return cached.Topics?.GetValueOrDefault(topicId);
}
/// <inheritdoc />
@@ -101,8 +104,7 @@ public class CategoryService : ICategoryService
/// <inheritdoc />
public void ClearCache()
{
_topicsCache = null;
_topicsCacheExpiry = DateTime.MinValue;
_topicsCacheByUnit.Clear();
_logger.LogInformation("Topics cache cleared");
}
@@ -34,9 +34,10 @@ public class ContentRefreshService : IContentRefreshService
/// <summary>
/// Refreshes latest content from SRF API using Play v3.
/// </summary>
/// <param name="businessUnit">The business unit to fetch content for (e.g. "srf", "rts").</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <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;
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.
/// </summary>
/// <param name="businessUnit">The business unit to fetch content for (e.g. "srf", "rts").</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <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;
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,
@@ -12,14 +12,16 @@ public interface IContentRefreshService
/// <summary>
/// Refreshes latest content from SRF API using Play v3.
/// </summary>
/// <param name="businessUnit">The business unit to fetch content for (e.g. "srf", "rts").</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>List of URNs for new content.</returns>
Task<List<string>> RefreshLatestContentAsync(CancellationToken cancellationToken);
Task<List<string>> RefreshLatestContentAsync(string businessUnit, CancellationToken cancellationToken);
/// <summary>
/// Refreshes trending content from SRF API using Play v3.
/// </summary>
/// <param name="businessUnit">The business unit to fetch content for (e.g. "srf", "rts").</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>List of URNs for trending content.</returns>
Task<List<string>> RefreshTrendingContentAsync(CancellationToken cancellationToken);
Task<List<string>> RefreshTrendingContentAsync(string businessUnit, CancellationToken cancellationToken);
}
@@ -62,9 +62,15 @@ public class MediaSourceFactory : IMediaSourceFactory
return Task.FromResult<MediaSourceInfo?>(null);
}
// Detect if this is a live stream
var isLiveStream = chapter.Type == "SCHEDULED_LIVESTREAM" ||
// A scheduled livestream is only treated as live when the broadcast window is open.
// Past replays (ValidTo in the past) and upcoming events (ValidFrom in the future)
// are treated as VOD to avoid IsInfiniteStream/IgnoreDts flags and FFmpeg -re mode.
var isScheduledLivestream = chapter.Type == "SCHEDULED_LIVESTREAM" ||
urn.Contains("livestream", StringComparison.OrdinalIgnoreCase);
var now = DateTime.UtcNow;
var isLiveStream = isScheduledLivestream &&
(chapter.ValidFrom == null || chapter.ValidFrom.Value.ToUniversalTime() <= now) &&
(chapter.ValidTo == null || chapter.ValidTo.Value.ToUniversalTime() > now);
// Register stream with UNAUTHENTICATED URL - proxy will authenticate on-demand
// This avoids wasting 30-second tokens during category browsing
@@ -148,18 +148,25 @@ public class RecordingService : IRecordingService, IDisposable
public async Task<IReadOnlyList<PlayV3TvProgram>> 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<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
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;
}
/// <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 />
public bool CancelRecording(string recordingId)
{
+7
View File
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<packageSources>
<clear />
<add key="nuget.org" value="https://api.nuget.org/v3/index.json" protocolVersion="3" />
</packageSources>
</configuration>
Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

+58 -2
View File
@@ -8,13 +8,69 @@
"category": "Live TV",
"imageUrl": "https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/raw/branch/master/assests/main%20logo.png",
"versions": [
{
"version": "1.0.32",
"changelog": "Release 1.0.32",
"targetAbi": "10.9.0.0",
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/releases/download/v1.0.32/srfplay_1.0.32.0.zip",
"checksum": "82f53848f2e2b15a35ea731ee69ee402",
"timestamp": "2026-06-27T07:16:38Z"
},
{
"version": "0.0.0.0",
"changelog": "Latest Build",
"targetAbi": "10.9.0.0",
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/releases/download/latest/srfplay_1.0.0.0.zip",
"checksum": "a58587b0c596992eb059885b20246d21",
"timestamp": "2026-03-07T17:41:15Z"
"checksum": "a3848128ff37d68f97493c33538a4d25",
"timestamp": "2026-06-27T07:15:26Z"
},
{
"version": "1.0.31",
"changelog": "Release 1.0.31",
"targetAbi": "10.9.0.0",
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/releases/download/v1.0.31/srfplay_1.0.31.0.zip",
"checksum": "4e499de15687e6e328ddca41c6192485",
"timestamp": "2026-06-27T06:54:01Z"
},
{
"version": "0.0.0.0",
"changelog": "Latest Build",
"targetAbi": "10.9.0.0",
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/releases/download/latest/srfplay_1.0.0.0.zip",
"checksum": "1a8b8b018ff6bcdb8da4783b74253fc4",
"timestamp": "2026-06-27T06:52:52Z"
},
{
"version": "1.0.30",
"changelog": "Release 1.0.30",
"targetAbi": "10.9.0.0",
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/releases/download/v1.0.30/srfplay_1.0.30.0.zip",
"checksum": "06731df9ba3d2dab53885c9c8ac95fa6",
"timestamp": "2026-05-03T16:44:57Z"
},
{
"version": "0.0.0.0",
"changelog": "Latest Build",
"targetAbi": "10.9.0.0",
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/releases/download/latest/srfplay_1.0.0.0.zip",
"checksum": "834e9f57206eee47ec9607ef6f65d17b",
"timestamp": "2026-05-03T16:39:53Z"
},
{
"version": "1.0.29",
"changelog": "Release 1.0.29",
"targetAbi": "10.9.0.0",
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/releases/download/v1.0.29/srfplay_1.0.29.0.zip",
"checksum": "fb745388e64299497262d9ad370d8823",
"timestamp": "2026-05-03T16:07:24Z"
},
{
"version": "0.0.0.0",
"changelog": "Latest Build",
"targetAbi": "10.9.0.0",
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/releases/download/latest/srfplay_1.0.0.0.zip",
"checksum": "7471a63c69deb5b9a31343bb6c49075f",
"timestamp": "2026-05-03T16:05:37Z"
},
{
"version": "1.0.27",