Compare commits

..
42 Commits
Author SHA1 Message Date
Gitea Actions affc87bd38 Nightly: 1.0.20260627.185 (1101385)
🚀 Release Plugin / build-and-release (push) Successful in 31s
2026-06-27 09:30:57 +00:00
dtourolle 1101385107 Really fix CI
🏗️ Build Plugin / build (push) Successful in 36s
Nightly Build / nightly-build (push) Successful in 43s
🧪 Test Plugin / test (push) Successful in 28s
2026-06-27 11:29:32 +02:00
dtourolle 4fc79f39f7 Fix CI
🏗️ Build Plugin / build (push) Successful in 41s
Nightly Build / nightly-build (push) Failing after 35s
🧪 Test Plugin / test (push) Successful in 30s
2026-06-27 11:23:53 +02:00
dtourolle 5875f81b9b fix correct guid
🏗️ Build Plugin / build (push) Successful in 42s
Nightly Build / nightly-build (push) Failing after 35s
🧪 Test Plugin / test (push) Successful in 28s
2026-06-27 11:18:48 +02:00
dtourolle cedef6d6aa Add nightly job
🏗️ Build Plugin / build (push) Successful in 29s
Nightly Build / nightly-build (push) Has been cancelled
🧪 Test Plugin / test (push) Has been cancelled
2026-06-27 11:17:57 +02:00
dtourolle 4b5d7e2a7f info add github cross-login info 2026-06-27 11:17:57 +02:00
Gitea Actions 8281960f0b Update manifest.json for latest build (7a719ee) 2026-06-27 09:01:57 +00:00
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
42 changed files with 2437 additions and 115 deletions
+31 -1
View File
@@ -25,10 +25,40 @@ jobs:
with:
path: build-${{ github.run_id }}
- name: Cache NuGet packages
uses: actions/cache@v3
with:
path: ~/.nuget/packages
key: nuget-${{ hashFiles('**/Jellyfin.Plugin.SRFPlay.csproj', '**/Jellyfin.Plugin.SRFPlay.Tests.csproj') }}
restore-keys: nuget-
- name: Restore dependencies
working-directory: build-${{ github.run_id }}
run: dotnet restore Jellyfin.Plugin.SRFPlay.sln
- name: Compute build version
id: version
run: |
# For PRs, stamp a distinct version so a side-loaded build is
# identifiable in Jellyfin: 1.0.<YYYYMMDD>-pr<N>.<run_number>.
# For plain master pushes, keep a date-based dev version.
DATE=$(date -u +"%Y%m%d")
if [ -n "${{ github.event.pull_request.number }}" ]; then
VERSION="1.0.${DATE}.${{ github.run_number }}"
LABEL="pr${{ github.event.pull_request.number }}"
else
VERSION="1.0.${DATE}.${{ github.run_number }}"
LABEL="master"
fi
echo "version=${VERSION}" >> $GITHUB_OUTPUT
echo "label=${LABEL}" >> $GITHUB_OUTPUT
echo "Build version: ${VERSION} (${LABEL})"
- name: Set build version
working-directory: build-${{ github.run_id }}
run: |
sed -i "s/^version:.*/version: \"${{ steps.version.outputs.version }}\"/" build.yaml
- name: Build solution
working-directory: build-${{ github.run_id }}
run: dotnet build Jellyfin.Plugin.SRFPlay.sln --configuration Release --no-restore --no-self-contained /m:1
@@ -52,7 +82,7 @@ jobs:
- name: Upload build artifact
uses: actions/upload-artifact@v3
with:
name: jellyfin-srfplay-plugin
name: srfplay-${{ steps.version.outputs.label }}-${{ steps.version.outputs.version }}
path: build-${{ github.run_id }}/${{ steps.jprm.outputs.artifact }}
retention-days: 30
if-no-files-found: error
+193
View File
@@ -0,0 +1,193 @@
name: 'Nightly Build'
on:
push:
branches:
- master
paths-ignore:
- '**/*.md'
- 'manifest.json'
- 'manifest-nightly.json'
jobs:
nightly-build:
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: Compute nightly version
id: version
run: |
# Date-based nightly version: 1.0.<YYYYMMDD>.<run_number>
# The run_number suffix keeps multiple builds on the same day
# monotonically increasing so Jellyfin always offers the newest.
DATE=$(date -u +"%Y%m%d")
VERSION="1.0.${DATE}.${{ github.run_number }}"
echo "version=${VERSION}" >> $GITHUB_OUTPUT
echo "Nightly version: ${VERSION}"
- name: Set build version
working-directory: build-${{ github.run_id }}
run: |
VERSION="${{ steps.version.outputs.version }}"
sed -i "s/^version:.*/version: \"${VERSION}\"/" build.yaml
cat build.yaml
- name: Restore dependencies
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 nightly 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 nightly 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 nightly 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 }}"
VERSION="${{ steps.version.outputs.version }}"
SHORT_SHA=$(echo "${{ github.sha }}" | cut -c1-7)
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 "Nightly Build ${VERSION}" --arg body "SRFPlay Jellyfin Plugin nightly build ${VERSION} from master (${SHORT_SHA})." '{tag_name: $tag, name: $name, body: $body, target_commitish: "master", draft: false, prerelease: true}')")
HTTP_CODE=$(echo "$RESPONSE" | tail -n1)
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 "Nightly release updated successfully!"
- name: Update manifest-nightly.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 }}"
VERSION="${{ steps.version.outputs.version }}"
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
NEW_VERSION=$(cat <<EOF
{
"version": "${VERSION}",
"changelog": "Nightly build ${VERSION} (${SHORT_SHA})",
"targetAbi": "10.9.0.0",
"sourceUrl": "${DOWNLOAD_URL}",
"checksum": "${CHECKSUM}",
"timestamp": "${TIMESTAMP}"
}
EOF
)
# Prepend the new build and keep only the most recent 5 nightlies.
jq --argjson newver "${NEW_VERSION}" \
'.[0].versions = ([$newver] + .[0].versions)[0:5]' \
manifest-nightly.json > manifest.tmp && mv manifest.tmp manifest-nightly.json
git add manifest-nightly.json
git commit -m "Nightly: ${VERSION} (${SHORT_SHA})" || echo "No changes to commit"
git push origin master
- name: Cleanup
if: always()
run: rm -rf build-${{ github.run_id }}
+7
View File
@@ -42,6 +42,13 @@ jobs:
sed -i "s/^version:.*/version: \"${VERSION}\"/" build.yaml
cat build.yaml
- name: Cache NuGet packages
uses: actions/cache@v3
with:
path: ~/.nuget/packages
key: nuget-${{ hashFiles('**/Jellyfin.Plugin.SRFPlay.csproj', '**/Jellyfin.Plugin.SRFPlay.Tests.csproj') }}
restore-keys: nuget-
- name: Restore dependencies
working-directory: release-${{ github.run_id }}
run: dotnet restore Jellyfin.Plugin.SRFPlay.sln
+7
View File
@@ -27,6 +27,13 @@ jobs:
with:
path: test-${{ github.run_id }}
- name: Cache NuGet packages
uses: actions/cache@v3
with:
path: ~/.nuget/packages
key: nuget-${{ hashFiles('**/Jellyfin.Plugin.SRFPlay.csproj', '**/Jellyfin.Plugin.SRFPlay.Tests.csproj') }}
restore-keys: nuget-
- name: Restore dependencies
working-directory: test-${{ github.run_id }}
run: dotnet restore Jellyfin.Plugin.SRFPlay.sln
+17
View File
@@ -0,0 +1,17 @@
<Project>
<!--
The Jellyfin meta build workflow rewrites Directory.Build.props, stamping Version,
AssemblyVersion and FileVersion all to a date-based value (e.g. 1.0.20260627.182).
The build segment (20260627) exceeds the 16-bit limit (0-65535) that AssemblyVersion
and FileVersion require, which fails the compile (CS7034/CS7035).
Directory.Build.targets is imported AFTER the project (and after Directory.Build.props),
and the workflow does not touch it, so we force valid assembly/file versions here. The
package/plugin manifest Version stays as injected; only the .NET assembly identity is
normalised, which is fine because Jellyfin identifies plugins by GUID + manifest version.
-->
<PropertyGroup>
<AssemblyVersion>1.0.0.0</AssemblyVersion>
<FileVersion>1.0.0.0</FileVersion>
</PropertyGroup>
</Project>
@@ -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
}
+18 -4
View File
@@ -20,7 +20,6 @@ namespace Jellyfin.Plugin.SRFPlay.Api;
/// </summary>
public class SRFApiClient : IDisposable
{
private static readonly System.Text.CompositeFormat PlayV3UrlFormat = System.Text.CompositeFormat.Parse(ApiEndpoints.PlayV3BaseUrlTemplate);
private readonly HttpClient _httpClient;
private readonly HttpClient _playV3HttpClient;
private readonly ILogger _logger;
@@ -58,6 +57,21 @@ public class SRFApiClient : IDisposable
};
}
/// <summary>
/// Builds the Play v3 API base URL for a business unit. The host is normally
/// www.&lt;unit&gt;.ch, but SWI's site lives at www.swissinfo.ch (www.swi.ch is an
/// unrelated welding institute), while the API path segment stays the unit code.
/// </summary>
/// <param name="businessUnit">The lowercase business unit (e.g. "srf", "swi").</param>
/// <returns>The Play v3 production base URL ending in a slash.</returns>
private static string BuildPlayV3BaseUrl(string businessUnit)
{
var host = string.Equals(businessUnit, "swi", StringComparison.OrdinalIgnoreCase)
? "www.swissinfo.ch"
: $"www.{businessUnit}.ch";
return $"https://{host}/play/v3/api/{businessUnit}/production/";
}
/// <summary>
/// Reads HTTP response content as UTF-8 string.
/// </summary>
@@ -315,7 +329,7 @@ public class SRFApiClient : IDisposable
{
try
{
var baseUrl = string.Format(CultureInfo.InvariantCulture, PlayV3UrlFormat, businessUnit);
var baseUrl = BuildPlayV3BaseUrl(businessUnit);
var url = $"{baseUrl}{endpoint}";
_logger.LogInformation("Fetching all {Endpoint} for business unit: {BusinessUnit} from URL: {Url}", endpoint, businessUnit, url);
@@ -352,7 +366,7 @@ public class SRFApiClient : IDisposable
{
try
{
var baseUrl = string.Format(CultureInfo.InvariantCulture, PlayV3UrlFormat, businessUnit);
var baseUrl = BuildPlayV3BaseUrl(businessUnit);
var url = $"{baseUrl}videos-by-show-id?showId={showId}";
_logger.LogDebug("Fetching videos for show {ShowId} from business unit: {BusinessUnit}", showId, businessUnit);
@@ -392,7 +406,7 @@ public class SRFApiClient : IDisposable
{
try
{
var baseUrl = string.Format(CultureInfo.InvariantCulture, PlayV3UrlFormat, businessUnit);
var baseUrl = BuildPlayV3BaseUrl(businessUnit);
var url = $"{baseUrl}livestreams?eventType={eventType.ToUpperInvariant()}";
_logger.LogInformation("Fetching scheduled livestreams for eventType={EventType} from business unit: {BusinessUnit}", eventType, businessUnit);
@@ -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.Tasks;
using Jellyfin.Plugin.SRFPlay.Api;
using Jellyfin.Plugin.SRFPlay.Api.Models;
using Jellyfin.Plugin.SRFPlay.Configuration;
using Jellyfin.Plugin.SRFPlay.Constants;
using Jellyfin.Plugin.SRFPlay.Services.Interfaces;
using Jellyfin.Plugin.SRFPlay.Utilities;
@@ -15,24 +17,27 @@ using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.Channels;
using MediaBrowser.Model.Dto;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.MediaInfo;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.SRFPlay.Channels;
/// <summary>
/// SRF Play channel for browsing and playing content.
/// Base channel for browsing and playing content from a single SRG SSR business unit.
/// One concrete subclass exists per unit (SRF, RTS, RSI, RTR, SWI), each shown as its own tile.
/// </summary>
public class SRFPlayChannel : IChannel, IHasCacheKey
public abstract class SrgChannelBase : IChannel, IHasCacheKey
{
private readonly ILogger<SRFPlayChannel> _logger;
private readonly ILogger<SrgChannelBase> _logger;
private readonly IContentRefreshService _contentRefreshService;
private readonly IStreamUrlResolver _streamResolver;
private readonly IMediaSourceFactory _mediaSourceFactory;
private readonly ICategoryService? _categoryService;
private readonly ISRFApiClientFactory _apiClientFactory;
private readonly IRecordingService _recordingService;
/// <summary>
/// Initializes a new instance of the <see cref="SRFPlayChannel"/> class.
/// Initializes a new instance of the <see cref="SrgChannelBase"/> class.
/// </summary>
/// <param name="loggerFactory">The logger factory.</param>
/// <param name="contentRefreshService">The content refresh service.</param>
@@ -40,34 +45,47 @@ public class SRFPlayChannel : IChannel, IHasCacheKey
/// <param name="mediaSourceFactory">The media source factory.</param>
/// <param name="categoryService">The category service (optional).</param>
/// <param name="apiClientFactory">The API client factory.</param>
public SRFPlayChannel(
/// <param name="recordingService">The recording service.</param>
protected SrgChannelBase(
ILoggerFactory loggerFactory,
IContentRefreshService contentRefreshService,
IStreamUrlResolver streamResolver,
IMediaSourceFactory mediaSourceFactory,
ICategoryService? categoryService,
ISRFApiClientFactory apiClientFactory)
ISRFApiClientFactory apiClientFactory,
IRecordingService recordingService)
{
_logger = loggerFactory.CreateLogger<SRFPlayChannel>();
_logger = loggerFactory.CreateLogger<SrgChannelBase>();
_contentRefreshService = contentRefreshService;
_streamResolver = streamResolver;
_mediaSourceFactory = mediaSourceFactory;
_categoryService = categoryService;
_apiClientFactory = apiClientFactory;
_recordingService = recordingService;
if (_categoryService == null)
{
_logger.LogWarning("CategoryService not available - category folders will be disabled");
}
_logger.LogDebug("SRFPlayChannel initialized");
_logger.LogDebug("SrgChannelBase initialized");
}
/// <inheritdoc />
public string Name => "SRF Play";
/// <summary>
/// Gets the business unit this channel serves.
/// </summary>
protected abstract BusinessUnit Unit { get; }
/// <summary>
/// Gets the embedded resource name of this unit's logo PNG.
/// </summary>
protected abstract string LogoResourceName { get; }
/// <inheritdoc />
public string Description => "Swiss Radio and Television video-on-demand content";
public string Name => $"{Unit} Play";
/// <inheritdoc />
public abstract string Description { get; }
/// <inheritdoc />
public string DataVersion => "2.0"; // Back to authenticating at channel refresh with auto-refresh for fresh tokens
@@ -108,7 +126,8 @@ public class SRFPlayChannel : IChannel, IHasCacheKey
public Task<DynamicImageResponse> GetChannelImage(ImageType type, CancellationToken cancellationToken)
{
var assembly = GetType().Assembly;
var resourceStream = assembly.GetManifestResourceStream("Jellyfin.Plugin.SRFPlay.Images.logo.png");
var resourceStream = assembly.GetManifestResourceStream(LogoResourceName)
?? assembly.GetManifestResourceStream("Jellyfin.Plugin.SRFPlay.Images.logo.png");
if (resourceStream == null)
{
@@ -170,6 +189,7 @@ public class SRFPlayChannel : IChannel, IHasCacheKey
"latest" => await GetLatestVideosAsync(cancellationToken).ConfigureAwait(false),
"trending" => await GetTrendingVideosAsync(cancellationToken).ConfigureAwait(false),
"live_sports" => await GetLiveSportsAsync(cancellationToken).ConfigureAwait(false),
"recordings" => GetRecordingItems(),
_ when folderId.StartsWith("category_", StringComparison.Ordinal) => await GetCategoryVideosAsync(folderId, cancellationToken).ConfigureAwait(false),
_ => new List<ChannelItemInfo>()
};
@@ -181,7 +201,8 @@ public class SRFPlayChannel : IChannel, IHasCacheKey
{
CreateFolder("latest", "Latest 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
@@ -190,7 +211,7 @@ public class SRFPlayChannel : IChannel, IHasCacheKey
{
try
{
var businessUnit = config.BusinessUnit.ToLowerString();
var businessUnit = Unit.ToLowerString();
var topics = await _categoryService.GetTopicsAsync(businessUnit, cancellationToken).ConfigureAwait(false);
foreach (var topic in topics.Where(t => !string.IsNullOrEmpty(t.Id)))
@@ -231,24 +252,23 @@ public class SRFPlayChannel : IChannel, IHasCacheKey
private async Task<List<ChannelItemInfo>> GetLatestVideosAsync(CancellationToken cancellationToken)
{
var urns = await _contentRefreshService.RefreshLatestContentAsync(cancellationToken).ConfigureAwait(false);
var urns = await _contentRefreshService.RefreshLatestContentAsync(Unit.ToLowerString(), cancellationToken).ConfigureAwait(false);
return await ConvertUrnsToChannelItems(urns, cancellationToken).ConfigureAwait(false);
}
private async Task<List<ChannelItemInfo>> GetTrendingVideosAsync(CancellationToken cancellationToken)
{
var urns = await _contentRefreshService.RefreshTrendingContentAsync(cancellationToken).ConfigureAwait(false);
var urns = await _contentRefreshService.RefreshTrendingContentAsync(Unit.ToLowerString(), cancellationToken).ConfigureAwait(false);
return await ConvertUrnsToChannelItems(urns, cancellationToken).ConfigureAwait(false);
}
private async Task<List<ChannelItemInfo>> GetLiveSportsAsync(CancellationToken cancellationToken)
{
var items = new List<ChannelItemInfo>();
var config = Plugin.Instance?.Configuration;
try
{
var businessUnit = config?.BusinessUnit.ToLowerString() ?? "srf";
var businessUnit = Unit.ToLowerString();
using var apiClient = _apiClientFactory.CreateClient();
var scheduledLivestreams = await apiClient.GetScheduledLivestreamsAsync(businessUnit, "SPORT", cancellationToken).ConfigureAwait(false);
@@ -296,6 +316,68 @@ public class SRFPlayChannel : IChannel, IHasCacheKey
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)
{
var items = new List<ChannelItemInfo>();
@@ -308,9 +390,8 @@ public class SRFPlayChannel : IChannel, IHasCacheKey
try
{
var config = Plugin.Instance?.Configuration;
var topicId = folderId.Substring("category_".Length);
var businessUnit = config?.BusinessUnit.ToLowerString() ?? "srf";
var businessUnit = Unit.ToLowerString();
var shows = await _categoryService.GetShowsByTopicAsync(topicId, businessUnit, 20, cancellationToken).ConfigureAwait(false);
var urns = new List<string>();
@@ -411,7 +492,8 @@ public class SRFPlayChannel : IChannel, IHasCacheKey
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);
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)
@@ -553,8 +635,7 @@ public class SRFPlayChannel : IChannel, IHasCacheKey
ProviderIds = new Dictionary<string, string>
{
{ "SRF", urn }
},
MediaSources = new List<MediaSourceInfo> { mediaSource }
}
};
// Add series info if available
@@ -0,0 +1,43 @@
using Jellyfin.Plugin.SRFPlay.Api;
using Jellyfin.Plugin.SRFPlay.Configuration;
using Jellyfin.Plugin.SRFPlay.Services.Interfaces;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.SRFPlay.Channels;
/// <summary>
/// SWI swissinfo.ch — Swiss international channel.
/// </summary>
public sealed class SwiChannel : SrgChannelBase
{
/// <summary>
/// Initializes a new instance of the <see cref="SwiChannel"/> class.
/// </summary>
/// <param name="loggerFactory">The logger factory.</param>
/// <param name="contentRefreshService">The content refresh service.</param>
/// <param name="streamResolver">The stream resolver.</param>
/// <param name="mediaSourceFactory">The media source factory.</param>
/// <param name="categoryService">The category service.</param>
/// <param name="apiClientFactory">The API client factory.</param>
/// <param name="recordingService">The recording service.</param>
public SwiChannel(
ILoggerFactory loggerFactory,
IContentRefreshService contentRefreshService,
IStreamUrlResolver streamResolver,
IMediaSourceFactory mediaSourceFactory,
ICategoryService? categoryService,
ISRFApiClientFactory apiClientFactory,
IRecordingService recordingService)
: base(loggerFactory, contentRefreshService, streamResolver, mediaSourceFactory, categoryService, apiClientFactory, recordingService)
{
}
/// <inheritdoc />
protected override BusinessUnit Unit => BusinessUnit.SWI;
/// <inheritdoc />
protected override string LogoResourceName => "Jellyfin.Plugin.SRFPlay.Images.swi-logo.png";
/// <inheritdoc />
public override string Description => "SWI swissinfo.ch — Swiss international video-on-demand content";
}
@@ -78,7 +78,8 @@ public class PluginConfiguration : BasePluginConfiguration
}
/// <summary>
/// Gets or sets the business unit to fetch content from.
/// Gets or sets the legacy single business unit. Retained only for backwards compatibility
/// with older configs; every unit now has its own always-on channel.
/// </summary>
public BusinessUnit BusinessUnit { get; set; }
@@ -156,4 +157,9 @@ public class PluginConfiguration : BasePluginConfiguration
/// When enabled, generates custom thumbnails instead of using SRF-provided images.
/// </summary>
public bool GenerateTitleCards { get; set; }
/// <summary>
/// Gets or sets the output directory for sport livestream recordings.
/// </summary>
public string RecordingOutputPath { get; set; } = string.Empty;
}
@@ -1,24 +1,10 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>SRF Play</title>
</head>
<body>
<div id="SRFPlayConfigPage" data-role="page" class="page type-interior pluginConfigurationPage" data-require="emby-input,emby-button,emby-select,emby-checkbox">
<div id="SRFPlayConfigPage" data-role="page" class="page type-interior pluginConfigurationPage" data-require="emby-input,emby-button,emby-select,emby-checkbox">
<div data-role="content">
<div class="content-primary">
<form id="SRFPlayConfigForm">
<div class="selectContainer">
<label class="selectLabel" for="BusinessUnit">Business Unit</label>
<select is="emby-select" id="BusinessUnit" name="BusinessUnit" class="emby-select-withcolor emby-select">
<option id="optSRF" value="SRF">SRF (German)</option>
<option id="optRTS" value="RTS">RTS (French)</option>
<option id="optRSI" value="RSI">RSI (Italian)</option>
<option id="optRTR" value="RTR">RTR (Romansh)</option>
<option id="optSWI" value="SWI">SWI (International)</option>
</select>
<div class="fieldDescription">Select the Swiss broadcasting unit to fetch content from</div>
<div class="checkboxContainer checkboxContainer-withDescription">
<h3>Channels</h3>
<div class="fieldDescription">Every Swiss broadcaster has its own channel tile: SRF (German), RTS (French), RSI (Italian), RTR (Romansh) and SWI (swissinfo.ch). They are always available — pin the ones you use from the Jellyfin home screen.</div>
</div>
<div class="selectContainer">
<label class="selectLabel" for="QualityPreference">Quality Preference</label>
@@ -90,6 +76,13 @@
<div class="fieldDescription">Password for proxy authentication (leave empty if not required)</div>
</div>
<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>
<div class="inputContainer">
<label class="inputLabel inputLabelUnfocused" for="PublicServerUrl">Public Server URL (Optional)</label>
@@ -106,6 +99,31 @@
</button>
</div>
</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>
<script type="text/javascript">
@@ -117,7 +135,6 @@
.addEventListener('pageshow', function() {
Dashboard.showLoadingMsg();
ApiClient.getPluginConfiguration(SRFPlayConfig.pluginUniqueId).then(function (config) {
document.querySelector('#BusinessUnit').value = config.BusinessUnit;
document.querySelector('#QualityPreference').value = config.QualityPreference;
document.querySelector('#ContentRefreshIntervalHours').value = config.ContentRefreshIntervalHours;
document.querySelector('#ExpirationCheckIntervalHours').value = config.ExpirationCheckIntervalHours;
@@ -130,7 +147,12 @@
document.querySelector('#ProxyUsername').value = config.ProxyUsername || '';
document.querySelector('#ProxyPassword').value = config.ProxyPassword || '';
document.querySelector('#PublicServerUrl').value = config.PublicServerUrl || '';
document.querySelector('#RecordingOutputPath').value = config.RecordingOutputPath || '';
Dashboard.hideLoadingMsg();
// Load recordings UI
SRFPlayRecordings.loadSchedule();
SRFPlayRecordings.loadRecordings();
});
});
@@ -138,7 +160,6 @@
.addEventListener('submit', function(e) {
Dashboard.showLoadingMsg();
ApiClient.getPluginConfiguration(SRFPlayConfig.pluginUniqueId).then(function (config) {
config.BusinessUnit = document.querySelector('#BusinessUnit').value;
config.QualityPreference = document.querySelector('#QualityPreference').value;
config.ContentRefreshIntervalHours = parseInt(document.querySelector('#ContentRefreshIntervalHours').value);
config.ExpirationCheckIntervalHours = parseInt(document.querySelector('#ExpirationCheckIntervalHours').value);
@@ -151,6 +172,7 @@
config.ProxyUsername = document.querySelector('#ProxyUsername').value;
config.ProxyPassword = document.querySelector('#ProxyPassword').value;
config.PublicServerUrl = document.querySelector('#PublicServerUrl').value;
config.RecordingOutputPath = document.querySelector('#RecordingOutputPath').value;
ApiClient.updatePluginConfiguration(SRFPlayConfig.pluginUniqueId, config).then(function (result) {
Dashboard.processPluginConfigurationUpdateResult(result);
});
@@ -159,7 +181,204 @@
e.preventDefault();
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>
</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);
_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)
{
@@ -231,7 +232,7 @@ public class StreamProxyController : ControllerBase
AddManifestCacheHeaders(actualItemId, isVariantManifest: true);
_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)
{
@@ -292,9 +293,23 @@ public class StreamProxyController : ControllerBase
_logger.LogDebug("Streamed segment {SegmentPath} ({ContentType})", segmentPath, contentType);
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)
{
_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);
}
}
@@ -26,11 +26,18 @@
<ItemGroup>
<None Remove="Configuration\configPage.html" />
<EmbeddedResource Include="Configuration\configPage.html" />
<None Remove="Configuration\recordingPage.html" />
<EmbeddedResource Include="Configuration\recordingPage.html" />
</ItemGroup>
<ItemGroup>
<None Remove="..\assests\main logo.png" />
<EmbeddedResource Include="..\assests\main logo.png" LogicalName="Jellyfin.Plugin.SRFPlay.Images.logo.png" />
<EmbeddedResource Include="..\assests\units\srf-logo.png" LogicalName="Jellyfin.Plugin.SRFPlay.Images.srf-logo.png" />
<EmbeddedResource Include="..\assests\units\rts-logo.png" LogicalName="Jellyfin.Plugin.SRFPlay.Images.rts-logo.png" />
<EmbeddedResource Include="..\assests\units\rsi-logo.png" LogicalName="Jellyfin.Plugin.SRFPlay.Images.rsi-logo.png" />
<EmbeddedResource Include="..\assests\units\rtr-logo.png" LogicalName="Jellyfin.Plugin.SRFPlay.Images.rtr-logo.png" />
<EmbeddedResource Include="..\assests\units\swi-logo.png" LogicalName="Jellyfin.Plugin.SRFPlay.Images.swi-logo.png" />
</ItemGroup>
</Project>
+6
View File
@@ -46,6 +46,12 @@ public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
Name = Name,
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.Tasks;
using Jellyfin.Plugin.SRFPlay.Services.Interfaces;
using Jellyfin.Plugin.SRFPlay.Utilities;
using MediaBrowser.Model.Tasks;
using Microsoft.Extensions.Logging;
@@ -63,20 +64,25 @@ public class ContentRefreshTask : IScheduledTask
return;
}
// Refresh latest content
if (config.EnableLatestContent)
// Refresh content for every business unit (one channel per unit).
var units = System.Enum.GetValues<Configuration.BusinessUnit>();
foreach (var unit in units)
{
_logger.LogInformation("Refreshing latest content");
progress?.Report(25);
await _contentRefreshService.RefreshLatestContentAsync(cancellationToken).ConfigureAwait(false);
}
var businessUnit = unit.ToLowerString();
// Refresh trending content
if (config.EnableTrendingContent)
{
_logger.LogInformation("Refreshing trending content");
progress?.Report(75);
await _contentRefreshService.RefreshTrendingContentAsync(cancellationToken).ConfigureAwait(false);
if (config.EnableLatestContent)
{
_logger.LogInformation("Refreshing latest content for {BusinessUnit}", businessUnit);
progress?.Report(25);
await _contentRefreshService.RefreshLatestContentAsync(businessUnit, cancellationToken).ConfigureAwait(false);
}
if (config.EnableTrendingContent)
{
_logger.LogInformation("Refreshing trending content for {BusinessUnit}", businessUnit);
progress?.Report(75);
await _contentRefreshService.RefreshTrendingContentAsync(businessUnit, cancellationToken).ConfigureAwait(false);
}
}
progress?.Report(100);
@@ -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
serviceCollection.AddSingleton<SRFMediaProvider>();
// Register recording service
serviceCollection.AddSingleton<IRecordingService, RecordingService>();
// Register scheduled tasks
serviceCollection.AddSingleton<IScheduledTask, ContentRefreshTask>();
serviceCollection.AddSingleton<IScheduledTask, ExpirationCheckTask>();
serviceCollection.AddSingleton<IScheduledTask, RecordingSchedulerTask>();
// Register channel - must register as IChannel interface for Jellyfin to discover it
serviceCollection.AddSingleton<IChannel, SRFPlayChannel>();
// Register one channel (tile) per SRG business unit. Each must be registered as IChannel
// for Jellyfin to discover it. A unit that is not in EnabledBusinessUnits returns no
// content (its tile appears empty) - see SrgChannelBase.
serviceCollection.AddSingleton<IChannel, SrfChannel>();
serviceCollection.AddSingleton<IChannel, RtsChannel>();
serviceCollection.AddSingleton<IChannel, RsiChannel>();
serviceCollection.AddSingleton<IChannel, RtrChannel>();
serviceCollection.AddSingleton<IChannel, SwiChannel>();
}
}
@@ -18,8 +18,10 @@ public class CategoryService : ICategoryService
private readonly ILogger<CategoryService> _logger;
private readonly ISRFApiClientFactory _apiClientFactory;
private readonly TimeSpan _topicsCacheDuration = TimeSpan.FromHours(24);
private Dictionary<string, PlayV3Topic>? _topicsCache;
private DateTime _topicsCacheExpiry = DateTime.MinValue;
// Cache topics per business unit. A single shared cache would return one unit's
// topics (e.g. German SRF) even after the user switched to another (e.g. French RTS).
private readonly Dictionary<string, (Dictionary<string, PlayV3Topic> Topics, DateTime Expiry)> _topicsCacheByUnit = new(StringComparer.OrdinalIgnoreCase);
/// <summary>
/// Initializes a new instance of the <see cref="CategoryService"/> class.
@@ -35,11 +37,11 @@ public class CategoryService : ICategoryService
/// <inheritdoc />
public async Task<List<PlayV3Topic>> GetTopicsAsync(string businessUnit, CancellationToken cancellationToken = default)
{
// Return cached topics if still valid
if (_topicsCache != null && DateTime.UtcNow < _topicsCacheExpiry)
// Return cached topics for this business unit if still valid
if (_topicsCacheByUnit.TryGetValue(businessUnit, out var cached) && DateTime.UtcNow < cached.Expiry)
{
_logger.LogDebug("Returning cached topics for business unit: {BusinessUnit}", businessUnit);
return _topicsCache.Values.ToList();
return cached.Topics.Values.ToList();
}
_logger.LogInformation("Fetching topics for business unit: {BusinessUnit}", businessUnit);
@@ -48,13 +50,13 @@ public class CategoryService : ICategoryService
if (topics != null && topics.Count > 0)
{
// Cache topics by ID for quick lookups
_topicsCache = topics
// Cache topics by ID for quick lookups, scoped to this business unit
var byId = topics
.Where(t => !string.IsNullOrEmpty(t.Id))
.ToDictionary(t => t.Id!, t => t);
_topicsCacheExpiry = DateTime.UtcNow.Add(_topicsCacheDuration);
_topicsCacheByUnit[businessUnit] = (byId, DateTime.UtcNow.Add(_topicsCacheDuration));
_logger.LogInformation("Cached {Count} topics for business unit: {BusinessUnit}", _topicsCache.Count, businessUnit);
_logger.LogInformation("Cached {Count} topics for business unit: {BusinessUnit}", byId.Count, businessUnit);
}
return topics ?? new List<PlayV3Topic>();
@@ -63,13 +65,14 @@ public class CategoryService : ICategoryService
/// <inheritdoc />
public async Task<PlayV3Topic?> GetTopicByIdAsync(string topicId, string businessUnit, CancellationToken cancellationToken = default)
{
// Ensure topics are loaded
if (_topicsCache == null || DateTime.UtcNow >= _topicsCacheExpiry)
// Ensure topics are loaded for this business unit
if (!_topicsCacheByUnit.TryGetValue(businessUnit, out var cached) || DateTime.UtcNow >= cached.Expiry)
{
await GetTopicsAsync(businessUnit, cancellationToken).ConfigureAwait(false);
_topicsCacheByUnit.TryGetValue(businessUnit, out cached);
}
return _topicsCache?.GetValueOrDefault(topicId);
return cached.Topics?.GetValueOrDefault(topicId);
}
/// <inheritdoc />
@@ -101,8 +104,7 @@ public class CategoryService : ICategoryService
/// <inheritdoc />
public void ClearCache()
{
_topicsCache = null;
_topicsCacheExpiry = DateTime.MinValue;
_topicsCacheByUnit.Clear();
_logger.LogInformation("Topics cache cleared");
}
@@ -120,9 +120,10 @@ public class ContentExpirationService : IContentExpirationService
if (mediaComposition?.HasChapters != true)
{
// If we can't fetch the content, consider it expired
_logger.LogWarning("Could not fetch media composition for URN: {Urn}, treating as expired", urn);
return true;
// Don't treat API failures as expired - the content may still be available
// and a transient error (network issue, 403, API outage) shouldn't delete library items
_logger.LogWarning("Could not fetch media composition for URN: {Urn}, skipping (not treating as expired)", urn);
return false;
}
var chapter = mediaComposition.ChapterList[0];
@@ -34,9 +34,10 @@ public class ContentRefreshService : IContentRefreshService
/// <summary>
/// Refreshes latest content from SRF API using Play v3.
/// </summary>
/// <param name="businessUnit">The business unit to fetch content for (e.g. "srf", "rts").</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>List of URNs for new content.</returns>
public async Task<List<string>> RefreshLatestContentAsync(CancellationToken cancellationToken)
public async Task<List<string>> RefreshLatestContentAsync(string businessUnit, CancellationToken cancellationToken)
{
var config = Plugin.Instance?.Configuration;
if (config == null || !config.EnableLatestContent)
@@ -46,7 +47,7 @@ public class ContentRefreshService : IContentRefreshService
}
return await FetchVideosFromShowsAsync(
config.BusinessUnit.ToLowerString(),
businessUnit,
minEpisodeCount: 0,
maxShows: 20,
videosPerShow: 1,
@@ -58,9 +59,10 @@ public class ContentRefreshService : IContentRefreshService
/// Refreshes trending content from SRF API using Play v3.
/// Gets videos from shows with the most episodes.
/// </summary>
/// <param name="businessUnit">The business unit to fetch content for (e.g. "srf", "rts").</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>List of URNs for trending content.</returns>
public async Task<List<string>> RefreshTrendingContentAsync(CancellationToken cancellationToken)
public async Task<List<string>> RefreshTrendingContentAsync(string businessUnit, CancellationToken cancellationToken)
{
var config = Plugin.Instance?.Configuration;
if (config == null || !config.EnableTrendingContent)
@@ -70,7 +72,7 @@ public class ContentRefreshService : IContentRefreshService
}
return await FetchVideosFromShowsAsync(
config.BusinessUnit.ToLowerString(),
businessUnit,
minEpisodeCount: 10,
maxShows: 15,
videosPerShow: 2,
@@ -12,14 +12,16 @@ public interface IContentRefreshService
/// <summary>
/// Refreshes latest content from SRF API using Play v3.
/// </summary>
/// <param name="businessUnit">The business unit to fetch content for (e.g. "srf", "rts").</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>List of URNs for new content.</returns>
Task<List<string>> RefreshLatestContentAsync(CancellationToken cancellationToken);
Task<List<string>> RefreshLatestContentAsync(string businessUnit, CancellationToken cancellationToken);
/// <summary>
/// Refreshes trending content from SRF API using Play v3.
/// </summary>
/// <param name="businessUnit">The business unit to fetch content for (e.g. "srf", "rts").</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>List of URNs for trending content.</returns>
Task<List<string>> RefreshTrendingContentAsync(CancellationToken cancellationToken);
Task<List<string>> RefreshTrendingContentAsync(string businessUnit, CancellationToken cancellationToken);
}
@@ -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);
}
// Detect if this is a live stream
var isLiveStream = chapter.Type == "SCHEDULED_LIVESTREAM" ||
urn.Contains("livestream", StringComparison.OrdinalIgnoreCase);
// A scheduled livestream is only treated as live when the broadcast window is open.
// Past replays (ValidTo in the past) and upcoming events (ValidFrom in the future)
// are treated as VOD to avoid IsInfiniteStream/IgnoreDts flags and FFmpeg -re mode.
var isScheduledLivestream = chapter.Type == "SCHEDULED_LIVESTREAM" ||
urn.Contains("livestream", StringComparison.OrdinalIgnoreCase);
var now = DateTime.UtcNow;
var isLiveStream = isScheduledLivestream &&
(chapter.ValidFrom == null || chapter.ValidFrom.Value.ToUniversalTime() <= now) &&
(chapter.ValidTo == null || chapter.ValidTo.Value.ToUniversalTime() > now);
// Register stream with UNAUTHENTICATED URL - proxy will authenticate on-demand
// This avoids wasting 30-second tokens during category browsing
@@ -91,15 +97,15 @@ public class MediaSourceFactory : IMediaSourceFactory
Protocol = MediaProtocol.Http,
// Use "hls" to trigger hls.js player in web client
Container = "hls",
SupportsDirectStream = true,
SupportsDirectStream = false,
SupportsDirectPlay = true,
SupportsTranscoding = false,
SupportsTranscoding = true,
IsRemote = true,
Type = MediaSourceType.Default,
RunTimeTicks = chapter.Duration > 0 ? TimeSpan.FromMilliseconds(chapter.Duration).Ticks : null,
VideoType = VideoType.VideoFile,
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,
RequiresClosing = false,
// Disable probing - we provide stream info directly
@@ -0,0 +1,580 @@
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 units = System.Enum.GetValues<Configuration.BusinessUnit>();
using var apiClient = _apiClientFactory.CreateClient();
// Aggregate sport livestreams across every business unit so the recordings
// page shows events from all languages.
var all = new List<PlayV3TvProgram>();
foreach (var unit in units)
{
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;
}
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);
_streamMappings.TryRemove(itemId, out _);
return null;
@@ -647,21 +656,6 @@ public class StreamProxyService : IStreamProxyService
// Rewrite the manifest to replace Akamai URLs with proxy URLs
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);
return rewrittenContent;
}
@@ -979,8 +973,9 @@ public class StreamProxyService : IStreamProxyService
_logger.LogDebug("Marking item {ItemId} for cleanup (old registration)", kvp.Key);
}
// Remove if token has expired
if (kvp.Value.TokenExpiresAt.HasValue && kvp.Value.TokenExpiresAt.Value <= now)
// Remove if token has expired — but skip livestreams since their CDN tokens
// 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;
_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>
+6 -1
View File
@@ -270,7 +270,12 @@ The plugin includes:
### Contributing
Contributions welcome!
Contributions welcome! This project is hosted on a self-hosted [Gitea](https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay) instance.
**You don't need a separate account** — you can sign in with your existing GitHub account. On the [sign-in page](https://gitea.tourolle.paris/user/login), choose **"Sign in with GitHub"** to register and log in via GitHub OAuth. Once signed in, you can:
- **Raise issues** — report bugs or request features on the [issue tracker](https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/issues)
- **Contribute code** — fork the repository, push a branch, and open a pull request
## License
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

+1 -1
View File
@@ -1,6 +1,6 @@
---
name: "SRFPlay"
guid: "eb5d7894-8eef-4b36-aa6f-5d124e828ce1"
guid: "a4b12f86-8c3d-4e9a-b7f2-1d5e6c8a9b4f"
version: "1.0.0.0"
targetAbi: "10.9.0.0"
framework: "net8.0"
+21
View File
@@ -0,0 +1,21 @@
[
{
"guid": "a4b12f86-8c3d-4e9a-b7f2-1d5e6c8a9b4f",
"name": "SRF Play (Nightly)",
"description": "NIGHTLY/UNSTABLE builds of the SRF Play plugin for Jellyfin. Built automatically from the master branch on every push. Expect bugs. For stable releases use the regular SRF Play repository instead.",
"overview": "Nightly builds of SRF Play for Jellyfin",
"owner": "dtourolle",
"category": "Live TV",
"imageUrl": "https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/raw/branch/master/assests/main%20logo.png",
"versions": [
{
"version": "1.0.20260627.185",
"changelog": "Nightly build 1.0.20260627.185 (1101385)",
"targetAbi": "10.9.0.0",
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/releases/download/latest/srfplay_1.0.20260627.185.zip",
"checksum": "577af725ca202bec555bf51905b75913",
"timestamp": "2026-06-27T09:30:57Z"
}
]
}
]
+80
View File
@@ -8,6 +8,86 @@
"category": "Live TV",
"imageUrl": "https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/raw/branch/master/assests/main%20logo.png",
"versions": [
{
"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": "2ed3281990b56fbfb5f75a1ee82a2f55",
"timestamp": "2026-06-27T09:01:57Z"
},
{
"version": "1.0.32",
"changelog": "Release 1.0.32",
"targetAbi": "10.9.0.0",
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/releases/download/v1.0.32/srfplay_1.0.32.0.zip",
"checksum": "82f53848f2e2b15a35ea731ee69ee402",
"timestamp": "2026-06-27T07:16:38Z"
},
{
"version": "1.0.31",
"changelog": "Release 1.0.31",
"targetAbi": "10.9.0.0",
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/releases/download/v1.0.31/srfplay_1.0.31.0.zip",
"checksum": "4e499de15687e6e328ddca41c6192485",
"timestamp": "2026-06-27T06:54:01Z"
},
{
"version": "1.0.30",
"changelog": "Release 1.0.30",
"targetAbi": "10.9.0.0",
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/releases/download/v1.0.30/srfplay_1.0.30.0.zip",
"checksum": "06731df9ba3d2dab53885c9c8ac95fa6",
"timestamp": "2026-05-03T16:44:57Z"
},
{
"version": "1.0.29",
"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": "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": "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",
"changelog": "Release 1.0.16",