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
dtourolle 24f53ef13e Add support for Tizen via transcoding
🏗️ Build Plugin / build (push) Failing after 15m44s
Latest Release / latest-release (push) Has been cancelled
🧪 Test Plugin / test (push) Has been cancelled
🚀 Release Plugin / build-and-release (push) Failing after 3m23s
2026-05-03 10:41:27 +02:00
Gitea Actions 86b22c69d7 Update manifest.json for latest build (8ffa0a0) 2026-03-07 17:41:15 +00:00
dtourolle 8ffa0a0f76 Add control page for downloads
🏗️ Build Plugin / build (push) Successful in 44s
Latest Release / latest-release (push) Successful in 53s
🧪 Test Plugin / test (push) Successful in 36s
2026-03-07 18:39:32 +01:00
Gitea Actions 92f315f6f6 Update manifest.json for version 1.0.27 2026-03-07 16:40:18 +00:00
Gitea Actions aa1ccbe458 Update manifest.json for latest build (702a5ab)
🚀 Release Plugin / build-and-release (push) Successful in 44s
2026-03-07 16:28:38 +00:00
dtourolle 702a5abdc5 fix time zone mix.up
🏗️ Build Plugin / build (push) Successful in 44s
Latest Release / latest-release (push) Successful in 53s
🧪 Test Plugin / test (push) Successful in 36s
2026-03-07 17:26:59 +01:00
Gitea Actions cdab2d76a7 Update manifest.json for latest build (9202ab6) 2026-03-07 15:59:58 +00:00
dtourolle 9202ab62ed fix CI
🏗️ Build Plugin / build (push) Successful in 45s
Latest Release / latest-release (push) Successful in 52s
🧪 Test Plugin / test (push) Successful in 39s
2026-03-07 16:58:13 +01:00
dtourolle 1b23e203ce CI fix
🏗️ Build Plugin / build (push) Successful in 45s
Latest Release / latest-release (push) Failing after 53s
🧪 Test Plugin / test (push) Successful in 36s
2026-03-07 16:39:38 +01:00
dtourolle 611fb52d76 CI fix
🏗️ Build Plugin / build (push) Successful in 44s
Latest Release / latest-release (push) Failing after 52s
🧪 Test Plugin / test (push) Successful in 37s
2026-03-07 16:35:12 +01:00
dtourolle 3336bac3fb latest make jeelyfin release
🏗️ Build Plugin / build (push) Successful in 46s
Latest Release / latest-release (push) Failing after 50s
🧪 Test Plugin / test (push) Successful in 37s
2026-03-07 16:31:53 +01:00
dtourolle 462c8a7c7b latest build pipeline 2026-03-07 16:29:57 +01:00
dtourolle 0a2d6a558c no dupplicate recordings
🏗️ Build Plugin / build (push) Failing after 28s
🧪 Test Plugin / test (push) Failing after 39s
🚀 Release Plugin / build-and-release (push) Failing after 38s
2026-03-07 16:25:01 +01:00
dtourolle fb539d6a32 Scheduling fixes 2026-03-07 16:24:29 +01:00
Gitea Actions 6ba5df6be9 Update manifest.json for version 1.0.25 2026-03-07 15:11:52 +00:00
dtourolle 33209c3b33 Fix bug in recoridng page
🏗️ Build Plugin / build (push) Successful in 43s
🧪 Test Plugin / test (push) Successful in 36s
🚀 Release Plugin / build-and-release (push) Successful in 52s
2026-03-07 16:09:37 +01:00
Gitea Actions 2177ef9814 Update manifest.json for version 1.0.24 2026-03-07 14:55:13 +00:00
dtourolle 87bdec280b Add recording of livestreams
🏗️ Build Plugin / build (push) Successful in 46s
🧪 Test Plugin / test (push) Successful in 37s
🚀 Release Plugin / build-and-release (push) Successful in 53s
2026-03-07 15:52:51 +01:00
Gitea Actions 5f6e928409 Update manifest.json for version 1.0.23 2026-02-28 12:13:10 +00:00
dtourolle c1b5dea569 Fixes for Livestreams
🏗️ Build Plugin / build (push) Successful in 41s
🧪 Test Plugin / test (push) Successful in 34s
🚀 Release Plugin / build-and-release (push) Successful in 51s
2026-02-28 13:11:02 +01:00
Gitea Actions 2879ebf4df Update manifest.json for version 1.0.22 2026-02-28 11:36:30 +00:00
dtourolle b0cdcda900 CI fix
🏗️ Build Plugin / build (push) Successful in 44s
🧪 Test Plugin / test (push) Successful in 35s
🚀 Release Plugin / build-and-release (push) Successful in 52s
2026-02-28 12:34:15 +01:00
dtourolle 5fadf5a9a7 CI fix
🏗️ Build Plugin / build (push) Successful in 43s
🧪 Test Plugin / test (push) Successful in 35s
🚀 Release Plugin / build-and-release (push) Failing after 54s
2026-02-28 12:29:27 +01:00
37 changed files with 2432 additions and 119 deletions
+7
View File
@@ -25,6 +25,13 @@ jobs:
with: with:
path: build-${{ github.run_id }} 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 - 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
+170
View File
@@ -0,0 +1,170 @@
name: 'Latest Release'
on:
push:
branches:
- master
paths-ignore:
- '**/*.md'
- 'manifest.json'
jobs:
latest-release:
runs-on: linux/amd64
container:
image: gitea.tourolle.paris/dtourolle/srfplay-builder:latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
path: build-${{ github.run_id }}
- name: Restore dependencies
working-directory: build-${{ github.run_id }}
run: dotnet restore Jellyfin.Plugin.SRFPlay.sln
- name: Build solution
working-directory: build-${{ github.run_id }}
run: dotnet build Jellyfin.Plugin.SRFPlay.sln --configuration Release --no-restore --no-self-contained /m:1
- name: Run tests
working-directory: build-${{ github.run_id }}
run: dotnet test Jellyfin.Plugin.SRFPlay.sln --no-build --configuration Release --verbosity normal
- name: Build Jellyfin Plugin
id: jprm
working-directory: build-${{ github.run_id }}
run: |
mkdir -p artifacts
jprm --verbosity=debug plugin build .
ARTIFACT=$(find . -name "*.zip" -type f -print -quit | sed 's|^\./||')
ARTIFACT_NAME=$(basename "${ARTIFACT}")
echo "artifact=${ARTIFACT}" >> $GITHUB_OUTPUT
echo "artifact_name=${ARTIFACT_NAME}" >> $GITHUB_OUTPUT
echo "Found artifact: ${ARTIFACT}"
- name: Calculate checksum
id: checksum
working-directory: build-${{ github.run_id }}
run: |
CHECKSUM=$(md5sum "${{ steps.jprm.outputs.artifact }}" | awk '{print $1}')
echo "checksum=${CHECKSUM}" >> $GITHUB_OUTPUT
echo "Checksum: ${CHECKSUM}"
- name: Delete existing latest release
working-directory: build-${{ github.run_id }}
env:
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
REPO_OWNER="${{ github.repository_owner }}"
REPO_NAME="${{ github.event.repository.name }}"
GITEA_URL="${{ github.server_url }}"
TAG="latest"
EXISTING=$(curl -s -o /dev/null -w "%{http_code}" \
-H "Authorization: token ${GITEA_TOKEN}" \
"${GITEA_URL}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}/releases/tags/${TAG}")
if [ "$EXISTING" = "200" ]; then
RELEASE_ID=$(curl -s \
-H "Authorization: token ${GITEA_TOKEN}" \
"${GITEA_URL}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}/releases/tags/${TAG}" | jq -r '.id')
echo "Deleting existing latest release (ID: ${RELEASE_ID})..."
curl -s -X DELETE \
-H "Authorization: token ${GITEA_TOKEN}" \
"${GITEA_URL}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}/releases/${RELEASE_ID}"
fi
curl -s -X DELETE \
-H "Authorization: token ${GITEA_TOKEN}" \
"${GITEA_URL}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}/tags/${TAG}" || true
- name: Create latest release
working-directory: build-${{ github.run_id }}
env:
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
REPO_OWNER="${{ github.repository_owner }}"
REPO_NAME="${{ github.event.repository.name }}"
GITEA_URL="${{ github.server_url }}"
RESPONSE=$(curl -s -w "\n%{http_code}" -X POST \
-H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/json" \
"${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}')")
HTTP_CODE=$(echo "$RESPONSE" | tail -n1)
BODY=$(echo "$RESPONSE" | sed '$d')
if [ "$HTTP_CODE" -ge 200 ] && [ "$HTTP_CODE" -lt 300 ]; then
RELEASE_ID=$(echo "$BODY" | jq -r '.id')
echo "Created release with ID: ${RELEASE_ID}"
else
echo "Failed to create release. HTTP ${HTTP_CODE}"
echo "$BODY"
exit 1
fi
# Upload plugin artifact
echo "Uploading plugin artifact..."
curl -f -X POST \
-H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/zip" \
--data-binary "@${{ steps.jprm.outputs.artifact }}" \
"${GITEA_URL}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}/releases/${RELEASE_ID}/assets?name=${{ steps.jprm.outputs.artifact_name }}"
# Upload build.yaml
echo "Uploading build.yaml..."
curl -f -X POST \
-H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/x-yaml" \
--data-binary "@build.yaml" \
"${GITEA_URL}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}/releases/${RELEASE_ID}/assets?name=build.yaml"
echo "Latest release updated successfully!"
- name: Update manifest.json
working-directory: build-${{ github.run_id }}
env:
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
REPO_OWNER="${{ github.repository_owner }}"
REPO_NAME="${{ github.event.repository.name }}"
GITEA_URL="${{ github.server_url }}"
CHECKSUM="${{ steps.checksum.outputs.checksum }}"
ARTIFACT_NAME="${{ steps.jprm.outputs.artifact_name }}"
TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
DOWNLOAD_URL="${GITEA_URL}/${REPO_OWNER}/${REPO_NAME}/releases/download/latest/${ARTIFACT_NAME}"
SHORT_SHA=$(echo "${{ github.sha }}" | cut -c1-7)
git config user.name "Gitea Actions"
git config user.email "actions@gitea.tourolle.paris"
git fetch origin 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
{
"version": "0.0.0.0",
"changelog": "Latest Build",
"targetAbi": "10.9.0.0",
"sourceUrl": "${DOWNLOAD_URL}",
"checksum": "${CHECKSUM}",
"timestamp": "${TIMESTAMP}"
}
EOF
)
jq --argjson newver "${NEW_VERSION}" '.[0].versions = [$newver] + .[0].versions' manifest.json > manifest.tmp && mv manifest.tmp manifest.json
git add manifest.json
git commit -m "Update manifest.json for latest build (${SHORT_SHA})" || echo "No changes to commit"
git push origin master
- name: Cleanup
if: always()
run: rm -rf build-${{ github.run_id }}
+9 -11
View File
@@ -42,6 +42,13 @@ jobs:
sed -i "s/^version:.*/version: \"${VERSION}\"/" build.yaml sed -i "s/^version:.*/version: \"${VERSION}\"/" build.yaml
cat 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 - name: Restore dependencies
working-directory: release-${{ github.run_id }} working-directory: release-${{ github.run_id }}
run: dotnet restore Jellyfin.Plugin.SRFPlay.sln run: dotnet restore Jellyfin.Plugin.SRFPlay.sln
@@ -84,22 +91,13 @@ jobs:
REPO_NAME="${{ github.event.repository.name }}" REPO_NAME="${{ github.event.repository.name }}"
GITEA_URL="${{ github.server_url }}" GITEA_URL="${{ github.server_url }}"
# Prepare release body
RELEASE_BODY="SRFPlay Jellyfin Plugin ${{ steps.get_version.outputs.version }}\n\nSee attached files for plugin installation."
RELEASE_BODY_JSON=$(echo -n "${RELEASE_BODY}" | jq -Rs .)
# Create release using Gitea API # Create release using Gitea API
VERSION="${{ steps.get_version.outputs.version }}"
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 "{ -d "$(jq -n --arg tag "$VERSION" --arg name "Release $VERSION" --arg body "SRFPlay Jellyfin Plugin. See attached files for plugin installation." '{tag_name: $tag, name: $name, body: $body, draft: false, prerelease: false}')")
\"tag_name\": \"${{ steps.get_version.outputs.version }}\",
\"name\": \"Release ${{ steps.get_version.outputs.version }}\",
\"body\": ${RELEASE_BODY_JSON},
\"draft\": false,
\"prerelease\": false
}")
HTTP_CODE=$(echo "$RESPONSE" | tail -n1) HTTP_CODE=$(echo "$RESPONSE" | tail -n1)
BODY=$(echo "$RESPONSE" | sed '$d') BODY=$(echo "$RESPONSE" | sed '$d')
+7
View File
@@ -27,6 +27,13 @@ jobs:
with: with:
path: test-${{ github.run_id }} 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 - name: Restore dependencies
working-directory: test-${{ github.run_id }} working-directory: test-${{ github.run_id }}
run: dotnet restore Jellyfin.Plugin.SRFPlay.sln run: dotnet restore Jellyfin.Plugin.SRFPlay.sln
@@ -0,0 +1,101 @@
using System;
using System.Text.Json.Serialization;
namespace Jellyfin.Plugin.SRFPlay.Api.Models;
/// <summary>
/// A scheduled or active recording.
/// </summary>
public class RecordingEntry
{
/// <summary>
/// Gets or sets the unique recording ID.
/// </summary>
[JsonPropertyName("id")]
public string Id { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the SRF URN.
/// </summary>
[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>
[JsonPropertyName("title")]
public string Title { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the description.
/// </summary>
[JsonPropertyName("description")]
public string? Description { get; set; }
/// <summary>
/// Gets or sets the image URL.
/// </summary>
[JsonPropertyName("imageUrl")]
public string? ImageUrl { get; set; }
/// <summary>
/// Gets or sets when the livestream starts.
/// </summary>
[JsonPropertyName("validFrom")]
public DateTime? ValidFrom { get; set; }
/// <summary>
/// Gets or sets when the livestream ends.
/// </summary>
[JsonPropertyName("validTo")]
public DateTime? ValidTo { get; set; }
/// <summary>
/// Gets or sets the recording state.
/// </summary>
[JsonPropertyName("state")]
public RecordingState State { get; set; } = RecordingState.Scheduled;
/// <summary>
/// Gets or sets the output file path.
/// </summary>
[JsonPropertyName("outputPath")]
public string? OutputPath { get; set; }
/// <summary>
/// Gets or sets when the recording actually started.
/// </summary>
[JsonPropertyName("recordingStartedAt")]
public DateTime? RecordingStartedAt { get; set; }
/// <summary>
/// Gets or sets when the recording ended.
/// </summary>
[JsonPropertyName("recordingEndedAt")]
public DateTime? RecordingEndedAt { get; set; }
/// <summary>
/// Gets or sets the file size in bytes.
/// </summary>
[JsonPropertyName("fileSizeBytes")]
public long? FileSizeBytes { get; set; }
/// <summary>
/// Gets or sets the error message if recording failed.
/// </summary>
[JsonPropertyName("errorMessage")]
public string? ErrorMessage { get; set; }
/// <summary>
/// Gets or sets when this entry was created.
/// </summary>
[JsonPropertyName("createdAt")]
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
}
@@ -0,0 +1,25 @@
namespace Jellyfin.Plugin.SRFPlay.Api.Models;
/// <summary>
/// State of a recording.
/// </summary>
public enum RecordingState
{
/// <summary>Scheduled for future recording.</summary>
Scheduled,
/// <summary>Waiting for stream to become available.</summary>
WaitingForStream,
/// <summary>Currently recording.</summary>
Recording,
/// <summary>Recording completed successfully.</summary>
Completed,
/// <summary>Recording failed.</summary>
Failed,
/// <summary>Recording was cancelled.</summary>
Cancelled
}
@@ -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";
}
@@ -6,6 +6,8 @@ using System.Text;
using System.Threading; 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.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;
@@ -15,24 +17,27 @@ using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.Channels; using MediaBrowser.Model.Channels;
using MediaBrowser.Model.Dto; using MediaBrowser.Model.Dto;
using MediaBrowser.Model.Entities; using MediaBrowser.Model.Entities;
using MediaBrowser.Model.MediaInfo;
using Microsoft.Extensions.Logging; 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;
private readonly ICategoryService? _categoryService; private readonly ICategoryService? _categoryService;
private readonly ISRFApiClientFactory _apiClientFactory; private readonly ISRFApiClientFactory _apiClientFactory;
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>
@@ -40,34 +45,47 @@ public class SRFPlayChannel : IChannel, IHasCacheKey
/// <param name="mediaSourceFactory">The media source factory.</param> /// <param name="mediaSourceFactory">The media source factory.</param>
/// <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>
public SRFPlayChannel( /// <param name="recordingService">The recording service.</param>
protected SrgChannelBase(
ILoggerFactory loggerFactory, ILoggerFactory loggerFactory,
IContentRefreshService contentRefreshService, IContentRefreshService contentRefreshService,
IStreamUrlResolver streamResolver, IStreamUrlResolver streamResolver,
IMediaSourceFactory mediaSourceFactory, IMediaSourceFactory mediaSourceFactory,
ICategoryService? categoryService, ICategoryService? categoryService,
ISRFApiClientFactory apiClientFactory) ISRFApiClientFactory apiClientFactory,
IRecordingService recordingService)
{ {
_logger = loggerFactory.CreateLogger<SRFPlayChannel>(); _logger = loggerFactory.CreateLogger<SrgChannelBase>();
_contentRefreshService = contentRefreshService; _contentRefreshService = contentRefreshService;
_streamResolver = streamResolver; _streamResolver = streamResolver;
_mediaSourceFactory = mediaSourceFactory; _mediaSourceFactory = mediaSourceFactory;
_categoryService = categoryService; _categoryService = categoryService;
_apiClientFactory = apiClientFactory; _apiClientFactory = apiClientFactory;
_recordingService = recordingService;
if (_categoryService == null) if (_categoryService == null)
{ {
_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
@@ -108,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)
{ {
@@ -156,8 +175,21 @@ public class SRFPlayChannel : IChannel, IHasCacheKey
} }
} }
private bool IsUnitEnabled()
{
var config = Plugin.Instance?.Configuration;
return config == null || config.ResolveEnabledUnits().Contains(Unit);
}
private async Task<List<ChannelItemInfo>> GetFolderItemsAsync(string? folderId, CancellationToken cancellationToken) private async Task<List<ChannelItemInfo>> GetFolderItemsAsync(string? folderId, CancellationToken cancellationToken)
{ {
// If this unit is not enabled in configuration, show nothing (empty tile).
if (!IsUnitEnabled())
{
_logger.LogDebug("Business unit {Unit} is not enabled - returning no items", Unit);
return new List<ChannelItemInfo>();
}
// Root level - show folder list // Root level - show folder list
if (string.IsNullOrEmpty(folderId)) if (string.IsNullOrEmpty(folderId))
{ {
@@ -170,6 +202,7 @@ public class SRFPlayChannel : IChannel, IHasCacheKey
"latest" => await GetLatestVideosAsync(cancellationToken).ConfigureAwait(false), "latest" => await GetLatestVideosAsync(cancellationToken).ConfigureAwait(false),
"trending" => await GetTrendingVideosAsync(cancellationToken).ConfigureAwait(false), "trending" => await GetTrendingVideosAsync(cancellationToken).ConfigureAwait(false),
"live_sports" => await GetLiveSportsAsync(cancellationToken).ConfigureAwait(false), "live_sports" => await GetLiveSportsAsync(cancellationToken).ConfigureAwait(false),
"recordings" => GetRecordingItems(),
_ when folderId.StartsWith("category_", StringComparison.Ordinal) => await GetCategoryVideosAsync(folderId, cancellationToken).ConfigureAwait(false), _ when folderId.StartsWith("category_", StringComparison.Ordinal) => await GetCategoryVideosAsync(folderId, cancellationToken).ConfigureAwait(false),
_ => new List<ChannelItemInfo>() _ => new List<ChannelItemInfo>()
}; };
@@ -181,7 +214,8 @@ public class SRFPlayChannel : IChannel, IHasCacheKey
{ {
CreateFolder("latest", "Latest Videos"), CreateFolder("latest", "Latest Videos"),
CreateFolder("trending", "Trending Videos"), CreateFolder("trending", "Trending Videos"),
CreateFolder("live_sports", "Live Sports & Events") CreateFolder("live_sports", "Live Sports & Events"),
CreateFolder("recordings", "Recordings")
}; };
// Add category folders if enabled // Add category folders if enabled
@@ -190,7 +224,7 @@ public class SRFPlayChannel : IChannel, IHasCacheKey
{ {
try try
{ {
var businessUnit = config.BusinessUnit.ToLowerString(); var businessUnit = Unit.ToLowerString();
var topics = await _categoryService.GetTopicsAsync(businessUnit, cancellationToken).ConfigureAwait(false); var topics = await _categoryService.GetTopicsAsync(businessUnit, cancellationToken).ConfigureAwait(false);
foreach (var topic in topics.Where(t => !string.IsNullOrEmpty(t.Id))) foreach (var topic in topics.Where(t => !string.IsNullOrEmpty(t.Id)))
@@ -231,24 +265,23 @@ public class SRFPlayChannel : IChannel, IHasCacheKey
private async Task<List<ChannelItemInfo>> GetLatestVideosAsync(CancellationToken cancellationToken) private async Task<List<ChannelItemInfo>> GetLatestVideosAsync(CancellationToken cancellationToken)
{ {
var urns = await _contentRefreshService.RefreshLatestContentAsync(cancellationToken).ConfigureAwait(false); var urns = await _contentRefreshService.RefreshLatestContentAsync(Unit.ToLowerString(), cancellationToken).ConfigureAwait(false);
return await ConvertUrnsToChannelItems(urns, cancellationToken).ConfigureAwait(false); return await ConvertUrnsToChannelItems(urns, cancellationToken).ConfigureAwait(false);
} }
private async Task<List<ChannelItemInfo>> GetTrendingVideosAsync(CancellationToken cancellationToken) private async Task<List<ChannelItemInfo>> GetTrendingVideosAsync(CancellationToken cancellationToken)
{ {
var urns = await _contentRefreshService.RefreshTrendingContentAsync(cancellationToken).ConfigureAwait(false); var urns = await _contentRefreshService.RefreshTrendingContentAsync(Unit.ToLowerString(), cancellationToken).ConfigureAwait(false);
return await ConvertUrnsToChannelItems(urns, cancellationToken).ConfigureAwait(false); return await ConvertUrnsToChannelItems(urns, cancellationToken).ConfigureAwait(false);
} }
private async Task<List<ChannelItemInfo>> GetLiveSportsAsync(CancellationToken cancellationToken) private async Task<List<ChannelItemInfo>> GetLiveSportsAsync(CancellationToken cancellationToken)
{ {
var items = new List<ChannelItemInfo>(); var items = new List<ChannelItemInfo>();
var config = Plugin.Instance?.Configuration;
try try
{ {
var businessUnit = config?.BusinessUnit.ToLowerString() ?? "srf"; var businessUnit = Unit.ToLowerString();
using var apiClient = _apiClientFactory.CreateClient(); using var apiClient = _apiClientFactory.CreateClient();
var scheduledLivestreams = await apiClient.GetScheduledLivestreamsAsync(businessUnit, "SPORT", cancellationToken).ConfigureAwait(false); var scheduledLivestreams = await apiClient.GetScheduledLivestreamsAsync(businessUnit, "SPORT", cancellationToken).ConfigureAwait(false);
@@ -296,6 +329,68 @@ public class SRFPlayChannel : IChannel, IHasCacheKey
return items; return items;
} }
private List<ChannelItemInfo> GetRecordingItems()
{
var items = new List<ChannelItemInfo>();
var unit = Unit.ToLowerString();
var recordings = _recordingService.GetRecordings(RecordingState.Completed);
foreach (var recording in recordings)
{
if (string.IsNullOrEmpty(recording.OutputPath) || !System.IO.File.Exists(recording.OutputPath))
{
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}";
var mediaSource = new MediaSourceInfo
{
Id = itemId,
Name = recording.Title,
Path = recording.OutputPath,
Protocol = MediaProtocol.File,
Container = "mkv",
SupportsDirectPlay = true,
SupportsDirectStream = true,
SupportsTranscoding = true,
IsRemote = false,
Size = fileInfo.Length,
Type = MediaSourceType.Default
};
var item = new ChannelItemInfo
{
Id = itemId,
Name = recording.Title,
Overview = recording.Description,
Type = ChannelItemType.Media,
ContentType = ChannelMediaContentType.Movie,
MediaType = ChannelMediaType.Video,
DateCreated = recording.RecordingStartedAt,
ImageUrl = !string.IsNullOrEmpty(recording.ImageUrl)
? CreateProxiedImageUrl(recording.ImageUrl, _mediaSourceFactory.GetServerBaseUrl())
: CreatePlaceholderImageUrl(recording.Title, _mediaSourceFactory.GetServerBaseUrl()),
MediaSources = new List<MediaSourceInfo> { mediaSource }
};
items.Add(item);
}
_logger.LogInformation("Returning {Count} completed recordings as channel items", items.Count);
return items;
}
private async Task<List<ChannelItemInfo>> GetCategoryVideosAsync(string folderId, CancellationToken cancellationToken) private async Task<List<ChannelItemInfo>> GetCategoryVideosAsync(string folderId, CancellationToken cancellationToken)
{ {
var items = new List<ChannelItemInfo>(); var items = new List<ChannelItemInfo>();
@@ -308,9 +403,8 @@ public class SRFPlayChannel : IChannel, IHasCacheKey
try try
{ {
var config = Plugin.Instance?.Configuration;
var topicId = folderId.Substring("category_".Length); var topicId = folderId.Substring("category_".Length);
var businessUnit = config?.BusinessUnit.ToLowerString() ?? "srf"; var businessUnit = Unit.ToLowerString();
var shows = await _categoryService.GetShowsByTopicAsync(topicId, businessUnit, 20, cancellationToken).ConfigureAwait(false); var shows = await _categoryService.GetShowsByTopicAsync(topicId, businessUnit, 20, cancellationToken).ConfigureAwait(false);
var urns = new List<string>(); var urns = new List<string>();
@@ -411,7 +505,8 @@ public class SRFPlayChannel : IChannel, IHasCacheKey
var timeBucket = new DateTime(now.Year, now.Month, now.Day, now.Hour, (now.Minute / 15) * 15, 0); var timeBucket = new DateTime(now.Year, now.Month, now.Day, now.Hour, (now.Minute / 15) * 15, 0);
var timeKey = timeBucket.ToString("yyyy-MM-dd-HH-mm", CultureInfo.InvariantCulture); var timeKey = timeBucket.ToString("yyyy-MM-dd-HH-mm", CultureInfo.InvariantCulture);
return $"{config?.BusinessUnit}_{config?.EnableLatestContent}_{config?.EnableTrendingContent}_{config?.EnableCategoryFolders}_{enabledTopics}_{timeKey}"; var recordingCount = _recordingService.GetRecordings(RecordingState.Completed).Count;
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)
@@ -553,8 +648,7 @@ public class SRFPlayChannel : IChannel, IHasCacheKey
ProviderIds = new Dictionary<string, string> ProviderIds = new Dictionary<string, string>
{ {
{ "SRF", urn } { "SRF", urn }
}, }
MediaSources = new List<MediaSourceInfo> { mediaSource }
}; };
// Add series info if available // 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 // Set default options
BusinessUnit = BusinessUnit.SRF; BusinessUnit = BusinessUnit.SRF;
EnabledBusinessUnits = new System.Collections.Generic.List<BusinessUnit> { BusinessUnit.SRF };
QualityPreference = QualityPreference.Auto; QualityPreference = QualityPreference.Auto;
ContentRefreshIntervalHours = 6; ContentRefreshIntervalHours = 6;
ExpirationCheckIntervalHours = 24; ExpirationCheckIntervalHours = 24;
@@ -78,10 +79,20 @@ public class PluginConfiguration : BasePluginConfiguration
} }
/// <summary> /// <summary>
/// Gets or sets the business unit to fetch content from. /// Gets or sets the legacy single business unit. Retained for backwards compatibility and
/// migration into <see cref="EnabledBusinessUnits"/>; new code should use the list instead.
/// </summary> /// </summary>
public BusinessUnit BusinessUnit { get; set; } public BusinessUnit BusinessUnit { get; set; }
/// <summary>
/// Gets or sets the list of business units to expose as channels. One channel tile is shown
/// per enabled unit, so polylingual households can browse e.g. SRF (German) and RTS (French)
/// at the same time.
/// </summary>
[System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "CA2227:Collection properties should be read only", Justification = "Required for configuration serialization")]
[System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "CA1002:Do not expose generic lists", Justification = "Configuration DTO")]
public System.Collections.Generic.List<BusinessUnit> EnabledBusinessUnits { get; set; }
/// <summary> /// <summary>
/// Gets or sets the preferred video quality. /// Gets or sets the preferred video quality.
/// </summary> /// </summary>
@@ -156,4 +167,25 @@ public class PluginConfiguration : BasePluginConfiguration
/// When enabled, generates custom thumbnails instead of using SRF-provided images. /// When enabled, generates custom thumbnails instead of using SRF-provided images.
/// </summary> /// </summary>
public bool GenerateTitleCards { get; set; } public bool GenerateTitleCards { get; set; }
/// <summary>
/// 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> <div id="SRFPlayConfigPage" data-role="page" class="page type-interior pluginConfigurationPage" data-require="emby-input,emby-button,emby-select,emby-checkbox">
<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 data-role="content">
<div class="content-primary"> <div class="content-primary">
<form id="SRFPlayConfigForm"> <form id="SRFPlayConfigForm">
<div class="selectContainer"> <div class="checkboxContainer checkboxContainer-withDescription">
<label class="selectLabel" for="BusinessUnit">Business Unit</label> <h3>Channels (Business Units)</h3>
<select is="emby-select" id="BusinessUnit" name="BusinessUnit" class="emby-select-withcolor emby-select"> <div class="fieldDescription" style="margin-bottom: 0.5em;">Enable a channel tile for each Swiss broadcaster you want. A separate tile appears per enabled unit, so polylingual households can browse e.g. SRF (German) and RTS (French) at the same time.</div>
<option id="optSRF" value="SRF">SRF (German)</option> <label class="emby-checkbox-label">
<option id="optRTS" value="RTS">RTS (French)</option> <input id="buSRF" type="checkbox" is="emby-checkbox" data-bu="SRF" />
<option id="optRSI" value="RSI">RSI (Italian)</option> <span>SRF — Schweizer Radio und Fernsehen (German)</span>
<option id="optRTR" value="RTR">RTR (Romansh)</option> </label>
<option id="optSWI" value="SWI">SWI (International)</option> <label class="emby-checkbox-label">
</select> <input id="buRTS" type="checkbox" is="emby-checkbox" data-bu="RTS" />
<div class="fieldDescription">Select the Swiss broadcasting unit to fetch content from</div> <span>RTS — Radio Télévision Suisse (French)</span>
</label>
<label class="emby-checkbox-label">
<input id="buRSI" type="checkbox" is="emby-checkbox" data-bu="RSI" />
<span>RSI — Radiotelevisione svizzera (Italian)</span>
</label>
<label class="emby-checkbox-label">
<input id="buRTR" type="checkbox" is="emby-checkbox" data-bu="RTR" />
<span>RTR — Radiotelevisiun Svizra Rumantscha (Romansh)</span>
</label>
<label class="emby-checkbox-label">
<input id="buSWI" type="checkbox" is="emby-checkbox" data-bu="SWI" />
<span>SWI — swissinfo.ch (International)</span>
</label>
</div> </div>
<div class="selectContainer"> <div class="selectContainer">
<label class="selectLabel" for="QualityPreference">Quality Preference</label> <label class="selectLabel" for="QualityPreference">Quality Preference</label>
@@ -90,6 +96,13 @@
<div class="fieldDescription">Password for proxy authentication (leave empty if not required)</div> <div class="fieldDescription">Password for proxy authentication (leave empty if not required)</div>
</div> </div>
<br /> <br />
<h2>Recording Settings</h2>
<div class="inputContainer">
<label class="inputLabel inputLabelUnfocused" for="RecordingOutputPath">Recording Output Directory</label>
<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>
<br />
<h2>Network Settings</h2> <h2>Network Settings</h2>
<div class="inputContainer"> <div class="inputContainer">
<label class="inputLabel inputLabelUnfocused" for="PublicServerUrl">Public Server URL (Optional)</label> <label class="inputLabel inputLabelUnfocused" for="PublicServerUrl">Public Server URL (Optional)</label>
@@ -106,6 +119,31 @@
</button> </button>
</div> </div>
</form> </form>
<br />
<h2>Sport Livestream Recordings</h2>
<h3>Upcoming Sport Livestreams</h3>
<div class="fieldDescription">Select livestreams to record. The recording starts automatically when the stream goes live.</div>
<div id="scheduleContainer" style="margin: 10px 0;">
<p><em>Loading schedule...</em></p>
</div>
<button is="emby-button" type="button" class="raised emby-button" onclick="SRFPlayRecordings.loadSchedule()" style="margin-bottom: 20px;">
<span>Refresh Schedule</span>
</button>
<h3>Scheduled &amp; Active Recordings</h3>
<div id="activeRecordingsContainer" style="margin: 10px 0;">
<p><em>Loading...</em></p>
</div>
<h3>Completed Recordings</h3>
<div id="completedRecordingsContainer" style="margin: 10px 0;">
<p><em>Loading...</em></p>
</div>
<button is="emby-button" type="button" class="raised emby-button" onclick="SRFPlayRecordings.loadRecordings()" style="margin-bottom: 20px;">
<span>Refresh Recordings</span>
</button>
</div> </div>
</div> </div>
<script type="text/javascript"> <script type="text/javascript">
@@ -117,7 +155,12 @@
.addEventListener('pageshow', function() { .addEventListener('pageshow', function() {
Dashboard.showLoadingMsg(); Dashboard.showLoadingMsg();
ApiClient.getPluginConfiguration(SRFPlayConfig.pluginUniqueId).then(function (config) { ApiClient.getPluginConfiguration(SRFPlayConfig.pluginUniqueId).then(function (config) {
document.querySelector('#BusinessUnit').value = config.BusinessUnit; var enabled = (config.EnabledBusinessUnits && config.EnabledBusinessUnits.length)
? config.EnabledBusinessUnits
: [config.BusinessUnit]; // migrate legacy single value
['SRF','RTS','RSI','RTR','SWI'].forEach(function(bu) {
document.querySelector('#bu' + bu).checked = enabled.indexOf(bu) !== -1;
});
document.querySelector('#QualityPreference').value = config.QualityPreference; document.querySelector('#QualityPreference').value = config.QualityPreference;
document.querySelector('#ContentRefreshIntervalHours').value = config.ContentRefreshIntervalHours; document.querySelector('#ContentRefreshIntervalHours').value = config.ContentRefreshIntervalHours;
document.querySelector('#ExpirationCheckIntervalHours').value = config.ExpirationCheckIntervalHours; document.querySelector('#ExpirationCheckIntervalHours').value = config.ExpirationCheckIntervalHours;
@@ -130,7 +173,12 @@
document.querySelector('#ProxyUsername').value = config.ProxyUsername || ''; document.querySelector('#ProxyUsername').value = config.ProxyUsername || '';
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 || '';
Dashboard.hideLoadingMsg(); Dashboard.hideLoadingMsg();
// Load recordings UI
SRFPlayRecordings.loadSchedule();
SRFPlayRecordings.loadRecordings();
}); });
}); });
@@ -138,7 +186,11 @@
.addEventListener('submit', function(e) { .addEventListener('submit', function(e) {
Dashboard.showLoadingMsg(); Dashboard.showLoadingMsg();
ApiClient.getPluginConfiguration(SRFPlayConfig.pluginUniqueId).then(function (config) { ApiClient.getPluginConfiguration(SRFPlayConfig.pluginUniqueId).then(function (config) {
config.BusinessUnit = document.querySelector('#BusinessUnit').value; config.EnabledBusinessUnits = ['SRF','RTS','RSI','RTR','SWI'].filter(function(bu) {
return document.querySelector('#bu' + bu).checked;
});
// Keep legacy field in sync with the first enabled unit for backwards compat.
config.BusinessUnit = config.EnabledBusinessUnits[0] || 'SRF';
config.QualityPreference = document.querySelector('#QualityPreference').value; config.QualityPreference = document.querySelector('#QualityPreference').value;
config.ContentRefreshIntervalHours = parseInt(document.querySelector('#ContentRefreshIntervalHours').value); config.ContentRefreshIntervalHours = parseInt(document.querySelector('#ContentRefreshIntervalHours').value);
config.ExpirationCheckIntervalHours = parseInt(document.querySelector('#ExpirationCheckIntervalHours').value); config.ExpirationCheckIntervalHours = parseInt(document.querySelector('#ExpirationCheckIntervalHours').value);
@@ -151,6 +203,7 @@
config.ProxyUsername = document.querySelector('#ProxyUsername').value; config.ProxyUsername = document.querySelector('#ProxyUsername').value;
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;
ApiClient.updatePluginConfiguration(SRFPlayConfig.pluginUniqueId, config).then(function (result) { ApiClient.updatePluginConfiguration(SRFPlayConfig.pluginUniqueId, config).then(function (result) {
Dashboard.processPluginConfigurationUpdateResult(result); Dashboard.processPluginConfigurationUpdateResult(result);
}); });
@@ -159,7 +212,204 @@
e.preventDefault(); e.preventDefault();
return false; return false;
}); });
var SRFPlayRecordings = {
apiBase: ApiClient.serverAddress() + '/Plugins/SRFPlay/Recording',
getHeaders: function() {
return {
'X-Emby-Token': ApiClient.accessToken()
};
},
formatDate: function(dateStr) {
if (!dateStr) return 'N/A';
var d = new Date(dateStr);
return d.toLocaleDateString() + ' ' + d.toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'});
},
formatSize: function(bytes) {
if (!bytes) return '';
if (bytes > 1073741824) return (bytes / 1073741824).toFixed(1) + ' GB';
if (bytes > 1048576) return (bytes / 1048576).toFixed(0) + ' MB';
return (bytes / 1024).toFixed(0) + ' KB';
},
loadSchedule: function() {
var container = document.getElementById('scheduleContainer');
container.innerHTML = '<p><em>Loading schedule...</em></p>';
fetch(this.apiBase + '/Schedule', { headers: this.getHeaders() })
.then(function(r) { return r.json(); })
.then(function(programs) {
if (!programs || programs.length === 0) {
container.innerHTML = '<p>No upcoming sport livestreams found.</p>';
return;
}
var html = '<table style="width:100%; border-collapse: collapse;">';
html += '<thead><tr style="text-align:left; border-bottom: 1px solid #444;">';
html += '<th style="padding: 8px;">Title</th>';
html += '<th style="padding: 8px;">Start</th>';
html += '<th style="padding: 8px;">End</th>';
html += '<th style="padding: 8px;">Action</th>';
html += '</tr></thead><tbody>';
programs.forEach(function(p) {
html += '<tr style="border-bottom: 1px solid #333;">';
html += '<td style="padding: 8px;">' + (p.title || 'Unknown') + '</td>';
html += '<td style="padding: 8px;">' + SRFPlayRecordings.formatDate(p.validFrom || p.date) + '</td>';
html += '<td style="padding: 8px;">' + SRFPlayRecordings.formatDate(p.validTo) + '</td>';
html += '<td style="padding: 8px;">';
html += '<button is="emby-button" type="button" class="raised emby-button" ';
html += 'onclick="SRFPlayRecordings.scheduleRecording(\'' + encodeURIComponent(p.urn) + '\')">';
html += '<span>Record</span></button>';
html += '</td></tr>';
});
html += '</tbody></table>';
container.innerHTML = html;
})
.catch(function(err) {
container.innerHTML = '<p style="color: #f44;">Error loading schedule: ' + err.message + '</p>';
});
},
scheduleRecording: function(encodedUrn) {
fetch(this.apiBase + '/Schedule/' + encodedUrn, {
method: 'POST',
headers: this.getHeaders()
})
.then(function(r) {
if (r.ok) {
Dashboard.alert('Recording scheduled!');
SRFPlayRecordings.loadRecordings();
} else {
Dashboard.alert('Failed to schedule recording');
}
})
.catch(function(err) { Dashboard.alert('Error: ' + err.message); });
},
loadRecordings: function() {
this.loadActiveRecordings();
this.loadCompletedRecordings();
},
loadActiveRecordings: function() {
var container = document.getElementById('activeRecordingsContainer');
container.innerHTML = '<p><em>Loading...</em></p>';
fetch(this.apiBase + '/All', { headers: this.getHeaders() })
.then(function(r) { return r.json(); })
.then(function(recordings) {
var activeStates = ['Scheduled', 'WaitingForStream', 'Recording', 0, 1, 2];
var active = recordings.filter(function(r) {
return activeStates.indexOf(r.state) !== -1;
});
if (active.length === 0) {
container.innerHTML = '<p>No scheduled or active recordings.</p>';
return;
}
var stateLabels = {'Scheduled': 'Scheduled', 'WaitingForStream': 'Waiting', 'Recording': 'Recording', 'Completed': 'Completed', 'Failed': 'Failed', 'Cancelled': 'Cancelled', 0: 'Scheduled', 1: 'Waiting', 2: 'Recording', 3: 'Completed', 4: 'Failed', 5: 'Cancelled'};
var stateColors = {'Scheduled': '#2196F3', 'WaitingForStream': '#FF9800', 'Recording': '#4CAF50', 'Failed': '#f44336', 'Cancelled': '#9E9E9E', 0: '#2196F3', 1: '#FF9800', 2: '#4CAF50', 4: '#f44336', 5: '#9E9E9E'};
var html = '<table style="width:100%; border-collapse: collapse;">';
html += '<thead><tr style="text-align:left; border-bottom: 1px solid #444;">';
html += '<th style="padding: 8px;">Title</th>';
html += '<th style="padding: 8px;">Status</th>';
html += '<th style="padding: 8px;">Start</th>';
html += '<th style="padding: 8px;">Action</th>';
html += '</tr></thead><tbody>';
active.forEach(function(r) {
html += '<tr style="border-bottom: 1px solid #333;">';
html += '<td style="padding: 8px;">' + (r.title || 'Unknown') + '</td>';
html += '<td style="padding: 8px;"><span style="color:' + (stateColors[r.state] || '#fff') + ';">' + (stateLabels[r.state] || r.state) + '</span></td>';
html += '<td style="padding: 8px;">' + SRFPlayRecordings.formatDate(r.validFrom) + '</td>';
html += '<td style="padding: 8px;">';
if (r.state === 2 || r.state === 'Recording') {
html += '<button is="emby-button" type="button" class="raised emby-button" ';
html += 'onclick="SRFPlayRecordings.stopRecording(\'' + r.id + '\')"><span>Stop</span></button>';
} else {
html += '<button is="emby-button" type="button" class="raised emby-button" ';
html += 'onclick="SRFPlayRecordings.cancelRecording(\'' + r.id + '\')"><span>Cancel</span></button>';
}
html += '</td></tr>';
});
html += '</tbody></table>';
container.innerHTML = html;
})
.catch(function(err) {
container.innerHTML = '<p style="color: #f44;">Error: ' + err.message + '</p>';
});
},
loadCompletedRecordings: function() {
var container = document.getElementById('completedRecordingsContainer');
container.innerHTML = '<p><em>Loading...</em></p>';
fetch(this.apiBase + '/Completed', { headers: this.getHeaders() })
.then(function(r) { return r.json(); })
.then(function(recordings) {
if (!recordings || recordings.length === 0) {
container.innerHTML = '<p>No completed recordings.</p>';
return;
}
var html = '<table style="width:100%; border-collapse: collapse;">';
html += '<thead><tr style="text-align:left; border-bottom: 1px solid #444;">';
html += '<th style="padding: 8px;">Title</th>';
html += '<th style="padding: 8px;">Recorded</th>';
html += '<th style="padding: 8px;">Size</th>';
html += '<th style="padding: 8px;">File</th>';
html += '<th style="padding: 8px;">Action</th>';
html += '</tr></thead><tbody>';
recordings.forEach(function(r) {
html += '<tr style="border-bottom: 1px solid #333;">';
html += '<td style="padding: 8px;">' + (r.title || 'Unknown') + '</td>';
html += '<td style="padding: 8px;">' + SRFPlayRecordings.formatDate(r.recordingStartedAt) + '</td>';
html += '<td style="padding: 8px;">' + SRFPlayRecordings.formatSize(r.fileSizeBytes) + '</td>';
html += '<td style="padding: 8px; font-size: 0.85em; word-break: break-all;">' + (r.outputPath || '') + '</td>';
html += '<td style="padding: 8px;">';
html += '<button is="emby-button" type="button" class="raised emby-button" style="background:#f44336;" ';
html += 'onclick="SRFPlayRecordings.deleteRecording(\'' + r.id + '\')"><span>Delete</span></button>';
html += '</td></tr>';
});
html += '</tbody></table>';
container.innerHTML = html;
})
.catch(function(err) {
container.innerHTML = '<p style="color: #f44;">Error: ' + err.message + '</p>';
});
},
stopRecording: function(id) {
fetch(this.apiBase + '/Active/' + id + '/Stop', {
method: 'POST',
headers: this.getHeaders()
}).then(function() { SRFPlayRecordings.loadRecordings(); });
},
cancelRecording: function(id) {
fetch(this.apiBase + '/Schedule/' + id, {
method: 'DELETE',
headers: this.getHeaders()
}).then(function() { SRFPlayRecordings.loadRecordings(); });
},
deleteRecording: function(id) {
if (!confirm('Delete this recording and its file?')) return;
fetch(this.apiBase + '/Completed/' + id + '?deleteFile=true', {
method: 'DELETE',
headers: this.getHeaders()
}).then(function() { SRFPlayRecordings.loadRecordings(); });
}
};
</script> </script>
</div> </div>
</body>
</html>
@@ -0,0 +1,346 @@
<div id="SRFPlayRecordingsPage" data-role="page" class="page type-interior pluginConfigurationPage"
data-require="emby-button,emby-input">
<style>
#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>
<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="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="srfrecMainSection" style="display:none;">
<h1>SRF Sport Recordings</h1>
<p class="fieldDescription">Browse upcoming sport livestreams and schedule recordings. <span id="srfrecUserInfo"></span></p>
<h2>Upcoming Sport Livestreams</h2>
<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="srfrecActiveRecordingsContainer"><p class="srfrec-msg">Loading...</p></div>
<h2>Completed Recordings</h2>
<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>
var SRFRec = {
serverUrl: '',
token: '',
init: function() {
// Check if we're inside Jellyfin's web client (ApiClient available)
if (typeof ApiClient !== 'undefined' && ApiClient.accessToken()) {
this.serverUrl = ApiClient.serverAddress();
this.token = ApiClient.accessToken();
this.showMain();
return;
}
// Check localStorage for saved session
var saved = localStorage.getItem('srfRecSession');
if (saved) {
try {
var session = JSON.parse(saved);
this.serverUrl = session.serverUrl;
this.token = session.token;
// Verify token still works
this.verifySession(session);
return;
} catch(e) { /* fall through to login */ }
}
// Show login form
var serverInput = document.getElementById('srfrecLoginServer');
serverInput.value = window.location.origin;
document.getElementById('srfrecLoginSection').style.display = 'block';
},
verifySession: function(session) {
var self = this;
fetch(this.serverUrl + '/System/Info', {
headers: { 'X-Emby-Token': this.token }
}).then(function(r) {
if (r.ok) {
document.getElementById('srfrecUserInfo').textContent = '(Server: ' + self.serverUrl + ')';
self.showMain();
} else {
localStorage.removeItem('srfRecSession');
document.getElementById('srfrecLoginSection').style.display = 'block';
}
}).catch(function() {
localStorage.removeItem('srfRecSession');
document.getElementById('srfrecLoginSection').style.display = 'block';
});
},
login: function() {
var self = this;
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', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Emby-Authorization': 'MediaBrowser Client="SRF Recordings", Device="Web", DeviceId="srfrec-' + Date.now() + '", Version="1.0.0"'
},
body: JSON.stringify({ Username: user, Pw: pass })
})
.then(function(r) {
if (!r.ok) throw new Error('Authentication failed');
return r.json();
})
.then(function(data) {
self.serverUrl = server;
self.token = data.AccessToken;
localStorage.setItem('srfRecSession', JSON.stringify({
serverUrl: server,
token: data.AccessToken,
userName: data.User.Name
}));
document.getElementById('srfrecUserInfo').textContent = '(Signed in as ' + data.User.Name + ')';
self.showMain();
})
.catch(function(err) {
errorEl.textContent = err.message;
});
},
showMain: function() {
document.getElementById('srfrecLoginSection').style.display = 'none';
document.getElementById('srfrecMainSection').style.display = 'block';
this.loadSchedule();
this.loadRecordings();
},
getHeaders: function() {
return { 'X-Emby-Token': this.token };
},
formatDate: function(dateStr) {
if (!dateStr) return 'N/A';
var d = new Date(dateStr);
return d.toLocaleDateString() + ' ' + d.toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'});
},
formatSize: function(bytes) {
if (!bytes) return '';
if (bytes > 1073741824) return (bytes / 1073741824).toFixed(1) + ' GB';
if (bytes > 1048576) return (bytes / 1048576).toFixed(0) + ' MB';
return (bytes / 1024).toFixed(0) + ' KB';
},
loadSchedule: function() {
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="srfrec-msg">No upcoming sport livestreams found.</p>';
return;
}
var html = '<table>';
html += '<thead><tr><th>Title</th><th>Start</th><th>End</th><th>Action</th></tr></thead><tbody>';
programs.forEach(function(p) {
html += '<tr>';
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 type="button" class="btn btn-record" onclick="SRFRec.scheduleRecording(\'' + encodeURIComponent(p.urn) + '\')">Record</button></td>';
html += '</tr>';
});
html += '</tbody></table>';
container.innerHTML = html;
})
.catch(function(err) {
container.innerHTML = '<p class="srfrec-error">Error loading schedule: ' + err.message + '</p>';
});
},
scheduleRecording: function(encodedUrn) {
var self = this;
fetch(this.serverUrl + '/Plugins/SRFPlay/Recording/Schedule/' + encodedUrn, {
method: 'POST',
headers: this.getHeaders()
})
.then(function(r) {
if (r.ok) {
alert('Recording scheduled!');
self.loadRecordings();
} else {
alert('Failed to schedule recording');
}
})
.catch(function(err) { alert('Error: ' + err.message); });
},
loadRecordings: function() {
this.loadActiveRecordings();
this.loadCompletedRecordings();
},
loadActiveRecordings: function() {
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(); })
.then(function(recordings) {
var activeStates = ['Scheduled', 'WaitingForStream', 'Recording', 0, 1, 2];
var active = recordings.filter(function(r) {
return activeStates.indexOf(r.state) !== -1;
});
if (active.length === 0) {
container.innerHTML = '<p class="srfrec-msg">No scheduled or active recordings.</p>';
return;
}
var stateMap = {
'Scheduled': {label: 'Scheduled', cls: 'status-scheduled'},
'WaitingForStream': {label: 'Waiting', cls: 'status-waiting'},
'Recording': {label: 'Recording', cls: 'status-recording'},
0: {label: 'Scheduled', cls: 'status-scheduled'},
1: {label: 'Waiting', cls: 'status-waiting'},
2: {label: 'Recording', cls: 'status-recording'}
};
var html = '<table>';
html += '<thead><tr><th>Title</th><th>Status</th><th>Start</th><th>Action</th></tr></thead><tbody>';
active.forEach(function(r) {
var st = stateMap[r.state] || {label: r.state, cls: ''};
html += '<tr>';
html += '<td>' + (r.title || 'Unknown') + '</td>';
html += '<td><span class="status ' + st.cls + '">' + st.label + '</span></td>';
html += '<td>' + SRFRec.formatDate(r.validFrom) + '</td>';
html += '<td>';
if (r.state === 2 || r.state === 'Recording') {
html += '<button type="button" class="btn btn-stop" onclick="SRFRec.stopRecording(\'' + r.id + '\')">Stop</button>';
} else {
html += '<button type="button" class="btn btn-cancel" onclick="SRFRec.cancelRecording(\'' + r.id + '\')">Cancel</button>';
}
html += '</td></tr>';
});
html += '</tbody></table>';
container.innerHTML = html;
})
.catch(function(err) {
container.innerHTML = '<p class="srfrec-error">Error: ' + err.message + '</p>';
});
},
loadCompletedRecordings: function() {
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="srfrec-msg">No completed recordings.</p>';
return;
}
var html = '<table>';
html += '<thead><tr><th>Title</th><th>Recorded</th><th>Size</th><th>Action</th></tr></thead><tbody>';
recordings.forEach(function(r) {
html += '<tr>';
html += '<td>' + (r.title || 'Unknown') + '</td>';
html += '<td>' + SRFRec.formatDate(r.recordingStartedAt) + '</td>';
html += '<td>' + SRFRec.formatSize(r.fileSizeBytes) + '</td>';
html += '<td><button type="button" class="btn btn-delete" onclick="SRFRec.deleteRecording(\'' + r.id + '\')">Delete</button></td>';
html += '</tr>';
});
html += '</tbody></table>';
container.innerHTML = html;
})
.catch(function(err) {
container.innerHTML = '<p class="srfrec-error">Error: ' + err.message + '</p>';
});
},
stopRecording: function(id) {
var self = this;
fetch(this.serverUrl + '/Plugins/SRFPlay/Recording/Active/' + id + '/Stop', {
method: 'POST',
headers: this.getHeaders()
}).then(function() { self.loadRecordings(); });
},
cancelRecording: function(id) {
var self = this;
fetch(this.serverUrl + '/Plugins/SRFPlay/Recording/Schedule/' + id, {
method: 'DELETE',
headers: this.getHeaders()
}).then(function() { self.loadRecordings(); });
},
deleteRecording: function(id) {
if (!confirm('Delete this recording and its file?')) return;
var self = this;
fetch(this.serverUrl + '/Plugins/SRFPlay/Recording/Completed/' + id + '?deleteFile=true', {
method: 'DELETE',
headers: this.getHeaders()
}).then(function() { self.loadRecordings(); });
}
};
(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>
</div>
@@ -0,0 +1,175 @@
using System.IO;
using System.Reflection;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Plugin.SRFPlay.Api.Models;
using Jellyfin.Plugin.SRFPlay.Services.Interfaces;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.SRFPlay.Controllers;
/// <summary>
/// Controller for managing sport livestream recordings.
/// </summary>
[ApiController]
[Route("Plugins/SRFPlay/Recording")]
[Authorize]
public class RecordingController : ControllerBase
{
private readonly ILogger<RecordingController> _logger;
private readonly IRecordingService _recordingService;
/// <summary>
/// Initializes a new instance of the <see cref="RecordingController"/> class.
/// </summary>
/// <param name="logger">The logger.</param>
/// <param name="recordingService">The recording service.</param>
public RecordingController(
ILogger<RecordingController> logger,
IRecordingService recordingService)
{
_logger = logger;
_recordingService = recordingService;
}
/// <summary>
/// Serves the recording manager page accessible to any authenticated user.
/// </summary>
/// <returns>The recording manager HTML page.</returns>
[HttpGet("Page")]
[AllowAnonymous]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public IActionResult GetRecordingPage()
{
var assembly = Assembly.GetExecutingAssembly();
var resourceStream = assembly.GetManifestResourceStream("Jellyfin.Plugin.SRFPlay.Configuration.recordingPage.html");
if (resourceStream == null)
{
return NotFound("Recording page not found");
}
return File(resourceStream, "text/html");
}
/// <summary>
/// Gets upcoming sport livestreams available for recording.
/// </summary>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>List of upcoming livestreams.</returns>
[HttpGet("Schedule")]
[ProducesResponseType(StatusCodes.Status200OK)]
public async Task<IActionResult> GetSchedule(CancellationToken cancellationToken)
{
var schedule = await _recordingService.GetUpcomingScheduleAsync(cancellationToken).ConfigureAwait(false);
return Ok(schedule);
}
/// <summary>
/// Schedules a livestream for recording by URN.
/// </summary>
/// <param name="urn">The SRF URN.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>The created recording entry.</returns>
[HttpPost("Schedule/{urn}")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<IActionResult> ScheduleRecording(
[FromRoute] string urn,
CancellationToken cancellationToken)
{
if (string.IsNullOrEmpty(urn))
{
return BadRequest("URN is required");
}
// URN comes URL-encoded with colons, decode it
urn = System.Net.WebUtility.UrlDecode(urn);
_logger.LogInformation("Scheduling recording for URN: {Urn}", urn);
var entry = await _recordingService.ScheduleRecordingAsync(urn, cancellationToken).ConfigureAwait(false);
return Ok(entry);
}
/// <summary>
/// Cancels a scheduled recording.
/// </summary>
/// <param name="id">The recording ID.</param>
/// <returns>OK or NotFound.</returns>
[HttpDelete("Schedule/{id}")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public IActionResult CancelRecording([FromRoute] string id)
{
return _recordingService.CancelRecording(id) ? Ok() : NotFound();
}
/// <summary>
/// Gets currently active recordings.
/// </summary>
/// <returns>List of active recordings.</returns>
[HttpGet("Active")]
[ProducesResponseType(StatusCodes.Status200OK)]
public IActionResult GetActiveRecordings()
{
var active = _recordingService.GetRecordings(RecordingState.Recording);
return Ok(active);
}
/// <summary>
/// Stops an active recording.
/// </summary>
/// <param name="id">The recording ID.</param>
/// <returns>OK or NotFound.</returns>
[HttpPost("Active/{id}/Stop")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public IActionResult StopRecording([FromRoute] string id)
{
return _recordingService.StopRecording(id) ? Ok() : NotFound();
}
/// <summary>
/// Gets completed recordings.
/// </summary>
/// <returns>List of completed recordings.</returns>
[HttpGet("Completed")]
[ProducesResponseType(StatusCodes.Status200OK)]
public IActionResult GetCompletedRecordings()
{
var completed = _recordingService.GetRecordings(RecordingState.Completed);
return Ok(completed);
}
/// <summary>
/// Gets all recordings (all states).
/// </summary>
/// <returns>List of all recordings.</returns>
[HttpGet("All")]
[ProducesResponseType(StatusCodes.Status200OK)]
public IActionResult GetAllRecordings()
{
var all = _recordingService.GetRecordings();
return Ok(all);
}
/// <summary>
/// Deletes a completed recording and its file.
/// </summary>
/// <param name="id">The recording ID.</param>
/// <param name="deleteFile">Whether to delete the file too.</param>
/// <returns>OK or NotFound.</returns>
[HttpDelete("Completed/{id}")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public IActionResult DeleteRecording(
[FromRoute] string id,
[FromQuery] bool deleteFile = true)
{
return _recordingService.DeleteRecording(id, deleteFile) ? Ok() : NotFound();
}
}
@@ -160,7 +160,8 @@ public class StreamProxyController : ControllerBase
AddManifestCacheHeaders(actualItemId); AddManifestCacheHeaders(actualItemId);
_logger.LogDebug("Returning master manifest for item {ItemId} ({Length} bytes)", itemId, manifestContent.Length); _logger.LogDebug("Returning master manifest for item {ItemId} ({Length} bytes)", itemId, manifestContent.Length);
return Content(manifestContent, "application/vnd.apple.mpegurl; charset=utf-8"); // Use application/x-mpegURL for broader compatibility (Samsung AVPlay requires it)
return Content(manifestContent, "application/x-mpegURL; charset=utf-8");
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -231,7 +232,7 @@ public class StreamProxyController : ControllerBase
AddManifestCacheHeaders(actualItemId, isVariantManifest: true); AddManifestCacheHeaders(actualItemId, isVariantManifest: true);
_logger.LogDebug("Returning variant manifest for item {ItemId} ({Length} bytes)", itemId, rewrittenContent.Length); _logger.LogDebug("Returning variant manifest for item {ItemId} ({Length} bytes)", itemId, rewrittenContent.Length);
return Content(rewrittenContent, "application/vnd.apple.mpegurl; charset=utf-8"); return Content(rewrittenContent, "application/x-mpegURL; charset=utf-8");
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -292,9 +293,23 @@ public class StreamProxyController : ControllerBase
_logger.LogDebug("Streamed segment {SegmentPath} ({ContentType})", segmentPath, contentType); _logger.LogDebug("Streamed segment {SegmentPath} ({ContentType})", segmentPath, contentType);
return new EmptyResult(); return new EmptyResult();
} }
catch (OperationCanceledException)
{
// Client disconnected during streaming (e.g., FFmpeg stopped, player seeked).
// This is expected behavior, not an error.
_logger.LogDebug("Segment streaming canceled - ItemId: {ItemId}, Path: {SegmentPath}", itemId, segmentPath);
return new EmptyResult();
}
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogError(ex, "Error proxying segment - ItemId: {ItemId}, Path: {SegmentPath}", itemId, segmentPath); _logger.LogError(ex, "Error proxying segment - ItemId: {ItemId}, Path: {SegmentPath}", itemId, segmentPath);
// If we already started streaming data to the client, we can't change the status code
if (Response.HasStarted)
{
return new EmptyResult();
}
return StatusCode(StatusCodes.Status500InternalServerError); return StatusCode(StatusCodes.Status500InternalServerError);
} }
} }
@@ -26,11 +26,18 @@
<ItemGroup> <ItemGroup>
<None Remove="Configuration\configPage.html" /> <None Remove="Configuration\configPage.html" />
<EmbeddedResource Include="Configuration\configPage.html" /> <EmbeddedResource Include="Configuration\configPage.html" />
<None Remove="Configuration\recordingPage.html" />
<EmbeddedResource Include="Configuration\recordingPage.html" />
</ItemGroup> </ItemGroup>
<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>
+6
View File
@@ -46,6 +46,12 @@ 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)
} }
// 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;
using System.Threading.Tasks; using System.Threading.Tasks;
using Jellyfin.Plugin.SRFPlay.Services.Interfaces; using Jellyfin.Plugin.SRFPlay.Services.Interfaces;
using Jellyfin.Plugin.SRFPlay.Utilities;
using MediaBrowser.Model.Tasks; using MediaBrowser.Model.Tasks;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
@@ -63,20 +64,25 @@ public class ContentRefreshTask : IScheduledTask
return; return;
} }
// Refresh latest content // Refresh content for every enabled business unit (one channel per unit).
if (config.EnableLatestContent) var units = config.ResolveEnabledUnits();
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);
@@ -0,0 +1,76 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Plugin.SRFPlay.Services.Interfaces;
using MediaBrowser.Model.Tasks;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.SRFPlay.ScheduledTasks;
/// <summary>
/// Scheduled task that checks and manages sport livestream recordings.
/// Runs every 2 minutes to start scheduled recordings when streams go live
/// and stop recordings when they end.
/// </summary>
public class RecordingSchedulerTask : IScheduledTask
{
private readonly ILogger<RecordingSchedulerTask> _logger;
private readonly IRecordingService _recordingService;
/// <summary>
/// Initializes a new instance of the <see cref="RecordingSchedulerTask"/> class.
/// </summary>
/// <param name="logger">The logger.</param>
/// <param name="recordingService">The recording service.</param>
public RecordingSchedulerTask(
ILogger<RecordingSchedulerTask> logger,
IRecordingService recordingService)
{
_logger = logger;
_recordingService = recordingService;
}
/// <inheritdoc />
public string Name => "Process SRF Play Recordings";
/// <inheritdoc />
public string Description => "Checks scheduled recordings and starts/stops them as needed";
/// <inheritdoc />
public string Category => "SRF Play";
/// <inheritdoc />
public string Key => "SRFPlayRecordingScheduler";
/// <inheritdoc />
public async Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken)
{
_logger.LogDebug("Processing SRF Play recordings");
progress?.Report(0);
try
{
await _recordingService.ProcessRecordingsAsync(cancellationToken).ConfigureAwait(false);
progress?.Report(100);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error processing recordings");
throw;
}
}
/// <inheritdoc />
public IEnumerable<TaskTriggerInfo> GetDefaultTriggers()
{
return new[]
{
new TaskTriggerInfo
{
Type = TaskTriggerInfo.TriggerInterval,
IntervalTicks = TimeSpan.FromSeconds(30).Ticks
}
};
}
}
+12 -2
View File
@@ -41,11 +41,21 @@ public class ServiceRegistrator : IPluginServiceRegistrator
// Register media source provider // Register media source provider
serviceCollection.AddSingleton<SRFMediaProvider>(); serviceCollection.AddSingleton<SRFMediaProvider>();
// Register recording service
serviceCollection.AddSingleton<IRecordingService, RecordingService>();
// Register scheduled tasks // Register scheduled tasks
serviceCollection.AddSingleton<IScheduledTask, ContentRefreshTask>(); serviceCollection.AddSingleton<IScheduledTask, ContentRefreshTask>();
serviceCollection.AddSingleton<IScheduledTask, ExpirationCheckTask>(); serviceCollection.AddSingleton<IScheduledTask, ExpirationCheckTask>();
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>();
} }
} }
@@ -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");
} }
@@ -120,9 +120,10 @@ public class ContentExpirationService : IContentExpirationService
if (mediaComposition?.HasChapters != true) if (mediaComposition?.HasChapters != true)
{ {
// If we can't fetch the content, consider it expired // Don't treat API failures as expired - the content may still be available
_logger.LogWarning("Could not fetch media composition for URN: {Urn}, treating as expired", urn); // and a transient error (network issue, 403, API outage) shouldn't delete library items
return true; _logger.LogWarning("Could not fetch media composition for URN: {Urn}, skipping (not treating as expired)", urn);
return false;
} }
var chapter = mediaComposition.ChapterList[0]; var chapter = mediaComposition.ChapterList[0];
@@ -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,
@@ -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);
} }
@@ -0,0 +1,65 @@
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Plugin.SRFPlay.Api.Models;
using Jellyfin.Plugin.SRFPlay.Api.Models.PlayV3;
namespace Jellyfin.Plugin.SRFPlay.Services.Interfaces;
/// <summary>
/// Service for managing sport livestream recordings.
/// </summary>
public interface IRecordingService
{
/// <summary>
/// Gets upcoming sport livestreams that can be recorded.
/// </summary>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>List of upcoming sport livestreams.</returns>
Task<IReadOnlyList<PlayV3TvProgram>> GetUpcomingScheduleAsync(CancellationToken cancellationToken = default);
/// <summary>
/// Schedules a livestream for recording.
/// </summary>
/// <param name="urn">The SRF URN to record.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>The created recording entry.</returns>
Task<RecordingEntry> ScheduleRecordingAsync(string urn, CancellationToken cancellationToken = default);
/// <summary>
/// Cancels a scheduled recording.
/// </summary>
/// <param name="recordingId">The recording ID.</param>
/// <returns>True if cancelled.</returns>
bool CancelRecording(string recordingId);
/// <summary>
/// Stops an active recording.
/// </summary>
/// <param name="recordingId">The recording ID.</param>
/// <returns>True if stopped.</returns>
bool StopRecording(string recordingId);
/// <summary>
/// Gets all recordings by state.
/// </summary>
/// <param name="stateFilter">Optional state filter.</param>
/// <returns>List of matching recording entries.</returns>
IReadOnlyList<RecordingEntry> GetRecordings(RecordingState? stateFilter = null);
/// <summary>
/// Deletes a completed recording (entry and optionally the file).
/// </summary>
/// <param name="recordingId">The recording ID.</param>
/// <param name="deleteFile">Whether to delete the file too.</param>
/// <returns>True if deleted.</returns>
bool DeleteRecording(string recordingId, bool deleteFile = true);
/// <summary>
/// Checks scheduled recordings and starts/stops them as needed.
/// Called periodically by the scheduler task.
/// </summary>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>A task representing the async operation.</returns>
Task ProcessRecordingsAsync(CancellationToken cancellationToken = default);
}
@@ -62,9 +62,15 @@ public class MediaSourceFactory : IMediaSourceFactory
return Task.FromResult<MediaSourceInfo?>(null); return Task.FromResult<MediaSourceInfo?>(null);
} }
// Detect if this is a live stream // A scheduled livestream is only treated as live when the broadcast window is open.
var isLiveStream = chapter.Type == "SCHEDULED_LIVESTREAM" || // Past replays (ValidTo in the past) and upcoming events (ValidFrom in the future)
urn.Contains("livestream", StringComparison.OrdinalIgnoreCase); // 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 // Register stream with UNAUTHENTICATED URL - proxy will authenticate on-demand
// This avoids wasting 30-second tokens during category browsing // This avoids wasting 30-second tokens during category browsing
@@ -91,15 +97,15 @@ public class MediaSourceFactory : IMediaSourceFactory
Protocol = MediaProtocol.Http, Protocol = MediaProtocol.Http,
// Use "hls" to trigger hls.js player in web client // Use "hls" to trigger hls.js player in web client
Container = "hls", Container = "hls",
SupportsDirectStream = true, SupportsDirectStream = false,
SupportsDirectPlay = true, SupportsDirectPlay = true,
SupportsTranscoding = false, SupportsTranscoding = true,
IsRemote = true, IsRemote = true,
Type = MediaSourceType.Default, Type = MediaSourceType.Default,
RunTimeTicks = chapter.Duration > 0 ? TimeSpan.FromMilliseconds(chapter.Duration).Ticks : null, RunTimeTicks = chapter.Duration > 0 ? TimeSpan.FromMilliseconds(chapter.Duration).Ticks : null,
VideoType = VideoType.VideoFile, VideoType = VideoType.VideoFile,
IsInfiniteStream = isLiveStream, IsInfiniteStream = isLiveStream,
// Don't use RequiresOpening - it forces Jellyfin to transcode which breaks token auth // RequiresOpening = false: proxy handles auth directly, no need for Jellyfin to open the stream
RequiresOpening = false, RequiresOpening = false,
RequiresClosing = false, RequiresClosing = false,
// Disable probing - we provide stream info directly // Disable probing - we provide stream info directly
@@ -0,0 +1,581 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Diagnostics;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Text.Json;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Plugin.SRFPlay.Api;
using Jellyfin.Plugin.SRFPlay.Api.Models;
using Jellyfin.Plugin.SRFPlay.Api.Models.PlayV3;
using Jellyfin.Plugin.SRFPlay.Services.Interfaces;
using MediaBrowser.Controller;
using MediaBrowser.Controller.MediaEncoding;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.SRFPlay.Services;
/// <summary>
/// Service for managing sport livestream recordings using ffmpeg.
/// </summary>
public class RecordingService : IRecordingService, IDisposable
{
private readonly ILogger<RecordingService> _logger;
private readonly ISRFApiClientFactory _apiClientFactory;
private readonly IStreamProxyService _proxyService;
private readonly IStreamUrlResolver _streamUrlResolver;
private readonly IMediaCompositionFetcher _mediaCompositionFetcher;
private readonly IServerApplicationHost _appHost;
private readonly IMediaEncoder _mediaEncoder;
private readonly ConcurrentDictionary<string, Process> _activeProcesses = new();
private static readonly JsonSerializerOptions _jsonOptions = new() { WriteIndented = true };
private readonly SemaphoreSlim _persistLock = new(1, 1);
private readonly SemaphoreSlim _processLock = new(1, 1);
private List<RecordingEntry> _recordings = new();
private bool _loaded;
private bool _disposed;
/// <summary>
/// Initializes a new instance of the <see cref="RecordingService"/> class.
/// </summary>
/// <param name="logger">The logger.</param>
/// <param name="apiClientFactory">The API client factory.</param>
/// <param name="proxyService">The stream proxy service.</param>
/// <param name="streamUrlResolver">The stream URL resolver.</param>
/// <param name="mediaCompositionFetcher">The media composition fetcher.</param>
/// <param name="appHost">The application host.</param>
/// <param name="mediaEncoder">The media encoder for ffmpeg path.</param>
public RecordingService(
ILogger<RecordingService> logger,
ISRFApiClientFactory apiClientFactory,
IStreamProxyService proxyService,
IStreamUrlResolver streamUrlResolver,
IMediaCompositionFetcher mediaCompositionFetcher,
IServerApplicationHost appHost,
IMediaEncoder mediaEncoder)
{
_logger = logger;
_apiClientFactory = apiClientFactory;
_proxyService = proxyService;
_streamUrlResolver = streamUrlResolver;
_mediaCompositionFetcher = mediaCompositionFetcher;
_appHost = appHost;
_mediaEncoder = mediaEncoder;
}
private string GetDataFilePath()
{
var dataPath = Plugin.Instance?.DataFolderPath ?? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "jellyfin", "plugins", "SRFPlay");
Directory.CreateDirectory(dataPath);
return Path.Combine(dataPath, "recordings.json");
}
private string GetRecordingOutputPath()
{
var config = Plugin.Instance?.Configuration;
var path = config?.RecordingOutputPath;
if (string.IsNullOrWhiteSpace(path))
{
path = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "SRFRecordings");
}
Directory.CreateDirectory(path);
return path;
}
private string GetServerBaseUrl()
{
var config = Plugin.Instance?.Configuration;
if (config != null && !string.IsNullOrWhiteSpace(config.PublicServerUrl))
{
return config.PublicServerUrl.TrimEnd('/');
}
// For local ffmpeg access, use localhost directly
return "http://localhost:8096";
}
private async Task LoadRecordingsAsync()
{
if (_loaded)
{
return;
}
var filePath = GetDataFilePath();
if (File.Exists(filePath))
{
try
{
var json = await File.ReadAllTextAsync(filePath).ConfigureAwait(false);
_recordings = JsonSerializer.Deserialize<List<RecordingEntry>>(json) ?? new List<RecordingEntry>();
_logger.LogInformation("Loaded {Count} recording entries from {Path}", _recordings.Count, filePath);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to load recordings from {Path}", filePath);
_recordings = new List<RecordingEntry>();
}
}
_loaded = true;
}
private async Task SaveRecordingsAsync()
{
await _persistLock.WaitAsync().ConfigureAwait(false);
try
{
var filePath = GetDataFilePath();
var json = JsonSerializer.Serialize(_recordings, _jsonOptions);
await File.WriteAllTextAsync(filePath, json).ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to save recordings");
}
finally
{
_persistLock.Release();
}
}
/// <inheritdoc />
public async Task<IReadOnlyList<PlayV3TvProgram>> GetUpcomingScheduleAsync(CancellationToken cancellationToken)
{
var config = Plugin.Instance?.Configuration;
var units = config?.ResolveEnabledUnits() ?? new[] { Configuration.BusinessUnit.SRF };
using var apiClient = _apiClientFactory.CreateClient();
// 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)
{
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 all
.Where(ls => ls.Blocked != true && (ls.ValidTo == null || ls.ValidTo.Value.ToUniversalTime() > DateTime.UtcNow))
.OrderBy(ls => ls.ValidFrom)
.ToList();
}
/// <inheritdoc />
public async Task<RecordingEntry> ScheduleRecordingAsync(string urn, CancellationToken cancellationToken)
{
await LoadRecordingsAsync().ConfigureAwait(false);
// Check if already scheduled
var existing = _recordings.FirstOrDefault(r => r.Urn == urn && r.State is RecordingState.Scheduled or RecordingState.WaitingForStream or RecordingState.Recording);
if (existing != null)
{
_logger.LogInformation("Recording already exists for URN {Urn} in state {State}", urn, existing.State);
return existing;
}
// 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 = ParseBusinessUnitFromUrn(urn)
?? (config?.BusinessUnit ?? Configuration.BusinessUnit.SRF).ToString().ToLowerInvariant();
using var apiClient = _apiClientFactory.CreateClient();
var livestreams = await apiClient.GetScheduledLivestreamsAsync(businessUnit, "SPORT", cancellationToken).ConfigureAwait(false);
var program = livestreams?.FirstOrDefault(ls => ls.Urn == urn);
var entry = new RecordingEntry
{
Id = Guid.NewGuid().ToString("N"),
Urn = urn,
BusinessUnit = ParseBusinessUnitFromUrn(urn) ?? businessUnit,
Title = program?.Title ?? urn,
Description = program?.Lead ?? program?.Description,
ImageUrl = program?.ImageUrl,
ValidFrom = program?.ValidFrom,
ValidTo = program?.ValidTo,
State = RecordingState.Scheduled,
CreatedAt = DateTime.UtcNow
};
_recordings.Add(entry);
await SaveRecordingsAsync().ConfigureAwait(false);
_logger.LogInformation("Scheduled recording for '{Title}' (URN: {Urn}, starts: {ValidFrom})", entry.Title, urn, entry.ValidFrom);
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)
{
var entry = _recordings.FirstOrDefault(r => r.Id == recordingId);
if (entry == null)
{
return false;
}
if (entry.State == RecordingState.Recording)
{
StopFfmpeg(recordingId);
}
entry.State = RecordingState.Cancelled;
entry.RecordingEndedAt = DateTime.UtcNow;
_ = SaveRecordingsAsync();
_logger.LogInformation("Cancelled recording '{Title}' ({Id})", entry.Title, recordingId);
return true;
}
/// <inheritdoc />
public bool StopRecording(string recordingId)
{
var entry = _recordings.FirstOrDefault(r => r.Id == recordingId && r.State == RecordingState.Recording);
if (entry == null)
{
return false;
}
StopFfmpeg(recordingId);
entry.State = RecordingState.Completed;
entry.RecordingEndedAt = DateTime.UtcNow;
if (entry.OutputPath != null && File.Exists(entry.OutputPath))
{
entry.FileSizeBytes = new FileInfo(entry.OutputPath).Length;
}
_ = SaveRecordingsAsync();
_logger.LogInformation("Stopped recording '{Title}' ({Id})", entry.Title, recordingId);
return true;
}
/// <inheritdoc />
public IReadOnlyList<RecordingEntry> GetRecordings(RecordingState? stateFilter)
{
// Ensure loaded synchronously for simple reads
if (!_loaded)
{
LoadRecordingsAsync().GetAwaiter().GetResult();
}
if (stateFilter.HasValue)
{
return _recordings.Where(r => r.State == stateFilter.Value).OrderByDescending(r => r.CreatedAt).ToList();
}
return _recordings.OrderByDescending(r => r.CreatedAt).ToList();
}
/// <inheritdoc />
public bool DeleteRecording(string recordingId, bool deleteFile)
{
var entry = _recordings.FirstOrDefault(r => r.Id == recordingId);
if (entry == null)
{
return false;
}
if (entry.State == RecordingState.Recording)
{
StopFfmpeg(recordingId);
}
if (deleteFile && !string.IsNullOrEmpty(entry.OutputPath) && File.Exists(entry.OutputPath))
{
try
{
File.Delete(entry.OutputPath);
_logger.LogInformation("Deleted recording file: {Path}", entry.OutputPath);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to delete recording file: {Path}", entry.OutputPath);
}
}
_recordings.Remove(entry);
_ = SaveRecordingsAsync();
_logger.LogInformation("Deleted recording entry '{Title}' ({Id})", entry.Title, recordingId);
return true;
}
/// <inheritdoc />
public async Task ProcessRecordingsAsync(CancellationToken cancellationToken)
{
// Prevent overlapping scheduler runs from spawning duplicate ffmpeg processes
if (!await _processLock.WaitAsync(0, CancellationToken.None).ConfigureAwait(false))
{
_logger.LogDebug("ProcessRecordingsAsync already running, skipping");
return;
}
try
{
await ProcessRecordingsCoreAsync(cancellationToken).ConfigureAwait(false);
}
finally
{
_processLock.Release();
}
}
private async Task ProcessRecordingsCoreAsync(CancellationToken cancellationToken)
{
await LoadRecordingsAsync().ConfigureAwait(false);
var now = DateTime.UtcNow;
var changed = false;
foreach (var entry in _recordings.ToList())
{
// Normalize ValidFrom/ValidTo to UTC for correct comparison
var validFromUtc = entry.ValidFrom.HasValue ? entry.ValidFrom.Value.ToUniversalTime() : (DateTime?)null;
var validToUtc = entry.ValidTo.HasValue ? entry.ValidTo.Value.ToUniversalTime() : (DateTime?)null;
switch (entry.State)
{
case RecordingState.Scheduled:
case RecordingState.WaitingForStream:
// Check if it's time to start recording
if (validFromUtc.HasValue && validFromUtc.Value <= now.AddMinutes(2))
{
_logger.LogInformation(
"Time to start recording '{Title}': ValidFrom={ValidFrom} (UTC: {ValidFromUtc}), Now={Now}",
entry.Title,
entry.ValidFrom,
validFromUtc,
now);
changed |= await TryStartRecordingAsync(entry, cancellationToken).ConfigureAwait(false);
}
break;
case RecordingState.Recording:
// Check if recording should stop (ValidTo reached or process died)
if (validToUtc.HasValue && validToUtc.Value <= now)
{
_logger.LogInformation("Recording '{Title}' reached ValidTo, stopping", entry.Title);
StopFfmpeg(entry.Id);
entry.State = RecordingState.Completed;
entry.RecordingEndedAt = now;
if (entry.OutputPath != null && File.Exists(entry.OutputPath))
{
entry.FileSizeBytes = new FileInfo(entry.OutputPath).Length;
}
changed = true;
}
else if (!_activeProcesses.ContainsKey(entry.Id))
{
// ffmpeg process died unexpectedly — try to restart
_logger.LogWarning("ffmpeg process for '{Title}' is no longer running, attempting restart", entry.Title);
changed |= await TryStartRecordingAsync(entry, cancellationToken).ConfigureAwait(false);
}
break;
}
}
if (changed)
{
await SaveRecordingsAsync().ConfigureAwait(false);
}
}
private async Task<bool> TryStartRecordingAsync(RecordingEntry entry, CancellationToken cancellationToken)
{
try
{
// Fetch the media composition to get the stream URL
var mediaComposition = await _mediaCompositionFetcher.GetMediaCompositionAsync(entry.Urn, cacheDurationOverride: 2, cancellationToken: cancellationToken).ConfigureAwait(false);
var chapter = mediaComposition?.ChapterList is { Count: > 0 } list ? list[0] : null;
if (chapter == null)
{
_logger.LogDebug("No chapter found for '{Title}', stream may not be live yet", entry.Title);
entry.State = RecordingState.WaitingForStream;
return true;
}
var config = Plugin.Instance?.Configuration;
var quality = config?.QualityPreference ?? Configuration.QualityPreference.Auto;
var streamUrl = _streamUrlResolver.GetStreamUrl(chapter, quality);
if (string.IsNullOrEmpty(streamUrl))
{
_logger.LogDebug("No stream URL available for '{Title}', waiting", entry.Title);
entry.State = RecordingState.WaitingForStream;
return true;
}
// Register the stream with the proxy so we can use the proxy URL
var itemId = $"rec_{entry.Id}";
var isLiveStream = chapter.Type == "SCHEDULED_LIVESTREAM" || entry.Urn.Contains("livestream", StringComparison.OrdinalIgnoreCase);
_proxyService.RegisterStreamDeferred(itemId, streamUrl, entry.Urn, isLiveStream);
// Build proxy URL for ffmpeg (use localhost for local access)
var proxyUrl = $"{GetServerBaseUrl()}/Plugins/SRFPlay/Proxy/{itemId}/master.m3u8";
// Build output file path
var safeTitle = SanitizeFileName(entry.Title);
var timestamp = DateTime.Now.ToString("yyyy-MM-dd_HHmm", CultureInfo.InvariantCulture);
var outputPath = Path.Combine(GetRecordingOutputPath(), $"{safeTitle}_{timestamp}.mkv");
entry.OutputPath = outputPath;
// Start ffmpeg
StartFfmpeg(entry.Id, proxyUrl, outputPath);
entry.State = RecordingState.Recording;
entry.RecordingStartedAt = DateTime.UtcNow;
_logger.LogInformation("Started recording '{Title}' to {OutputPath}", entry.Title, outputPath);
return true;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to start recording '{Title}'", entry.Title);
entry.State = RecordingState.Failed;
entry.ErrorMessage = ex.Message;
return true;
}
}
private void StartFfmpeg(string recordingId, string inputUrl, string outputPath)
{
var process = new Process
{
StartInfo = new ProcessStartInfo
{
FileName = _mediaEncoder.EncoderPath,
Arguments = $"-y -i \"{inputUrl}\" -c copy -movflags +faststart \"{outputPath}\"",
UseShellExecute = false,
RedirectStandardInput = true,
RedirectStandardError = true,
CreateNoWindow = true
},
EnableRaisingEvents = true
};
process.ErrorDataReceived += (_, args) =>
{
if (!string.IsNullOrEmpty(args.Data))
{
_logger.LogDebug("ffmpeg [{RecordingId}]: {Data}", recordingId, args.Data);
}
};
process.Exited += (_, _) =>
{
_logger.LogInformation("ffmpeg process exited for recording {RecordingId} with code {ExitCode}", recordingId, process.ExitCode);
_activeProcesses.TryRemove(recordingId, out _);
};
process.Start();
process.BeginErrorReadLine();
_activeProcesses[recordingId] = process;
_logger.LogInformation("Started ffmpeg (PID {Pid}) for recording {RecordingId}: {Args}", process.Id, recordingId, process.StartInfo.Arguments);
}
private void StopFfmpeg(string recordingId)
{
if (_activeProcesses.TryRemove(recordingId, out var process))
{
try
{
if (!process.HasExited)
{
// Send 'q' to ffmpeg stdin for graceful shutdown
process.StandardInput.Write("q");
process.StandardInput.Flush();
if (!process.WaitForExit(10000))
{
_logger.LogWarning("ffmpeg did not exit gracefully for {RecordingId}, killing", recordingId);
process.Kill(true);
}
}
process.Dispose();
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Error stopping ffmpeg for recording {RecordingId}", recordingId);
}
}
}
private static string SanitizeFileName(string name)
{
var invalid = Path.GetInvalidFileNameChars();
var sanitized = string.Join("_", name.Split(invalid, StringSplitOptions.RemoveEmptyEntries));
// Also replace spaces and other problematic chars
sanitized = Regex.Replace(sanitized, @"[\s]+", "_");
return sanitized.Length > 100 ? sanitized[..100] : sanitized;
}
/// <inheritdoc />
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}
/// <summary>
/// Releases resources.
/// </summary>
/// <param name="disposing">True to release managed resources.</param>
protected virtual void Dispose(bool disposing)
{
if (_disposed)
{
return;
}
if (disposing)
{
foreach (var kvp in _activeProcesses)
{
StopFfmpeg(kvp.Key);
}
_persistLock.Dispose();
_processLock.Dispose();
}
_disposed = true;
}
}
@@ -373,6 +373,15 @@ public class StreamProxyService : IStreamProxyService
return refreshedUrl; return refreshedUrl;
} }
if (streamInfo.IsLiveStream)
{
// For livestreams, keep the mapping and flag for re-authentication
// rather than removing it — the next request will trigger a fresh auth
_logger.LogWarning("Failed to refresh token for livestream {ItemId}, will re-authenticate on next request", itemId);
streamInfo.NeedsAuthentication = true;
return streamInfo.AuthenticatedUrl;
}
_logger.LogWarning("Failed to refresh token for item {ItemId}, removing mapping", itemId); _logger.LogWarning("Failed to refresh token for item {ItemId}, removing mapping", itemId);
_streamMappings.TryRemove(itemId, out _); _streamMappings.TryRemove(itemId, out _);
return null; return null;
@@ -647,21 +656,6 @@ public class StreamProxyService : IStreamProxyService
// Rewrite the manifest to replace Akamai URLs with proxy URLs // Rewrite the manifest to replace Akamai URLs with proxy URLs
var rewrittenContent = RewriteManifestUrls(manifestContent, authenticatedUrl, baseProxyUrl); var rewrittenContent = RewriteManifestUrls(manifestContent, authenticatedUrl, baseProxyUrl);
// For live streams, inject #EXT-X-START to tell the player to start near the live edge
// Without this, players may start at the beginning of the sliding window and stutter
// as old segments get rotated out by the CDN
if (_streamMappings.TryGetValue(itemId, out var streamInfoForManifest) && streamInfoForManifest.IsLiveStream)
{
if (!rewrittenContent.Contains("#EXT-X-START", StringComparison.Ordinal))
{
rewrittenContent = rewrittenContent.Replace(
"#EXTM3U",
"#EXTM3U\n#EXT-X-START:TIME-OFFSET=-6,PRECISE=NO",
StringComparison.Ordinal);
_logger.LogDebug("Injected #EXT-X-START tag for live stream {ItemId}", itemId);
}
}
_logger.LogDebug("Rewritten manifest for item {ItemId} ({Length} bytes):\n{Content}", itemId, rewrittenContent.Length, rewrittenContent); _logger.LogDebug("Rewritten manifest for item {ItemId} ({Length} bytes):\n{Content}", itemId, rewrittenContent.Length, rewrittenContent);
return rewrittenContent; return rewrittenContent;
} }
@@ -979,8 +973,9 @@ public class StreamProxyService : IStreamProxyService
_logger.LogDebug("Marking item {ItemId} for cleanup (old registration)", kvp.Key); _logger.LogDebug("Marking item {ItemId} for cleanup (old registration)", kvp.Key);
} }
// Remove if token has expired // Remove if token has expired — but skip livestreams since their CDN tokens
if (kvp.Value.TokenExpiresAt.HasValue && kvp.Value.TokenExpiresAt.Value <= now) // expire every ~30s and get refreshed on-demand during playback
if (kvp.Value.TokenExpiresAt.HasValue && kvp.Value.TokenExpiresAt.Value <= now && !kvp.Value.IsLiveStream)
{ {
shouldRemove = true; shouldRemove = true;
_logger.LogDebug("Marking item {ItemId} for cleanup (expired token)", kvp.Key); _logger.LogDebug("Marking item {ItemId} for cleanup (expired token)", kvp.Key);
+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

+112
View File
@@ -8,6 +8,118 @@
"category": "Live TV", "category": "Live TV",
"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.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": "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",
"changelog": "Release 1.0.27",
"targetAbi": "10.9.0.0",
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/releases/download/v1.0.27/srfplay_1.0.27.0.zip",
"checksum": "1e15e35452f7b82bf74d8c3560c15949",
"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",
"changelog": "Release 1.0.25",
"targetAbi": "10.9.0.0",
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/releases/download/v1.0.25/srfplay_1.0.25.0.zip",
"checksum": "5e4599cfeee7e0845a1be30ec288cc0b",
"timestamp": "2026-03-07T15:11:52Z"
},
{
"version": "1.0.24",
"changelog": "Release 1.0.24",
"targetAbi": "10.9.0.0",
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/releases/download/v1.0.24/srfplay_1.0.24.0.zip",
"checksum": "f54dfb8cd9b555471859ffc89c35fb90",
"timestamp": "2026-03-07T14:55:13Z"
},
{
"version": "1.0.23",
"changelog": "Release 1.0.23",
"targetAbi": "10.9.0.0",
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/releases/download/v1.0.23/srfplay_1.0.23.0.zip",
"checksum": "cd98644c758c84e2759699ea1da5a716",
"timestamp": "2026-02-28T12:13:10Z"
},
{
"version": "1.0.22",
"changelog": "Release 1.0.22",
"targetAbi": "10.9.0.0",
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/releases/download/v1.0.22/srfplay_1.0.22.0.zip",
"checksum": "08177ed8edb4b4cf2441bc808b9860bb",
"timestamp": "2026-02-28T11:36:30Z"
},
{ {
"version": "1.0.16", "version": "1.0.16",
"changelog": "Release 1.0.16", "changelog": "Release 1.0.16",