Compare commits

...

17 Commits

Author SHA1 Message Date
Gitea Actions
a9db0aa09c Nightly: 1.0.20260627.189 (7c81ef9) 2026-06-27 13:46:17 +00:00
7c81ef94ca fix Start stream 3 seconds behind edge
All checks were successful
🏗️ Build Plugin / build (push) Successful in 33s
Nightly Build / nightly-build (push) Successful in 44s
🧪 Test Plugin / test (push) Successful in 29s
2026-06-27 15:44:56 +02:00
Gitea Actions
f715130302 Update manifest.json for version 1.1.0 2026-06-27 09:33:05 +00:00
Gitea Actions
affc87bd38 Nightly: 1.0.20260627.185 (1101385)
All checks were successful
🚀 Release Plugin / build-and-release (push) Successful in 31s
2026-06-27 09:30:57 +00:00
1101385107 Really fix CI
All checks were successful
🏗️ Build Plugin / build (push) Successful in 36s
Nightly Build / nightly-build (push) Successful in 43s
🧪 Test Plugin / test (push) Successful in 28s
2026-06-27 11:29:32 +02:00
4fc79f39f7 Fix CI
Some checks failed
🏗️ Build Plugin / build (push) Successful in 41s
Nightly Build / nightly-build (push) Failing after 35s
🧪 Test Plugin / test (push) Successful in 30s
2026-06-27 11:23:53 +02:00
5875f81b9b fix correct guid
Some checks failed
🏗️ Build Plugin / build (push) Successful in 42s
Nightly Build / nightly-build (push) Failing after 35s
🧪 Test Plugin / test (push) Successful in 28s
2026-06-27 11:18:48 +02:00
cedef6d6aa Add nightly job
Some checks failed
🏗️ Build Plugin / build (push) Successful in 29s
Nightly Build / nightly-build (push) Has been cancelled
🧪 Test Plugin / test (push) Has been cancelled
2026-06-27 11:17:57 +02:00
4b5d7e2a7f info add github cross-login info 2026-06-27 11:17:57 +02:00
Gitea Actions
8281960f0b Update manifest.json for latest build (7a719ee) 2026-06-27 09:01:57 +00:00
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
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
c2feaae89c Fix really fix redirect
All checks were successful
🏗️ 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
34 changed files with 636 additions and 132 deletions

View File

@ -36,6 +36,29 @@ jobs:
working-directory: build-${{ github.run_id }} working-directory: build-${{ github.run_id }}
run: dotnet restore Jellyfin.Plugin.SRFPlay.sln run: dotnet restore Jellyfin.Plugin.SRFPlay.sln
- name: Compute build version
id: version
run: |
# For PRs, stamp a distinct version so a side-loaded build is
# identifiable in Jellyfin: 1.0.<YYYYMMDD>-pr<N>.<run_number>.
# For plain master pushes, keep a date-based dev version.
DATE=$(date -u +"%Y%m%d")
if [ -n "${{ github.event.pull_request.number }}" ]; then
VERSION="1.0.${DATE}.${{ github.run_number }}"
LABEL="pr${{ github.event.pull_request.number }}"
else
VERSION="1.0.${DATE}.${{ github.run_number }}"
LABEL="master"
fi
echo "version=${VERSION}" >> $GITHUB_OUTPUT
echo "label=${LABEL}" >> $GITHUB_OUTPUT
echo "Build version: ${VERSION} (${LABEL})"
- name: Set build version
working-directory: build-${{ github.run_id }}
run: |
sed -i "s/^version:.*/version: \"${{ steps.version.outputs.version }}\"/" build.yaml
- name: Build solution - name: Build solution
working-directory: build-${{ github.run_id }} working-directory: build-${{ github.run_id }}
run: dotnet build Jellyfin.Plugin.SRFPlay.sln --configuration Release --no-restore --no-self-contained /m:1 run: dotnet build Jellyfin.Plugin.SRFPlay.sln --configuration Release --no-restore --no-self-contained /m:1
@ -59,7 +82,7 @@ jobs:
- name: Upload build artifact - name: Upload build artifact
uses: actions/upload-artifact@v3 uses: actions/upload-artifact@v3
with: with:
name: jellyfin-srfplay-plugin name: srfplay-${{ steps.version.outputs.label }}-${{ steps.version.outputs.version }}
path: build-${{ github.run_id }}/${{ steps.jprm.outputs.artifact }} path: build-${{ github.run_id }}/${{ steps.jprm.outputs.artifact }}
retention-days: 30 retention-days: 30
if-no-files-found: error if-no-files-found: error

View File

@ -1,4 +1,4 @@
name: 'Latest Release' name: 'Nightly Build'
on: on:
push: push:
@ -7,9 +7,10 @@ on:
paths-ignore: paths-ignore:
- '**/*.md' - '**/*.md'
- 'manifest.json' - 'manifest.json'
- 'manifest-nightly.json'
jobs: jobs:
latest-release: nightly-build:
runs-on: linux/amd64 runs-on: linux/amd64
container: container:
image: gitea.tourolle.paris/dtourolle/srfplay-builder:latest image: gitea.tourolle.paris/dtourolle/srfplay-builder:latest
@ -20,6 +21,24 @@ jobs:
with: with:
path: build-${{ github.run_id }} path: build-${{ github.run_id }}
- name: Compute nightly version
id: version
run: |
# Date-based nightly version: 1.0.<YYYYMMDD>.<run_number>
# The run_number suffix keeps multiple builds on the same day
# monotonically increasing so Jellyfin always offers the newest.
DATE=$(date -u +"%Y%m%d")
VERSION="1.0.${DATE}.${{ github.run_number }}"
echo "version=${VERSION}" >> $GITHUB_OUTPUT
echo "Nightly version: ${VERSION}"
- name: Set build version
working-directory: build-${{ github.run_id }}
run: |
VERSION="${{ steps.version.outputs.version }}"
sed -i "s/^version:.*/version: \"${VERSION}\"/" build.yaml
cat build.yaml
- name: Restore dependencies - name: Restore dependencies
working-directory: build-${{ github.run_id }} working-directory: build-${{ github.run_id }}
run: dotnet restore Jellyfin.Plugin.SRFPlay.sln run: dotnet restore Jellyfin.Plugin.SRFPlay.sln
@ -52,7 +71,7 @@ jobs:
echo "checksum=${CHECKSUM}" >> $GITHUB_OUTPUT echo "checksum=${CHECKSUM}" >> $GITHUB_OUTPUT
echo "Checksum: ${CHECKSUM}" echo "Checksum: ${CHECKSUM}"
- name: Delete existing latest release - name: Delete existing nightly release
working-directory: build-${{ github.run_id }} working-directory: build-${{ github.run_id }}
env: env:
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@ -71,7 +90,7 @@ jobs:
-H "Authorization: token ${GITEA_TOKEN}" \ -H "Authorization: token ${GITEA_TOKEN}" \
"${GITEA_URL}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}/releases/tags/${TAG}" | jq -r '.id') "${GITEA_URL}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}/releases/tags/${TAG}" | jq -r '.id')
echo "Deleting existing latest release (ID: ${RELEASE_ID})..." echo "Deleting existing nightly release (ID: ${RELEASE_ID})..."
curl -s -X DELETE \ curl -s -X DELETE \
-H "Authorization: token ${GITEA_TOKEN}" \ -H "Authorization: token ${GITEA_TOKEN}" \
"${GITEA_URL}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}/releases/${RELEASE_ID}" "${GITEA_URL}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}/releases/${RELEASE_ID}"
@ -81,7 +100,7 @@ jobs:
-H "Authorization: token ${GITEA_TOKEN}" \ -H "Authorization: token ${GITEA_TOKEN}" \
"${GITEA_URL}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}/tags/${TAG}" || true "${GITEA_URL}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}/tags/${TAG}" || true
- name: Create latest release - name: Create nightly release
working-directory: build-${{ github.run_id }} working-directory: build-${{ github.run_id }}
env: env:
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@ -89,12 +108,14 @@ jobs:
REPO_OWNER="${{ github.repository_owner }}" REPO_OWNER="${{ github.repository_owner }}"
REPO_NAME="${{ github.event.repository.name }}" REPO_NAME="${{ github.event.repository.name }}"
GITEA_URL="${{ github.server_url }}" GITEA_URL="${{ github.server_url }}"
VERSION="${{ steps.version.outputs.version }}"
SHORT_SHA=$(echo "${{ github.sha }}" | cut -c1-7)
RESPONSE=$(curl -s -w "\n%{http_code}" -X POST \ RESPONSE=$(curl -s -w "\n%{http_code}" -X POST \
-H "Authorization: token ${GITEA_TOKEN}" \ -H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
"${GITEA_URL}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}/releases" \ "${GITEA_URL}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}/releases" \
-d "$(jq -n --arg tag "latest" --arg name "Latest Build" --arg body "SRFPlay Jellyfin Plugin latest build from master." '{tag_name: $tag, name: $name, body: $body, target_commitish: "master", draft: false, prerelease: true}')") -d "$(jq -n --arg tag "latest" --arg name "Nightly Build ${VERSION}" --arg body "SRFPlay Jellyfin Plugin nightly build ${VERSION} from master (${SHORT_SHA})." '{tag_name: $tag, name: $name, body: $body, target_commitish: "master", draft: false, prerelease: true}')")
HTTP_CODE=$(echo "$RESPONSE" | tail -n1) HTTP_CODE=$(echo "$RESPONSE" | tail -n1)
BODY=$(echo "$RESPONSE" | sed '$d') BODY=$(echo "$RESPONSE" | sed '$d')
@ -124,9 +145,9 @@ jobs:
--data-binary "@build.yaml" \ --data-binary "@build.yaml" \
"${GITEA_URL}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}/releases/${RELEASE_ID}/assets?name=build.yaml" "${GITEA_URL}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}/releases/${RELEASE_ID}/assets?name=build.yaml"
echo "Latest release updated successfully!" echo "Nightly release updated successfully!"
- name: Update manifest.json - name: Update manifest-nightly.json
working-directory: build-${{ github.run_id }} working-directory: build-${{ github.run_id }}
env: env:
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@ -134,6 +155,7 @@ jobs:
REPO_OWNER="${{ github.repository_owner }}" REPO_OWNER="${{ github.repository_owner }}"
REPO_NAME="${{ github.event.repository.name }}" REPO_NAME="${{ github.event.repository.name }}"
GITEA_URL="${{ github.server_url }}" GITEA_URL="${{ github.server_url }}"
VERSION="${{ steps.version.outputs.version }}"
CHECKSUM="${{ steps.checksum.outputs.checksum }}" CHECKSUM="${{ steps.checksum.outputs.checksum }}"
ARTIFACT_NAME="${{ steps.jprm.outputs.artifact_name }}" ARTIFACT_NAME="${{ steps.jprm.outputs.artifact_name }}"
TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ") TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
@ -145,13 +167,10 @@ jobs:
git fetch origin master git fetch origin master
git checkout master git checkout master
# Remove existing "latest" entry if present, then prepend new one
jq --arg url "$DOWNLOAD_URL" 'if .[0].versions[0].changelog == "Latest Build" then .[0].versions = .[0].versions[1:] else . end' manifest.json > manifest.tmp && mv manifest.tmp manifest.json
NEW_VERSION=$(cat <<EOF NEW_VERSION=$(cat <<EOF
{ {
"version": "0.0.0.0", "version": "${VERSION}",
"changelog": "Latest Build", "changelog": "Nightly build ${VERSION} (${SHORT_SHA})",
"targetAbi": "10.9.0.0", "targetAbi": "10.9.0.0",
"sourceUrl": "${DOWNLOAD_URL}", "sourceUrl": "${DOWNLOAD_URL}",
"checksum": "${CHECKSUM}", "checksum": "${CHECKSUM}",
@ -160,9 +179,13 @@ jobs:
EOF EOF
) )
jq --argjson newver "${NEW_VERSION}" '.[0].versions = [$newver] + .[0].versions' manifest.json > manifest.tmp && mv manifest.tmp manifest.json # Prepend the new build and keep only the most recent 5 nightlies.
git add manifest.json jq --argjson newver "${NEW_VERSION}" \
git commit -m "Update manifest.json for latest build (${SHORT_SHA})" || echo "No changes to commit" '.[0].versions = ([$newver] + .[0].versions)[0:5]' \
manifest-nightly.json > manifest.tmp && mv manifest.tmp manifest-nightly.json
git add manifest-nightly.json
git commit -m "Nightly: ${VERSION} (${SHORT_SHA})" || echo "No changes to commit"
git push origin master git push origin master
- name: Cleanup - name: Cleanup

17
Directory.Build.targets Normal file
View File

@ -0,0 +1,17 @@
<Project>
<!--
The Jellyfin meta build workflow rewrites Directory.Build.props, stamping Version,
AssemblyVersion and FileVersion all to a date-based value (e.g. 1.0.20260627.182).
The build segment (20260627) exceeds the 16-bit limit (0-65535) that AssemblyVersion
and FileVersion require, which fails the compile (CS7034/CS7035).
Directory.Build.targets is imported AFTER the project (and after Directory.Build.props),
and the workflow does not touch it, so we force valid assembly/file versions here. The
package/plugin manifest Version stays as injected; only the .NET assembly identity is
normalised, which is fine because Jellyfin identifies plugins by GUID + manifest version.
-->
<PropertyGroup>
<AssemblyVersion>1.0.0.0</AssemblyVersion>
<FileVersion>1.0.0.0</FileVersion>
</PropertyGroup>
</Project>

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

@ -20,7 +20,6 @@ namespace Jellyfin.Plugin.SRFPlay.Api;
/// </summary> /// </summary>
public class SRFApiClient : IDisposable public class SRFApiClient : IDisposable
{ {
private static readonly System.Text.CompositeFormat PlayV3UrlFormat = System.Text.CompositeFormat.Parse(ApiEndpoints.PlayV3BaseUrlTemplate);
private readonly HttpClient _httpClient; private readonly HttpClient _httpClient;
private readonly HttpClient _playV3HttpClient; private readonly HttpClient _playV3HttpClient;
private readonly ILogger _logger; private readonly ILogger _logger;
@ -58,6 +57,21 @@ public class SRFApiClient : IDisposable
}; };
} }
/// <summary>
/// Builds the Play v3 API base URL for a business unit. The host is normally
/// www.&lt;unit&gt;.ch, but SWI's site lives at www.swissinfo.ch (www.swi.ch is an
/// unrelated welding institute), while the API path segment stays the unit code.
/// </summary>
/// <param name="businessUnit">The lowercase business unit (e.g. "srf", "swi").</param>
/// <returns>The Play v3 production base URL ending in a slash.</returns>
private static string BuildPlayV3BaseUrl(string businessUnit)
{
var host = string.Equals(businessUnit, "swi", StringComparison.OrdinalIgnoreCase)
? "www.swissinfo.ch"
: $"www.{businessUnit}.ch";
return $"https://{host}/play/v3/api/{businessUnit}/production/";
}
/// <summary> /// <summary>
/// Reads HTTP response content as UTF-8 string. /// Reads HTTP response content as UTF-8 string.
/// </summary> /// </summary>
@ -315,7 +329,7 @@ public class SRFApiClient : IDisposable
{ {
try try
{ {
var baseUrl = string.Format(CultureInfo.InvariantCulture, PlayV3UrlFormat, businessUnit); var baseUrl = BuildPlayV3BaseUrl(businessUnit);
var url = $"{baseUrl}{endpoint}"; var url = $"{baseUrl}{endpoint}";
_logger.LogInformation("Fetching all {Endpoint} for business unit: {BusinessUnit} from URL: {Url}", endpoint, businessUnit, url); _logger.LogInformation("Fetching all {Endpoint} for business unit: {BusinessUnit} from URL: {Url}", endpoint, businessUnit, url);
@ -352,7 +366,7 @@ public class SRFApiClient : IDisposable
{ {
try try
{ {
var baseUrl = string.Format(CultureInfo.InvariantCulture, PlayV3UrlFormat, businessUnit); var baseUrl = BuildPlayV3BaseUrl(businessUnit);
var url = $"{baseUrl}videos-by-show-id?showId={showId}"; var url = $"{baseUrl}videos-by-show-id?showId={showId}";
_logger.LogDebug("Fetching videos for show {ShowId} from business unit: {BusinessUnit}", showId, businessUnit); _logger.LogDebug("Fetching videos for show {ShowId} from business unit: {BusinessUnit}", showId, businessUnit);
@ -392,7 +406,7 @@ public class SRFApiClient : IDisposable
{ {
try try
{ {
var baseUrl = string.Format(CultureInfo.InvariantCulture, PlayV3UrlFormat, businessUnit); var baseUrl = BuildPlayV3BaseUrl(businessUnit);
var url = $"{baseUrl}livestreams?eventType={eventType.ToUpperInvariant()}"; var url = $"{baseUrl}livestreams?eventType={eventType.ToUpperInvariant()}";
_logger.LogInformation("Fetching scheduled livestreams for eventType={EventType} from business unit: {BusinessUnit}", eventType, businessUnit); _logger.LogInformation("Fetching scheduled livestreams for eventType={EventType} from business unit: {BusinessUnit}", eventType, businessUnit);

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)
{ {
@ -198,7 +211,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 +252,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 +319,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 +329,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 +390,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 +493,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

@ -75,10 +75,12 @@ public class PluginConfiguration : BasePluginConfiguration
EnableCategoryFolders = true; EnableCategoryFolders = true;
EnabledTopics = new System.Collections.Generic.List<string>(); EnabledTopics = new System.Collections.Generic.List<string>();
GenerateTitleCards = true; GenerateTitleCards = true;
LiveStartSegmentsBack = 3;
} }
/// <summary> /// <summary>
/// Gets or sets the business unit to fetch content from. /// Gets or sets the legacy single business unit. Retained only for backwards compatibility
/// with older configs; every unit now has its own always-on channel.
/// </summary> /// </summary>
public BusinessUnit BusinessUnit { get; set; } public BusinessUnit BusinessUnit { get; set; }
@ -161,4 +163,14 @@ 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>
/// Gets or sets how many segments back from the live edge livestream playback should start.
/// Injected as <c>#EXT-X-START:TIME-OFFSET=-(N * targetDuration)</c> into the live media
/// playlist. This keeps the start point out of the volatile live edge, which on Android TV
/// (ExoPlayer) otherwise causes stalling/jumping until a manual skip. RFC 8216 requires the
/// offset to stay at least 3 target durations from the edge, so values below 3 are clamped.
/// Set to 0 to disable injection entirely.
/// </summary>
public int LiveStartSegmentsBack { get; set; }
} }

View File

@ -2,16 +2,9 @@
<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</h3>
<select is="emby-select" id="BusinessUnit" name="BusinessUnit" class="emby-select-withcolor emby-select"> <div class="fieldDescription">Every Swiss broadcaster has its own channel tile: SRF (German), RTS (French), RSI (Italian), RTR (Romansh) and SWI (swissinfo.ch). They are always available — pin the ones you use from the Jellyfin home screen.</div>
<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> </div>
<div class="selectContainer"> <div class="selectContainer">
<label class="selectLabel" for="QualityPreference">Quality Preference</label> <label class="selectLabel" for="QualityPreference">Quality Preference</label>
@ -89,6 +82,11 @@
<input id="RecordingOutputPath" name="RecordingOutputPath" type="text" is="emby-input" placeholder="e.g., /media/recordings/srf" /> <input id="RecordingOutputPath" name="RecordingOutputPath" type="text" is="emby-input" placeholder="e.g., /media/recordings/srf" />
<div class="fieldDescription">Directory where sport livestream recordings will be saved (requires ffmpeg)</div> <div class="fieldDescription">Directory where sport livestream recordings will be saved (requires ffmpeg)</div>
</div> </div>
<div class="inputContainer">
<label class="inputLabel inputLabelUnfocused" for="LiveStartSegmentsBack">Live Start Offset (segments)</label>
<input id="LiveStartSegmentsBack" name="LiveStartSegmentsBack" type="number" is="emby-input" min="0" max="20" />
<div class="fieldDescription">How many segments back from the live edge livestreams start playing. Fixes jumpy/stalling playback at the start on Android TV. Minimum 3 (RFC requirement); 0 disables it. Default 3.</div>
</div>
<br /> <br />
<h2>Network Settings</h2> <h2>Network Settings</h2>
<div class="inputContainer"> <div class="inputContainer">
@ -142,7 +140,6 @@
.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;
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;
@ -156,6 +153,7 @@
document.querySelector('#ProxyPassword').value = config.ProxyPassword || ''; document.querySelector('#ProxyPassword').value = config.ProxyPassword || '';
document.querySelector('#PublicServerUrl').value = config.PublicServerUrl || ''; document.querySelector('#PublicServerUrl').value = config.PublicServerUrl || '';
document.querySelector('#RecordingOutputPath').value = config.RecordingOutputPath || ''; document.querySelector('#RecordingOutputPath').value = config.RecordingOutputPath || '';
document.querySelector('#LiveStartSegmentsBack').value = config.LiveStartSegmentsBack != null ? config.LiveStartSegmentsBack : 3;
Dashboard.hideLoadingMsg(); Dashboard.hideLoadingMsg();
// Load recordings UI // Load recordings UI
@ -168,7 +166,6 @@
.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.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);
@ -182,6 +179,7 @@
config.ProxyPassword = document.querySelector('#ProxyPassword').value; config.ProxyPassword = document.querySelector('#ProxyPassword').value;
config.PublicServerUrl = document.querySelector('#PublicServerUrl').value; config.PublicServerUrl = document.querySelector('#PublicServerUrl').value;
config.RecordingOutputPath = document.querySelector('#RecordingOutputPath').value; config.RecordingOutputPath = document.querySelector('#RecordingOutputPath').value;
config.LiveStartSegmentsBack = parseInt(document.querySelector('#LiveStartSegmentsBack').value) || 0;
ApiClient.updatePluginConfiguration(SRFPlayConfig.pluginUniqueId, config).then(function (result) { ApiClient.updatePluginConfiguration(SRFPlayConfig.pluginUniqueId, config).then(function (result) {
Dashboard.processPluginConfigurationUpdateResult(result); Dashboard.processPluginConfigurationUpdateResult(result);
}); });

View File

@ -40,6 +40,7 @@ public class RecordingController : ControllerBase
/// </summary> /// </summary>
/// <returns>The recording manager HTML page.</returns> /// <returns>The recording manager HTML page.</returns>
[HttpGet("Page")] [HttpGet("Page")]
[AllowAnonymous]
[ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status404NotFound)]
public IActionResult GetRecordingPage() public IActionResult GetRecordingPage()

View File

@ -225,7 +225,8 @@ public class StreamProxyController : ControllerBase
queryParams = string.Empty; queryParams = string.Empty;
} }
var rewrittenContent = _proxyService.RewriteVariantManifestUrls(manifestContent, baseProxyUrl, queryParams); var isLiveStream = _proxyService.GetStreamMetadata(actualItemId)?.IsLiveStream ?? false;
var rewrittenContent = _proxyService.RewriteVariantManifestUrls(manifestContent, baseProxyUrl, queryParams, isLiveStream);
// Set cache headers based on stream type (live vs VOD) // Set cache headers based on stream type (live vs VOD)
// Variant manifests use stricter no-cache for live streams // Variant manifests use stricter no-cache for live streams

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

@ -45,16 +45,13 @@ public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
{ {
Name = Name, Name = Name,
EmbeddedResourcePath = string.Format(CultureInfo.InvariantCulture, "{0}.Configuration.configPage.html", GetType().Namespace) 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).
]; ];
} }
} }

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 business unit (one channel per unit).
if (config.EnableLatestContent) var units = System.Enum.GetValues<Configuration.BusinessUnit>();
foreach (var unit in units)
{ {
_logger.LogInformation("Refreshing latest content"); var businessUnit = unit.ToLowerString();
progress?.Report(25);
await _contentRefreshService.RefreshLatestContentAsync(cancellationToken).ConfigureAwait(false);
}
// Refresh trending content if (config.EnableLatestContent)
if (config.EnableTrendingContent) {
{ _logger.LogInformation("Refreshing latest content for {BusinessUnit}", businessUnit);
_logger.LogInformation("Refreshing trending content"); progress?.Report(25);
progress?.Report(75); await _contentRefreshService.RefreshLatestContentAsync(businessUnit, cancellationToken).ConfigureAwait(false);
await _contentRefreshService.RefreshTrendingContentAsync(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); 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

@ -71,8 +71,9 @@ public interface IStreamProxyService
/// <param name="manifestContent">The variant manifest content.</param> /// <param name="manifestContent">The variant manifest content.</param>
/// <param name="baseProxyUrl">The base proxy URL (without query params).</param> /// <param name="baseProxyUrl">The base proxy URL (without query params).</param>
/// <param name="queryParams">Query parameters to append to rewritten URLs (e.g., "?token=abc").</param> /// <param name="queryParams">Query parameters to append to rewritten URLs (e.g., "?token=abc").</param>
/// <param name="isLiveStream">Whether this is a livestream; if so an EXT-X-START offset is injected.</param>
/// <returns>The rewritten manifest content.</returns> /// <returns>The rewritten manifest content.</returns>
string RewriteVariantManifestUrls(string manifestContent, string baseProxyUrl, string queryParams); string RewriteVariantManifestUrls(string manifestContent, string baseProxyUrl, string queryParams, bool isLiveStream = false);
/// <summary> /// <summary>
/// Cleans up old and expired stream mappings. /// Cleans up old and expired stream mappings.

View File

@ -147,19 +147,25 @@ public class RecordingService : IRecordingService, IDisposable
/// <inheritdoc /> /// <inheritdoc />
public async Task<IReadOnlyList<PlayV3TvProgram>> GetUpcomingScheduleAsync(CancellationToken cancellationToken) public async Task<IReadOnlyList<PlayV3TvProgram>> GetUpcomingScheduleAsync(CancellationToken cancellationToken)
{ {
var config = Plugin.Instance?.Configuration; var units = System.Enum.GetValues<Configuration.BusinessUnit>();
var businessUnit = (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);
if (livestreams == null) // Aggregate sport livestreams across every business unit so the recordings
// page shows events from all 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 +184,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 +198,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 +215,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)
{ {

View File

@ -900,9 +900,15 @@ public class StreamProxyService : IStreamProxyService
/// <param name="manifestContent">The variant manifest content.</param> /// <param name="manifestContent">The variant manifest content.</param>
/// <param name="baseProxyUrl">The base proxy URL (without query params).</param> /// <param name="baseProxyUrl">The base proxy URL (without query params).</param>
/// <param name="queryParams">Query parameters to append to rewritten URLs (e.g., "?token=abc").</param> /// <param name="queryParams">Query parameters to append to rewritten URLs (e.g., "?token=abc").</param>
/// <param name="isLiveStream">Whether this is a livestream; if so an EXT-X-START offset is injected.</param>
/// <returns>The rewritten manifest content.</returns> /// <returns>The rewritten manifest content.</returns>
public string RewriteVariantManifestUrls(string manifestContent, string baseProxyUrl, string queryParams) public string RewriteVariantManifestUrls(string manifestContent, string baseProxyUrl, string queryParams, bool isLiveStream = false)
{ {
if (isLiveStream)
{
manifestContent = InjectLiveStartOffset(manifestContent);
}
string RewriteUrl(string url) string RewriteUrl(string url)
{ {
if (url.Contains("://", StringComparison.Ordinal)) if (url.Contains("://", StringComparison.Ordinal))
@ -953,6 +959,82 @@ public class StreamProxyService : IStreamProxyService
return result.ToString(); return result.ToString();
} }
/// <summary>
/// Injects an <c>#EXT-X-START:TIME-OFFSET</c> tag into a live media playlist so that players
/// (notably ExoPlayer on Android TV) begin a few segments back from the volatile live edge
/// instead of at the edge itself, which otherwise causes stalling/jumping until a manual skip.
/// The offset is sized relative to the playlist's own <c>EXT-X-TARGETDURATION</c> to stay
/// RFC 8216 compliant (≥ 3 target durations from the edge for live playlists).
/// </summary>
/// <param name="manifestContent">The media playlist content.</param>
/// <returns>The playlist with the tag injected, or unchanged if it is not applicable.</returns>
private string InjectLiveStartOffset(string manifestContent)
{
var segmentsBack = Plugin.Instance?.Configuration?.LiveStartSegmentsBack ?? 3;
if (segmentsBack <= 0)
{
return manifestContent; // Disabled by config.
}
// Only a media playlist (has segments, no variant list) should carry EXT-X-START.
// A master/multivariant playlist or a VOD playlist (#EXT-X-ENDLIST) is left untouched.
if (!manifestContent.Contains("#EXTINF", StringComparison.Ordinal)
|| manifestContent.Contains("#EXT-X-STREAM-INF", StringComparison.Ordinal)
|| manifestContent.Contains("#EXT-X-ENDLIST", StringComparison.Ordinal))
{
return manifestContent;
}
// Don't add a second tag if the source already specified a start point.
if (manifestContent.Contains("#EXT-X-START", StringComparison.Ordinal))
{
_logger.LogDebug("Live playlist already contains #EXT-X-START; leaving as-is");
return manifestContent;
}
var targetDurationMatch = Regex.Match(manifestContent, @"#EXT-X-TARGETDURATION:(\d+(?:\.\d+)?)");
if (!targetDurationMatch.Success
|| !double.TryParse(
targetDurationMatch.Groups[1].Value,
System.Globalization.NumberStyles.Float,
System.Globalization.CultureInfo.InvariantCulture,
out var targetDuration)
|| targetDuration <= 0)
{
_logger.LogDebug("Could not read EXT-X-TARGETDURATION; skipping EXT-X-START injection");
return manifestContent;
}
// RFC 8216: TIME-OFFSET SHOULD NOT be within 3 target durations of the live edge.
var clampedSegments = Math.Max(3, segmentsBack);
var offsetSeconds = clampedSegments * targetDuration;
var startTag = string.Format(
System.Globalization.CultureInfo.InvariantCulture,
"#EXT-X-START:TIME-OFFSET=-{0:0.###},PRECISE=YES",
offsetSeconds);
_logger.LogInformation(
"Injecting {StartTag} into live playlist (targetDuration={TargetDuration}s, segmentsBack={SegmentsBack})",
startTag,
targetDuration,
clampedSegments);
// Insert after the #EXTM3U header so it sits with the other top-level tags.
var headerIndex = manifestContent.IndexOf("#EXTM3U", StringComparison.Ordinal);
if (headerIndex < 0)
{
return startTag + "\n" + manifestContent;
}
var lineEnd = manifestContent.IndexOf('\n', headerIndex);
if (lineEnd < 0)
{
return manifestContent + "\n" + startTag;
}
return manifestContent[..(lineEnd + 1)] + startTag + "\n" + manifestContent[(lineEnd + 1)..];
}
/// <summary> /// <summary>
/// Cleans up old and expired stream mappings. /// Cleans up old and expired stream mappings.
/// </summary> /// </summary>

View File

@ -270,7 +270,12 @@ The plugin includes:
### Contributing ### Contributing
Contributions welcome! Contributions welcome! This project is hosted on a self-hosted [Gitea](https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay) instance.
**You don't need a separate account** — you can sign in with your existing GitHub account. On the [sign-in page](https://gitea.tourolle.paris/user/login), choose **"Sign in with GitHub"** to register and log in via GitHub OAuth. Once signed in, you can:
- **Raise issues** — report bugs or request features on the [issue tracker](https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/issues)
- **Contribute code** — fork the repository, push a branch, and open a pull request
## License ## License

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

View File

@ -1,6 +1,6 @@
--- ---
name: "SRFPlay" name: "SRFPlay"
guid: "eb5d7894-8eef-4b36-aa6f-5d124e828ce1" guid: "a4b12f86-8c3d-4e9a-b7f2-1d5e6c8a9b4f"
version: "1.0.0.0" version: "1.0.0.0"
targetAbi: "10.9.0.0" targetAbi: "10.9.0.0"
framework: "net8.0" framework: "net8.0"

29
manifest-nightly.json Normal file
View File

@ -0,0 +1,29 @@
[
{
"guid": "a4b12f86-8c3d-4e9a-b7f2-1d5e6c8a9b4f",
"name": "SRF Play (Nightly)",
"description": "NIGHTLY/UNSTABLE builds of the SRF Play plugin for Jellyfin. Built automatically from the master branch on every push. Expect bugs. For stable releases use the regular SRF Play repository instead.",
"overview": "Nightly builds of SRF Play for Jellyfin",
"owner": "dtourolle",
"category": "Live TV",
"imageUrl": "https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/raw/branch/master/assests/main%20logo.png",
"versions": [
{
"version": "1.0.20260627.189",
"changelog": "Nightly build 1.0.20260627.189 (7c81ef9)",
"targetAbi": "10.9.0.0",
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/releases/download/latest/srfplay_1.0.20260627.189.zip",
"checksum": "553ac3f806bce24e5aae3b2c8d17395b",
"timestamp": "2026-06-27T13:46:17Z"
},
{
"version": "1.0.20260627.185",
"changelog": "Nightly build 1.0.20260627.185 (1101385)",
"targetAbi": "10.9.0.0",
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/releases/download/latest/srfplay_1.0.20260627.185.zip",
"checksum": "577af725ca202bec555bf51905b75913",
"timestamp": "2026-06-27T09:30:57Z"
}
]
}
]

View File

@ -9,20 +9,44 @@
"imageUrl": "https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/raw/branch/master/assests/main%20logo.png", "imageUrl": "https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/raw/branch/master/assests/main%20logo.png",
"versions": [ "versions": [
{ {
"version": "1.0.30", "version": "1.1.0",
"changelog": "Release 1.0.30", "changelog": "Release 1.1.0",
"targetAbi": "10.9.0.0", "targetAbi": "10.9.0.0",
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/releases/download/v1.0.30/srfplay_1.0.30.0.zip", "sourceUrl": "https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/releases/download/v1.1.0/srfplay_1.1.0.0.zip",
"checksum": "06731df9ba3d2dab53885c9c8ac95fa6", "checksum": "2094b00b29f79e7625fcd3ebf046674f",
"timestamp": "2026-05-03T16:44:57Z" "timestamp": "2026-06-27T09:33:04Z"
}, },
{ {
"version": "0.0.0.0", "version": "0.0.0.0",
"changelog": "Latest Build", "changelog": "Latest Build",
"targetAbi": "10.9.0.0", "targetAbi": "10.9.0.0",
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/releases/download/latest/srfplay_1.0.0.0.zip", "sourceUrl": "https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/releases/download/latest/srfplay_1.0.0.0.zip",
"checksum": "834e9f57206eee47ec9607ef6f65d17b", "checksum": "2ed3281990b56fbfb5f75a1ee82a2f55",
"timestamp": "2026-05-03T16:39:53Z" "timestamp": "2026-06-27T09:01:57Z"
},
{
"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": "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": "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": "1.0.29", "version": "1.0.29",
@ -32,14 +56,6 @@
"checksum": "fb745388e64299497262d9ad370d8823", "checksum": "fb745388e64299497262d9ad370d8823",
"timestamp": "2026-05-03T16:07:24Z" "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", "version": "1.0.27",
"changelog": "Release 1.0.27", "changelog": "Release 1.0.27",
@ -48,14 +64,6 @@
"checksum": "1e15e35452f7b82bf74d8c3560c15949", "checksum": "1e15e35452f7b82bf74d8c3560c15949",
"timestamp": "2026-03-07T16:40:17Z" "timestamp": "2026-03-07T16:40:17Z"
}, },
{
"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": "c7e868d23293adcc21d72e735094d9d6",
"timestamp": "2026-03-07T16:28:38Z"
},
{ {
"version": "1.0.25", "version": "1.0.25",
"changelog": "Release 1.0.25", "changelog": "Release 1.0.25",