Compare commits

...
20 Commits
Author SHA1 Message Date
dtourolle 33209c3b33 Fix bug in recoridng page
🏗️ Build Plugin / build (push) Successful in 43s
🧪 Test Plugin / test (push) Successful in 36s
🚀 Release Plugin / build-and-release (push) Successful in 52s
2026-03-07 16:09:37 +01:00
Gitea Actions 2177ef9814 Update manifest.json for version 1.0.24 2026-03-07 14:55:13 +00:00
dtourolle 87bdec280b Add recording of livestreams
🏗️ Build Plugin / build (push) Successful in 46s
🧪 Test Plugin / test (push) Successful in 37s
🚀 Release Plugin / build-and-release (push) Successful in 53s
2026-03-07 15:52:51 +01:00
Gitea Actions 5f6e928409 Update manifest.json for version 1.0.23 2026-02-28 12:13:10 +00:00
dtourolle c1b5dea569 Fixes for Livestreams
🏗️ Build Plugin / build (push) Successful in 41s
🧪 Test Plugin / test (push) Successful in 34s
🚀 Release Plugin / build-and-release (push) Successful in 51s
2026-02-28 13:11:02 +01:00
Gitea Actions 2879ebf4df Update manifest.json for version 1.0.22 2026-02-28 11:36:30 +00:00
dtourolle b0cdcda900 CI fix
🏗️ Build Plugin / build (push) Successful in 44s
🧪 Test Plugin / test (push) Successful in 35s
🚀 Release Plugin / build-and-release (push) Successful in 52s
2026-02-28 12:34:15 +01:00
dtourolle 5fadf5a9a7 CI fix
🏗️ Build Plugin / build (push) Successful in 43s
🧪 Test Plugin / test (push) Successful in 35s
🚀 Release Plugin / build-and-release (push) Failing after 54s
2026-02-28 12:29:27 +01:00
dtourolle a6ae4994e9 CI fix
🏗️ Build Plugin / build (push) Successful in 44s
🧪 Test Plugin / test (push) Successful in 36s
🚀 Release Plugin / build-and-release (push) Failing after 42s
2026-02-28 12:21:35 +01:00
dtourolle 12af9bddb1 CI fix
🏗️ Build Plugin / build (push) Failing after 46s
🧪 Test Plugin / test (push) Successful in 36s
2026-02-28 12:19:14 +01:00
dtourolle 92a840cf42 CI fix
🏗️ Build Plugin / build (push) Failing after 27s
🧪 Test Plugin / test (push) Failing after 41s
2026-02-28 12:17:48 +01:00
dtourolle 9210532cf1 CI fix
🏗️ Build Plugin / build (push) Failing after 42s
🧪 Test Plugin / test (push) Successful in 52s
2026-02-28 12:14:41 +01:00
dtourolle 66d5faead2 build system cleans before build
🏗️ Build Plugin / build (push) Failing after 37s
🧪 Test Plugin / test (push) Successful in 48s
2026-02-28 12:12:37 +01:00
dtourolle 65c7a16cee Add missing deps
🏗️ Build Plugin / build (push) Failing after 1s
🧪 Test Plugin / test (push) Failing after 2s
2026-02-28 12:09:22 +01:00
dtourolle 46684402e6 Use docker on CI
🚀 Release Plugin / build-and-release (push) Failing after 2s
🧪 Test Plugin / test (push) Failing after 50s
🏗️ Build Plugin / build (push) Failing after 50s
2026-02-28 12:05:41 +01:00
dtourolle d75e69249b Live-stream starts 6 seconds from edge not at oldest segment.
🧪 Test Plugin / test (push) Failing after 5s
🚀 Release Plugin / build-and-release (push) Failing after 4s
🏗️ Build Plugin / build (push) Successful in 2m55s
2026-02-28 11:55:05 +01:00
dtourolle 7c76402a4a Clean up dead code, consolidate duplication, fix redundancies
🏗️ Build Plugin / build (push) Failing after 9s
🧪 Test Plugin / test (push) Successful in 1m28s
🚀 Release Plugin / build-and-release (push) Failing after 5s
Remove 9 dead methods, 6 unused constants, and redundant
ReaderWriterLockSlim from MetadataCache. Consolidate repeated
patterns into HasChapters, IsPlayable, and ToLowerString helpers.
Extract shared API methods in SRFApiClient. Move variant manifest
rewriting from controller to StreamProxyService. Make Auto quality
distinct from HD. Update README architecture section.
2026-02-28 11:34:45 +01:00
Gitea Actions 873e531599 Update manifest.json for version 1.0.16 2026-01-17 20:31:09 +00:00
dtourolle 5c898a98f5 Use place holders for channel titles
🏗️ Build Plugin / build (push) Successful in 2m56s
🧪 Test Plugin / test (push) Successful in 1m25s
🚀 Release Plugin / build-and-release (push) Successful in 2m51s
2026-01-17 21:23:50 +01:00
Gitea Actions c282a98377 Update manifest.json for version 1.0.15 2026-01-17 09:54:39 +00:00
37 changed files with 1694 additions and 696 deletions
+21 -20
View File
@@ -15,47 +15,48 @@ on:
jobs: jobs:
build: build:
runs-on: ubuntu-latest runs-on: linux/amd64
container:
image: gitea.tourolle.paris/dtourolle/srfplay-builder:latest
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@v4
with:
- name: Verify .NET installation path: build-${{ github.run_id }}
run: dotnet --version
- name: Restore dependencies - name: Restore dependencies
working-directory: build-${{ github.run_id }}
run: dotnet restore Jellyfin.Plugin.SRFPlay.sln run: dotnet restore Jellyfin.Plugin.SRFPlay.sln
- name: Build solution - name: Build solution
run: dotnet build Jellyfin.Plugin.SRFPlay.sln --configuration Release --no-restore --no-self-contained 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 - name: Run tests
working-directory: build-${{ github.run_id }}
run: dotnet test Jellyfin.Plugin.SRFPlay.sln --no-build --configuration Release --verbosity normal run: dotnet test Jellyfin.Plugin.SRFPlay.sln --no-build --configuration Release --verbosity normal
- name: Install JPRM
run: |
python3 -m venv /tmp/jprm-venv
/tmp/jprm-venv/bin/pip install jprm
- name: Build Jellyfin Plugin - name: Build Jellyfin Plugin
id: jprm id: jprm
working-directory: build-${{ github.run_id }}
run: | run: |
# Create artifacts directory for JPRM output
mkdir -p artifacts mkdir -p artifacts
jprm --verbosity=debug plugin build .
# Build plugin using JPRM ARTIFACT=$(find . -name "*.zip" -type f -print -quit | sed 's|^\./||')
/tmp/jprm-venv/bin/jprm --verbosity=debug plugin build . LATEST="artifacts/srfplay_latest.zip"
cp "${ARTIFACT}" "${LATEST}"
# Find the generated zip file echo "artifact=${LATEST}" >> $GITHUB_OUTPUT
ARTIFACT=$(find . -name "*.zip" -type f -print -quit) echo "Found artifact: ${ARTIFACT} -> ${LATEST}"
echo "artifact=${ARTIFACT}" >> $GITHUB_OUTPUT
echo "Found artifact: ${ARTIFACT}"
- name: Upload build artifact - name: Upload build artifact
uses: actions/upload-artifact@v3 uses: actions/upload-artifact@v3
with: with:
name: jellyfin-srfplay-plugin name: jellyfin-srfplay-plugin
path: ${{ steps.jprm.outputs.artifact }} path: build-${{ github.run_id }}/${{ steps.jprm.outputs.artifact }}
retention-days: 30 retention-days: 30
if-no-files-found: error if-no-files-found: error
- name: Cleanup
if: always()
run: rm -rf build-${{ github.run_id }}
+22 -28
View File
@@ -13,14 +13,15 @@ on:
jobs: jobs:
build-and-release: build-and-release:
runs-on: ubuntu-latest runs-on: linux/amd64
container:
image: gitea.tourolle.paris/dtourolle/srfplay-builder:latest
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@v4
with:
- name: Verify .NET installation path: release-${{ github.run_id }}
run: dotnet --version
- name: Get version - name: Get version
id: get_version id: get_version
@@ -35,36 +36,31 @@ jobs:
echo "Building version: ${VERSION}" echo "Building version: ${VERSION}"
- name: Update build.yaml with version - name: Update build.yaml with version
working-directory: release-${{ github.run_id }}
run: | run: |
VERSION="${{ steps.get_version.outputs.version_number }}" VERSION="${{ steps.get_version.outputs.version_number }}"
sed -i "s/^version:.*/version: \"${VERSION}\"/" build.yaml sed -i "s/^version:.*/version: \"${VERSION}\"/" build.yaml
cat build.yaml cat build.yaml
- name: Restore dependencies - name: Restore dependencies
working-directory: release-${{ github.run_id }}
run: dotnet restore Jellyfin.Plugin.SRFPlay.sln run: dotnet restore Jellyfin.Plugin.SRFPlay.sln
- name: Build solution - name: Build solution
run: dotnet build Jellyfin.Plugin.SRFPlay.sln --configuration Release --no-restore --no-self-contained working-directory: release-${{ github.run_id }}
run: dotnet build Jellyfin.Plugin.SRFPlay.sln --configuration Release --no-restore --no-self-contained /m:1
- name: Run tests - name: Run tests
working-directory: release-${{ github.run_id }}
run: dotnet test Jellyfin.Plugin.SRFPlay.sln --no-build --configuration Release --verbosity normal run: dotnet test Jellyfin.Plugin.SRFPlay.sln --no-build --configuration Release --verbosity normal
- name: Install JPRM
run: |
python3 -m venv /tmp/jprm-venv
/tmp/jprm-venv/bin/pip install jprm
- name: Build Jellyfin Plugin - name: Build Jellyfin Plugin
id: jprm id: jprm
working-directory: release-${{ github.run_id }}
run: | run: |
# Create artifacts directory for JPRM output
mkdir -p artifacts mkdir -p artifacts
jprm --verbosity=debug plugin build ./
# Build plugin using JPRM ARTIFACT=$(find . -name "*.zip" -type f -print -quit | sed 's|^\./||')
/tmp/jprm-venv/bin/jprm --verbosity=debug plugin build ./
# Find the generated zip file
ARTIFACT=$(find . -name "*.zip" -type f -print -quit)
ARTIFACT_NAME=$(basename "${ARTIFACT}") ARTIFACT_NAME=$(basename "${ARTIFACT}")
echo "artifact=${ARTIFACT}" >> $GITHUB_OUTPUT echo "artifact=${ARTIFACT}" >> $GITHUB_OUTPUT
echo "artifact_name=${ARTIFACT_NAME}" >> $GITHUB_OUTPUT echo "artifact_name=${ARTIFACT_NAME}" >> $GITHUB_OUTPUT
@@ -72,12 +68,14 @@ jobs:
- name: Calculate checksum - name: Calculate checksum
id: checksum id: checksum
working-directory: release-${{ github.run_id }}
run: | run: |
CHECKSUM=$(md5sum "${{ steps.jprm.outputs.artifact }}" | awk '{print $1}') CHECKSUM=$(md5sum "${{ steps.jprm.outputs.artifact }}" | awk '{print $1}')
echo "checksum=${CHECKSUM}" >> $GITHUB_OUTPUT echo "checksum=${CHECKSUM}" >> $GITHUB_OUTPUT
echo "Checksum: ${CHECKSUM}" echo "Checksum: ${CHECKSUM}"
- name: Create Release - name: Create Release
working-directory: release-${{ github.run_id }}
env: env:
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: | run: |
@@ -86,22 +84,13 @@ jobs:
REPO_NAME="${{ github.event.repository.name }}" REPO_NAME="${{ github.event.repository.name }}"
GITEA_URL="${{ github.server_url }}" GITEA_URL="${{ github.server_url }}"
# Prepare release body
RELEASE_BODY="SRFPlay Jellyfin Plugin ${{ steps.get_version.outputs.version }}\n\nSee attached files for plugin installation."
RELEASE_BODY_JSON=$(echo -n "${RELEASE_BODY}" | jq -Rs .)
# Create release using Gitea API # Create release using Gitea API
VERSION="${{ steps.get_version.outputs.version }}"
RESPONSE=$(curl -s -w "\n%{http_code}" -X POST \ RESPONSE=$(curl -s -w "\n%{http_code}" -X POST \
-H "Authorization: token ${GITEA_TOKEN}" \ -H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
"${GITEA_URL}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}/releases" \ "${GITEA_URL}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}/releases" \
-d "{ -d "$(jq -n --arg tag "$VERSION" --arg name "Release $VERSION" --arg body "SRFPlay Jellyfin Plugin. See attached files for plugin installation." '{tag_name: $tag, name: $name, body: $body, draft: false, prerelease: false}')")
\"tag_name\": \"${{ steps.get_version.outputs.version }}\",
\"name\": \"Release ${{ steps.get_version.outputs.version }}\",
\"body\": ${RELEASE_BODY_JSON},
\"draft\": false,
\"prerelease\": false
}")
HTTP_CODE=$(echo "$RESPONSE" | tail -n1) HTTP_CODE=$(echo "$RESPONSE" | tail -n1)
BODY=$(echo "$RESPONSE" | sed '$d') BODY=$(echo "$RESPONSE" | sed '$d')
@@ -135,6 +124,7 @@ jobs:
echo "View at: ${GITEA_URL}/${REPO_OWNER}/${REPO_NAME}/releases/tag/${{ steps.get_version.outputs.version }}" echo "View at: ${GITEA_URL}/${REPO_OWNER}/${REPO_NAME}/releases/tag/${{ steps.get_version.outputs.version }}"
- name: Update manifest.json - name: Update manifest.json
working-directory: release-${{ github.run_id }}
env: env:
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: | run: |
@@ -168,3 +158,7 @@ jobs:
git add manifest.json git add manifest.json
git commit -m "Update manifest.json for version ${VERSION}" git commit -m "Update manifest.json for version ${VERSION}"
git push origin master git push origin master
- name: Cleanup
if: always()
run: rm -rf release-${{ github.run_id }}
+14 -6
View File
@@ -17,22 +17,26 @@ on:
jobs: jobs:
test: test:
runs-on: ubuntu-latest runs-on: linux/amd64
container:
image: gitea.tourolle.paris/dtourolle/srfplay-builder:latest
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@v4
with:
- name: Verify .NET installation path: test-${{ github.run_id }}
run: dotnet --version
- name: Restore dependencies - name: Restore dependencies
working-directory: test-${{ github.run_id }}
run: dotnet restore Jellyfin.Plugin.SRFPlay.sln run: dotnet restore Jellyfin.Plugin.SRFPlay.sln
- name: Build solution - name: Build solution
run: dotnet build Jellyfin.Plugin.SRFPlay.sln --configuration Debug --no-restore --no-self-contained working-directory: test-${{ github.run_id }}
run: dotnet build Jellyfin.Plugin.SRFPlay.sln --configuration Debug --no-restore --no-self-contained /m:1
- name: Run tests - name: Run tests
working-directory: test-${{ github.run_id }}
run: dotnet test Jellyfin.Plugin.SRFPlay.sln --no-build --configuration Debug --verbosity normal --logger "trx;LogFileName=test-results.trx" run: dotnet test Jellyfin.Plugin.SRFPlay.sln --no-build --configuration Debug --verbosity normal --logger "trx;LogFileName=test-results.trx"
- name: Upload test results - name: Upload test results
@@ -40,5 +44,9 @@ jobs:
uses: actions/upload-artifact@v3 uses: actions/upload-artifact@v3
with: with:
name: test-results name: test-results
path: '**/test-results.trx' path: test-${{ github.run_id }}/**/test-results.trx
retention-days: 7 retention-days: 7
- name: Cleanup
if: always()
run: rm -rf test-${{ github.run_id }}
+19
View File
@@ -0,0 +1,19 @@
# SRFPlay Builder Image
# Pre-built image with .NET SDK and JPRM for building Jellyfin plugins
# Build: docker build -f Dockerfile.builder -t gitea.tourolle.paris/dtourolle/srfplay-builder:latest .
# Push: docker push gitea.tourolle.paris/dtourolle/srfplay-builder:latest
FROM mcr.microsoft.com/dotnet/sdk:8.0
RUN apt-get update && apt-get install -y \
python3 \
python3-pip \
git \
jq \
nodejs \
npm \
&& rm -rf /var/lib/apt/lists/*
RUN pip install --break-system-packages jprm
WORKDIR /src
@@ -115,7 +115,7 @@ namespace Jellyfin.Plugin.SRFPlay.Tests
if (chapter.ResourceList != null && chapter.ResourceList.Any()) if (chapter.ResourceList != null && chapter.ResourceList.Any())
{ {
var hlsResource = chapter.ResourceList.FirstOrDefault(r => var hlsResource = chapter.ResourceList.FirstOrDefault(r =>
r.Protocol == "HLS" && (r.DrmList == null || r.DrmList.ToString() == "[]")); r.Protocol == "HLS" && r.IsPlayable);
if (hlsResource != null) if (hlsResource != null)
{ {
@@ -74,7 +74,9 @@ public class Chapter
/// Gets or sets the list of available resources (streams). /// Gets or sets the list of available resources (streams).
/// </summary> /// </summary>
[JsonPropertyName("resourceList")] [JsonPropertyName("resourceList")]
public IReadOnlyList<Resource> ResourceList { get; set; } = new List<Resource>(); [System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "CA1002:Do not expose generic lists", Justification = "Required for JSON deserialization")]
[System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "CA2227:Collection properties should be read only", Justification = "Required for JSON deserialization")]
public List<Resource> ResourceList { get; set; } = new List<Resource>();
/// <summary> /// <summary>
/// Gets or sets the episode number. /// Gets or sets the episode number.
@@ -1,5 +1,4 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
namespace Jellyfin.Plugin.SRFPlay.Api.Models; namespace Jellyfin.Plugin.SRFPlay.Api.Models;
@@ -15,6 +14,12 @@ public class MediaComposition
[JsonPropertyName("chapterList")] [JsonPropertyName("chapterList")]
public IReadOnlyList<Chapter> ChapterList { get; set; } = new List<Chapter>(); public IReadOnlyList<Chapter> ChapterList { get; set; } = new List<Chapter>();
/// <summary>
/// Gets a value indicating whether this composition has any chapters.
/// </summary>
[JsonIgnore]
public bool HasChapters => ChapterList != null && ChapterList.Count > 0;
/// <summary> /// <summary>
/// Gets or sets the episode information. /// Gets or sets the episode information.
/// </summary> /// </summary>
@@ -0,0 +1,94 @@
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 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
}
@@ -2,6 +2,9 @@ using System.Text.Json.Serialization;
namespace Jellyfin.Plugin.SRFPlay.Api.Models; namespace Jellyfin.Plugin.SRFPlay.Api.Models;
// NOTE: DrmList is typed as object? because the SRF API returns either null or a JSON array.
// IsPlayable checks for both null and empty array ("[]") to determine if content is DRM-free.
/// <summary> /// <summary>
/// Represents a streaming resource (URL) in the SRF API response. /// Represents a streaming resource (URL) in the SRF API response.
/// </summary> /// </summary>
@@ -48,4 +51,10 @@ public class Resource
/// </summary> /// </summary>
[JsonPropertyName("drmList")] [JsonPropertyName("drmList")]
public object? DrmList { get; set; } public object? DrmList { get; set; }
/// <summary>
/// Gets a value indicating whether this resource is playable (not DRM-protected).
/// </summary>
[JsonIgnore]
public bool IsPlayable => DrmList == null || DrmList.ToString() == "[]";
} }
+24 -100
View File
@@ -228,7 +228,7 @@ public class SRFApiClient : IDisposable
var result = JsonSerializer.Deserialize<MediaComposition>(output, _jsonOptions); var result = JsonSerializer.Deserialize<MediaComposition>(output, _jsonOptions);
if (result?.ChapterList != null && result.ChapterList.Count > 0) if (result?.HasChapters == true)
{ {
_logger.LogInformation("Successfully fetched media composition via curl - Chapters: {ChapterCount}", result.ChapterList.Count); _logger.LogInformation("Successfully fetched media composition via curl - Chapters: {ChapterCount}", result.ChapterList.Count);
} }
@@ -248,38 +248,8 @@ public class SRFApiClient : IDisposable
/// <param name="businessUnit">The business unit (e.g., srf, rts).</param> /// <param name="businessUnit">The business unit (e.g., srf, rts).</param>
/// <param name="cancellationToken">The cancellation token.</param> /// <param name="cancellationToken">The cancellation token.</param>
/// <returns>The media composition containing latest videos.</returns> /// <returns>The media composition containing latest videos.</returns>
public async Task<MediaComposition?> GetLatestVideosAsync(string businessUnit, CancellationToken cancellationToken = default) public Task<MediaComposition?> GetLatestVideosAsync(string businessUnit, CancellationToken cancellationToken = default)
{ => GetMediaCompositionListAsync(businessUnit, "latest", cancellationToken);
try
{
var url = $"/video/{businessUnit}/latest.json";
_logger.LogInformation("Fetching latest videos for business unit: {BusinessUnit} from URL: {Url}", businessUnit, url);
var response = await _httpClient.GetAsync(url, cancellationToken).ConfigureAwait(false);
_logger.LogInformation("Latest videos API response: {StatusCode}", response.StatusCode);
if (!response.IsSuccessStatusCode)
{
var errorContent = await ReadAsUtf8StringAsync(response.Content, cancellationToken).ConfigureAwait(false);
_logger.LogError("API returned error {StatusCode}: {Error}", response.StatusCode, errorContent);
return null;
}
var content = await ReadAsUtf8StringAsync(response.Content, cancellationToken).ConfigureAwait(false);
_logger.LogDebug("Latest videos response length: {Length}", content.Length);
var result = JsonSerializer.Deserialize<MediaComposition>(content, _jsonOptions);
_logger.LogInformation("Successfully fetched latest videos for business unit: {BusinessUnit}", businessUnit);
return result;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error fetching latest videos for business unit: {BusinessUnit}", businessUnit);
return null;
}
}
/// <summary> /// <summary>
/// Gets the trending videos for a business unit. /// Gets the trending videos for a business unit.
@@ -287,16 +257,19 @@ public class SRFApiClient : IDisposable
/// <param name="businessUnit">The business unit (e.g., srf, rts).</param> /// <param name="businessUnit">The business unit (e.g., srf, rts).</param>
/// <param name="cancellationToken">The cancellation token.</param> /// <param name="cancellationToken">The cancellation token.</param>
/// <returns>The media composition containing trending videos.</returns> /// <returns>The media composition containing trending videos.</returns>
public async Task<MediaComposition?> GetTrendingVideosAsync(string businessUnit, CancellationToken cancellationToken = default) public Task<MediaComposition?> GetTrendingVideosAsync(string businessUnit, CancellationToken cancellationToken = default)
=> GetMediaCompositionListAsync(businessUnit, "trending", cancellationToken);
private async Task<MediaComposition?> GetMediaCompositionListAsync(string businessUnit, string endpoint, CancellationToken cancellationToken)
{ {
try try
{ {
var url = $"/video/{businessUnit}/trending.json"; var url = $"/video/{businessUnit}/{endpoint}.json";
_logger.LogInformation("Fetching trending videos for business unit: {BusinessUnit} from URL: {Url}", businessUnit, url); _logger.LogInformation("Fetching {Endpoint} videos for business unit: {BusinessUnit} from URL: {Url}", endpoint, businessUnit, url);
var response = await _httpClient.GetAsync(url, cancellationToken).ConfigureAwait(false); var response = await _httpClient.GetAsync(url, cancellationToken).ConfigureAwait(false);
_logger.LogInformation("Trending videos API response: {StatusCode}", response.StatusCode); _logger.LogInformation("{Endpoint} videos API response: {StatusCode}", endpoint, response.StatusCode);
if (!response.IsSuccessStatusCode) if (!response.IsSuccessStatusCode)
{ {
@@ -306,41 +279,16 @@ public class SRFApiClient : IDisposable
} }
var content = await ReadAsUtf8StringAsync(response.Content, cancellationToken).ConfigureAwait(false); var content = await ReadAsUtf8StringAsync(response.Content, cancellationToken).ConfigureAwait(false);
_logger.LogDebug("Trending videos response length: {Length}", content.Length); _logger.LogDebug("{Endpoint} videos response length: {Length}", endpoint, content.Length);
var result = JsonSerializer.Deserialize<MediaComposition>(content, _jsonOptions); var result = JsonSerializer.Deserialize<MediaComposition>(content, _jsonOptions);
_logger.LogInformation("Successfully fetched trending videos for business unit: {BusinessUnit}", businessUnit); _logger.LogInformation("Successfully fetched {Endpoint} videos for business unit: {BusinessUnit}", endpoint, businessUnit);
return result; return result;
} }
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogError(ex, "Error fetching trending videos for business unit: {BusinessUnit}", businessUnit); _logger.LogError(ex, "Error fetching {Endpoint} videos for business unit: {BusinessUnit}", endpoint, businessUnit);
return null;
}
}
/// <summary>
/// Gets raw JSON response from a URL.
/// </summary>
/// <param name="url">The relative URL.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>The JSON string.</returns>
public async Task<string?> GetJsonAsync(string url, CancellationToken cancellationToken = default)
{
try
{
_logger.LogDebug("Fetching JSON from URL: {Url}", url);
var response = await _httpClient.GetAsync(url, cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
var content = await ReadAsUtf8StringAsync(response.Content, cancellationToken).ConfigureAwait(false);
return content;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error fetching JSON from URL: {Url}", url);
return null; return null;
} }
} }
@@ -351,35 +299,8 @@ public class SRFApiClient : IDisposable
/// <param name="businessUnit">The business unit (e.g., srf, rts).</param> /// <param name="businessUnit">The business unit (e.g., srf, rts).</param>
/// <param name="cancellationToken">The cancellation token.</param> /// <param name="cancellationToken">The cancellation token.</param>
/// <returns>List of shows.</returns> /// <returns>List of shows.</returns>
public async Task<System.Collections.Generic.List<PlayV3Show>?> GetAllShowsAsync(string businessUnit, CancellationToken cancellationToken = default) public Task<System.Collections.Generic.List<PlayV3Show>?> GetAllShowsAsync(string businessUnit, CancellationToken cancellationToken = default)
{ => GetPlayV3DirectListAsync<PlayV3Show>(businessUnit, "shows", cancellationToken);
try
{
var baseUrl = string.Format(CultureInfo.InvariantCulture, PlayV3UrlFormat, businessUnit);
var url = $"{baseUrl}shows";
_logger.LogInformation("Fetching all shows for business unit: {BusinessUnit} from URL: {Url}", businessUnit, url);
var response = await _playV3HttpClient.GetAsync(url, cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
var errorContent = await ReadAsUtf8StringAsync(response.Content, cancellationToken).ConfigureAwait(false);
_logger.LogError("API returned error {StatusCode}: {Error}", response.StatusCode, errorContent);
return null;
}
var content = await ReadAsUtf8StringAsync(response.Content, cancellationToken).ConfigureAwait(false);
var result = JsonSerializer.Deserialize<PlayV3DirectResponse<PlayV3Show>>(content, _jsonOptions);
_logger.LogInformation("Successfully fetched {Count} shows for business unit: {BusinessUnit}", result?.Data?.Count ?? 0, businessUnit);
return result?.Data;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error fetching shows for business unit: {BusinessUnit}", businessUnit);
return null;
}
}
/// <summary> /// <summary>
/// Gets all topics from the Play v3 API. /// Gets all topics from the Play v3 API.
@@ -387,13 +308,16 @@ public class SRFApiClient : IDisposable
/// <param name="businessUnit">The business unit (e.g., srf, rts).</param> /// <param name="businessUnit">The business unit (e.g., srf, rts).</param>
/// <param name="cancellationToken">The cancellation token.</param> /// <param name="cancellationToken">The cancellation token.</param>
/// <returns>List of topics.</returns> /// <returns>List of topics.</returns>
public async Task<System.Collections.Generic.List<PlayV3Topic>?> GetAllTopicsAsync(string businessUnit, CancellationToken cancellationToken = default) public Task<System.Collections.Generic.List<PlayV3Topic>?> GetAllTopicsAsync(string businessUnit, CancellationToken cancellationToken = default)
=> GetPlayV3DirectListAsync<PlayV3Topic>(businessUnit, "topics", cancellationToken);
private async Task<System.Collections.Generic.List<T>?> GetPlayV3DirectListAsync<T>(string businessUnit, string endpoint, CancellationToken cancellationToken)
{ {
try try
{ {
var baseUrl = string.Format(CultureInfo.InvariantCulture, PlayV3UrlFormat, businessUnit); var baseUrl = string.Format(CultureInfo.InvariantCulture, PlayV3UrlFormat, businessUnit);
var url = $"{baseUrl}topics"; var url = $"{baseUrl}{endpoint}";
_logger.LogInformation("Fetching all topics for business unit: {BusinessUnit} from URL: {Url}", businessUnit, url); _logger.LogInformation("Fetching all {Endpoint} for business unit: {BusinessUnit} from URL: {Url}", endpoint, businessUnit, url);
var response = await _playV3HttpClient.GetAsync(url, cancellationToken).ConfigureAwait(false); var response = await _playV3HttpClient.GetAsync(url, cancellationToken).ConfigureAwait(false);
@@ -405,14 +329,14 @@ public class SRFApiClient : IDisposable
} }
var content = await ReadAsUtf8StringAsync(response.Content, cancellationToken).ConfigureAwait(false); var content = await ReadAsUtf8StringAsync(response.Content, cancellationToken).ConfigureAwait(false);
var result = JsonSerializer.Deserialize<PlayV3DirectResponse<PlayV3Topic>>(content, _jsonOptions); var result = JsonSerializer.Deserialize<PlayV3DirectResponse<T>>(content, _jsonOptions);
_logger.LogInformation("Successfully fetched {Count} topics for business unit: {BusinessUnit}", result?.Data?.Count ?? 0, businessUnit); _logger.LogInformation("Successfully fetched {Count} {Endpoint} for business unit: {BusinessUnit}", result?.Data?.Count ?? 0, endpoint, businessUnit);
return result?.Data; return result?.Data;
} }
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogError(ex, "Error fetching topics for business unit: {BusinessUnit}", businessUnit); _logger.LogError(ex, "Error fetching {Endpoint} for business unit: {BusinessUnit}", endpoint, businessUnit);
return null; return null;
} }
} }
@@ -190,7 +190,7 @@ public class SRFPlayChannel : IChannel, IHasCacheKey
{ {
try try
{ {
var businessUnit = config.BusinessUnit.ToString().ToLowerInvariant(); var businessUnit = config.BusinessUnit.ToLowerString();
var topics = await _categoryService.GetTopicsAsync(businessUnit, cancellationToken).ConfigureAwait(false); var topics = await _categoryService.GetTopicsAsync(businessUnit, cancellationToken).ConfigureAwait(false);
foreach (var topic in topics.Where(t => !string.IsNullOrEmpty(t.Id))) foreach (var topic in topics.Where(t => !string.IsNullOrEmpty(t.Id)))
@@ -200,7 +200,9 @@ public class SRFPlayChannel : IChannel, IHasCacheKey
continue; continue;
} }
items.Add(CreateFolder($"category_{topic.Id}", topic.Title ?? topic.Id!, topic.Lead)); // Generate placeholder image for topic
var placeholderUrl = CreatePlaceholderImageUrl(topic.Title ?? topic.Id!, _mediaSourceFactory.GetServerBaseUrl());
items.Add(CreateFolder($"category_{topic.Id}", topic.Title ?? topic.Id!, topic.Lead, placeholderUrl));
} }
_logger.LogInformation("Added {Count} category folders", topics.Count); _logger.LogInformation("Added {Count} category folders", topics.Count);
@@ -214,7 +216,7 @@ public class SRFPlayChannel : IChannel, IHasCacheKey
return items; return items;
} }
private static ChannelItemInfo CreateFolder(string id, string name, string? overview = null) private static ChannelItemInfo CreateFolder(string id, string name, string? overview = null, string? imageUrl = null)
{ {
return new ChannelItemInfo return new ChannelItemInfo
{ {
@@ -222,7 +224,7 @@ public class SRFPlayChannel : IChannel, IHasCacheKey
Name = name, Name = name,
Type = ChannelItemType.Folder, Type = ChannelItemType.Folder,
FolderType = ChannelFolderType.Container, FolderType = ChannelFolderType.Container,
ImageUrl = null, ImageUrl = imageUrl,
Overview = overview Overview = overview
}; };
} }
@@ -246,7 +248,7 @@ public class SRFPlayChannel : IChannel, IHasCacheKey
try try
{ {
var businessUnit = config?.BusinessUnit.ToString().ToLowerInvariant() ?? "srf"; var businessUnit = config?.BusinessUnit.ToLowerString() ?? "srf";
using var apiClient = _apiClientFactory.CreateClient(); using var apiClient = _apiClientFactory.CreateClient();
var scheduledLivestreams = await apiClient.GetScheduledLivestreamsAsync(businessUnit, "SPORT", cancellationToken).ConfigureAwait(false); var scheduledLivestreams = await apiClient.GetScheduledLivestreamsAsync(businessUnit, "SPORT", cancellationToken).ConfigureAwait(false);
@@ -308,7 +310,7 @@ public class SRFPlayChannel : IChannel, IHasCacheKey
{ {
var config = Plugin.Instance?.Configuration; var config = Plugin.Instance?.Configuration;
var topicId = folderId.Substring("category_".Length); var topicId = folderId.Substring("category_".Length);
var businessUnit = config?.BusinessUnit.ToString().ToLowerInvariant() ?? "srf"; var businessUnit = config?.BusinessUnit.ToLowerString() ?? "srf";
var shows = await _categoryService.GetShowsByTopicAsync(topicId, businessUnit, 20, cancellationToken).ConfigureAwait(false); var shows = await _categoryService.GetShowsByTopicAsync(topicId, businessUnit, 20, cancellationToken).ConfigureAwait(false);
var urns = new List<string>(); var urns = new List<string>();
@@ -438,7 +440,7 @@ public class SRFPlayChannel : IChannel, IHasCacheKey
_logger.LogDebug("Processing URN: {Urn}", urn); _logger.LogDebug("Processing URN: {Urn}", urn);
var mediaComposition = await apiClient.GetMediaCompositionByUrnAsync(urn, cancellationToken).ConfigureAwait(false); var mediaComposition = await apiClient.GetMediaCompositionByUrnAsync(urn, cancellationToken).ConfigureAwait(false);
if (mediaComposition?.ChapterList == null || mediaComposition.ChapterList.Count == 0) if (mediaComposition?.HasChapters != true)
{ {
_logger.LogWarning("URN {Urn}: No media composition or chapters found", urn); _logger.LogWarning("URN {Urn}: No media composition or chapters found", urn);
failedCount++; failedCount++;
@@ -156,4 +156,9 @@ public class PluginConfiguration : BasePluginConfiguration
/// When enabled, generates custom thumbnails instead of using SRF-provided images. /// When enabled, generates custom thumbnails instead of using SRF-provided images.
/// </summary> /// </summary>
public bool GenerateTitleCards { get; set; } public bool GenerateTitleCards { get; set; }
/// <summary>
/// Gets or sets the output directory for sport livestream recordings.
/// </summary>
public string RecordingOutputPath { get; set; } = string.Empty;
} }
@@ -90,6 +90,13 @@
<div class="fieldDescription">Password for proxy authentication (leave empty if not required)</div> <div class="fieldDescription">Password for proxy authentication (leave empty if not required)</div>
</div> </div>
<br /> <br />
<h2>Recording Settings</h2>
<div class="inputContainer">
<label class="inputLabel inputLabelUnfocused" for="RecordingOutputPath">Recording Output Directory</label>
<input id="RecordingOutputPath" name="RecordingOutputPath" type="text" is="emby-input" placeholder="e.g., /media/recordings/srf" />
<div class="fieldDescription">Directory where sport livestream recordings will be saved (requires ffmpeg)</div>
</div>
<br />
<h2>Network Settings</h2> <h2>Network Settings</h2>
<div class="inputContainer"> <div class="inputContainer">
<label class="inputLabel inputLabelUnfocused" for="PublicServerUrl">Public Server URL (Optional)</label> <label class="inputLabel inputLabelUnfocused" for="PublicServerUrl">Public Server URL (Optional)</label>
@@ -106,6 +113,31 @@
</button> </button>
</div> </div>
</form> </form>
<br />
<h2>Sport Livestream Recordings</h2>
<h3>Upcoming Sport Livestreams</h3>
<div class="fieldDescription">Select livestreams to record. The recording starts automatically when the stream goes live.</div>
<div id="scheduleContainer" style="margin: 10px 0;">
<p><em>Loading schedule...</em></p>
</div>
<button is="emby-button" type="button" class="raised emby-button" onclick="SRFPlayRecordings.loadSchedule()" style="margin-bottom: 20px;">
<span>Refresh Schedule</span>
</button>
<h3>Scheduled &amp; Active Recordings</h3>
<div id="activeRecordingsContainer" style="margin: 10px 0;">
<p><em>Loading...</em></p>
</div>
<h3>Completed Recordings</h3>
<div id="completedRecordingsContainer" style="margin: 10px 0;">
<p><em>Loading...</em></p>
</div>
<button is="emby-button" type="button" class="raised emby-button" onclick="SRFPlayRecordings.loadRecordings()" style="margin-bottom: 20px;">
<span>Refresh Recordings</span>
</button>
</div> </div>
</div> </div>
<script type="text/javascript"> <script type="text/javascript">
@@ -130,7 +162,12 @@
document.querySelector('#ProxyUsername').value = config.ProxyUsername || ''; document.querySelector('#ProxyUsername').value = config.ProxyUsername || '';
document.querySelector('#ProxyPassword').value = config.ProxyPassword || ''; document.querySelector('#ProxyPassword').value = config.ProxyPassword || '';
document.querySelector('#PublicServerUrl').value = config.PublicServerUrl || ''; document.querySelector('#PublicServerUrl').value = config.PublicServerUrl || '';
document.querySelector('#RecordingOutputPath').value = config.RecordingOutputPath || '';
Dashboard.hideLoadingMsg(); Dashboard.hideLoadingMsg();
// Load recordings UI
SRFPlayRecordings.loadSchedule();
SRFPlayRecordings.loadRecordings();
}); });
}); });
@@ -151,6 +188,7 @@
config.ProxyUsername = document.querySelector('#ProxyUsername').value; config.ProxyUsername = document.querySelector('#ProxyUsername').value;
config.ProxyPassword = document.querySelector('#ProxyPassword').value; config.ProxyPassword = document.querySelector('#ProxyPassword').value;
config.PublicServerUrl = document.querySelector('#PublicServerUrl').value; config.PublicServerUrl = document.querySelector('#PublicServerUrl').value;
config.RecordingOutputPath = document.querySelector('#RecordingOutputPath').value;
ApiClient.updatePluginConfiguration(SRFPlayConfig.pluginUniqueId, config).then(function (result) { ApiClient.updatePluginConfiguration(SRFPlayConfig.pluginUniqueId, config).then(function (result) {
Dashboard.processPluginConfigurationUpdateResult(result); Dashboard.processPluginConfigurationUpdateResult(result);
}); });
@@ -159,6 +197,205 @@
e.preventDefault(); e.preventDefault();
return false; return false;
}); });
var SRFPlayRecordings = {
apiBase: ApiClient.serverAddress() + '/Plugins/SRFPlay/Recording',
getHeaders: function() {
return {
'X-Emby-Token': ApiClient.accessToken()
};
},
formatDate: function(dateStr) {
if (!dateStr) return 'N/A';
var d = new Date(dateStr);
return d.toLocaleDateString() + ' ' + d.toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'});
},
formatSize: function(bytes) {
if (!bytes) return '';
if (bytes > 1073741824) return (bytes / 1073741824).toFixed(1) + ' GB';
if (bytes > 1048576) return (bytes / 1048576).toFixed(0) + ' MB';
return (bytes / 1024).toFixed(0) + ' KB';
},
loadSchedule: function() {
var container = document.getElementById('scheduleContainer');
container.innerHTML = '<p><em>Loading schedule...</em></p>';
fetch(this.apiBase + '/Schedule', { headers: this.getHeaders() })
.then(function(r) { return r.json(); })
.then(function(programs) {
if (!programs || programs.length === 0) {
container.innerHTML = '<p>No upcoming sport livestreams found.</p>';
return;
}
var html = '<table style="width:100%; border-collapse: collapse;">';
html += '<thead><tr style="text-align:left; border-bottom: 1px solid #444;">';
html += '<th style="padding: 8px;">Title</th>';
html += '<th style="padding: 8px;">Start</th>';
html += '<th style="padding: 8px;">End</th>';
html += '<th style="padding: 8px;">Action</th>';
html += '</tr></thead><tbody>';
programs.forEach(function(p) {
html += '<tr style="border-bottom: 1px solid #333;">';
html += '<td style="padding: 8px;">' + (p.title || 'Unknown') + '</td>';
html += '<td style="padding: 8px;">' + SRFPlayRecordings.formatDate(p.validFrom || p.date) + '</td>';
html += '<td style="padding: 8px;">' + SRFPlayRecordings.formatDate(p.validTo) + '</td>';
html += '<td style="padding: 8px;">';
html += '<button is="emby-button" type="button" class="raised emby-button" ';
html += 'onclick="SRFPlayRecordings.scheduleRecording(\'' + encodeURIComponent(p.urn) + '\')">';
html += '<span>Record</span></button>';
html += '</td></tr>';
});
html += '</tbody></table>';
container.innerHTML = html;
})
.catch(function(err) {
container.innerHTML = '<p style="color: #f44;">Error loading schedule: ' + err.message + '</p>';
});
},
scheduleRecording: function(encodedUrn) {
fetch(this.apiBase + '/Schedule/' + encodedUrn, {
method: 'POST',
headers: this.getHeaders()
})
.then(function(r) {
if (r.ok) {
Dashboard.alert('Recording scheduled!');
SRFPlayRecordings.loadRecordings();
} else {
Dashboard.alert('Failed to schedule recording');
}
})
.catch(function(err) { Dashboard.alert('Error: ' + err.message); });
},
loadRecordings: function() {
this.loadActiveRecordings();
this.loadCompletedRecordings();
},
loadActiveRecordings: function() {
var container = document.getElementById('activeRecordingsContainer');
container.innerHTML = '<p><em>Loading...</em></p>';
fetch(this.apiBase + '/All', { headers: this.getHeaders() })
.then(function(r) { return r.json(); })
.then(function(recordings) {
var activeStates = ['Scheduled', 'WaitingForStream', 'Recording', 0, 1, 2];
var active = recordings.filter(function(r) {
return activeStates.indexOf(r.state) !== -1;
});
if (active.length === 0) {
container.innerHTML = '<p>No scheduled or active recordings.</p>';
return;
}
var stateLabels = {'Scheduled': 'Scheduled', 'WaitingForStream': 'Waiting', 'Recording': 'Recording', 'Completed': 'Completed', 'Failed': 'Failed', 'Cancelled': 'Cancelled', 0: 'Scheduled', 1: 'Waiting', 2: 'Recording', 3: 'Completed', 4: 'Failed', 5: 'Cancelled'};
var stateColors = {'Scheduled': '#2196F3', 'WaitingForStream': '#FF9800', 'Recording': '#4CAF50', 'Failed': '#f44336', 'Cancelled': '#9E9E9E', 0: '#2196F3', 1: '#FF9800', 2: '#4CAF50', 4: '#f44336', 5: '#9E9E9E'};
var html = '<table style="width:100%; border-collapse: collapse;">';
html += '<thead><tr style="text-align:left; border-bottom: 1px solid #444;">';
html += '<th style="padding: 8px;">Title</th>';
html += '<th style="padding: 8px;">Status</th>';
html += '<th style="padding: 8px;">Start</th>';
html += '<th style="padding: 8px;">Action</th>';
html += '</tr></thead><tbody>';
active.forEach(function(r) {
html += '<tr style="border-bottom: 1px solid #333;">';
html += '<td style="padding: 8px;">' + (r.title || 'Unknown') + '</td>';
html += '<td style="padding: 8px;"><span style="color:' + (stateColors[r.state] || '#fff') + ';">' + (stateLabels[r.state] || r.state) + '</span></td>';
html += '<td style="padding: 8px;">' + SRFPlayRecordings.formatDate(r.validFrom) + '</td>';
html += '<td style="padding: 8px;">';
if (r.state === 2 || r.state === 'Recording') {
html += '<button is="emby-button" type="button" class="raised emby-button" ';
html += 'onclick="SRFPlayRecordings.stopRecording(\'' + r.id + '\')"><span>Stop</span></button>';
} else {
html += '<button is="emby-button" type="button" class="raised emby-button" ';
html += 'onclick="SRFPlayRecordings.cancelRecording(\'' + r.id + '\')"><span>Cancel</span></button>';
}
html += '</td></tr>';
});
html += '</tbody></table>';
container.innerHTML = html;
})
.catch(function(err) {
container.innerHTML = '<p style="color: #f44;">Error: ' + err.message + '</p>';
});
},
loadCompletedRecordings: function() {
var container = document.getElementById('completedRecordingsContainer');
container.innerHTML = '<p><em>Loading...</em></p>';
fetch(this.apiBase + '/Completed', { headers: this.getHeaders() })
.then(function(r) { return r.json(); })
.then(function(recordings) {
if (!recordings || recordings.length === 0) {
container.innerHTML = '<p>No completed recordings.</p>';
return;
}
var html = '<table style="width:100%; border-collapse: collapse;">';
html += '<thead><tr style="text-align:left; border-bottom: 1px solid #444;">';
html += '<th style="padding: 8px;">Title</th>';
html += '<th style="padding: 8px;">Recorded</th>';
html += '<th style="padding: 8px;">Size</th>';
html += '<th style="padding: 8px;">File</th>';
html += '<th style="padding: 8px;">Action</th>';
html += '</tr></thead><tbody>';
recordings.forEach(function(r) {
html += '<tr style="border-bottom: 1px solid #333;">';
html += '<td style="padding: 8px;">' + (r.title || 'Unknown') + '</td>';
html += '<td style="padding: 8px;">' + SRFPlayRecordings.formatDate(r.recordingStartedAt) + '</td>';
html += '<td style="padding: 8px;">' + SRFPlayRecordings.formatSize(r.fileSizeBytes) + '</td>';
html += '<td style="padding: 8px; font-size: 0.85em; word-break: break-all;">' + (r.outputPath || '') + '</td>';
html += '<td style="padding: 8px;">';
html += '<button is="emby-button" type="button" class="raised emby-button" style="background:#f44336;" ';
html += 'onclick="SRFPlayRecordings.deleteRecording(\'' + r.id + '\')"><span>Delete</span></button>';
html += '</td></tr>';
});
html += '</tbody></table>';
container.innerHTML = html;
})
.catch(function(err) {
container.innerHTML = '<p style="color: #f44;">Error: ' + err.message + '</p>';
});
},
stopRecording: function(id) {
fetch(this.apiBase + '/Active/' + id + '/Stop', {
method: 'POST',
headers: this.getHeaders()
}).then(function() { SRFPlayRecordings.loadRecordings(); });
},
cancelRecording: function(id) {
fetch(this.apiBase + '/Schedule/' + id, {
method: 'DELETE',
headers: this.getHeaders()
}).then(function() { SRFPlayRecordings.loadRecordings(); });
},
deleteRecording: function(id) {
if (!confirm('Delete this recording and its file?')) return;
fetch(this.apiBase + '/Completed/' + id + '?deleteFile=true', {
method: 'DELETE',
headers: this.getHeaders()
}).then(function() { SRFPlayRecordings.loadRecordings(); });
}
};
</script> </script>
</div> </div>
</body> </body>
@@ -28,12 +28,6 @@ public static class ApiEndpoints
/// </summary> /// </summary>
public const string SrfPlayHomepage = "https://www.srf.ch/play"; public const string SrfPlayHomepage = "https://www.srf.ch/play";
/// <summary>
/// Media composition endpoint template (relative to IntegrationLayerBaseUrl).
/// Format: {0} = URN.
/// </summary>
public const string MediaCompositionByUrnPath = "/mediaComposition/byUrn/{0}.json";
/// <summary> /// <summary>
/// Base proxy route for stream proxying (relative to server root). /// Base proxy route for stream proxying (relative to server root).
/// </summary> /// </summary>
@@ -44,30 +38,4 @@ public static class ApiEndpoints
/// Format: {0} = item ID. /// Format: {0} = item ID.
/// </summary> /// </summary>
public const string ProxyMasterManifestPath = "/Plugins/SRFPlay/Proxy/{0}/master.m3u8"; public const string ProxyMasterManifestPath = "/Plugins/SRFPlay/Proxy/{0}/master.m3u8";
/// <summary>
/// Image proxy route template (relative to server root).
/// Format: {0} = base64-encoded original URL.
/// </summary>
public const string ImageProxyPath = "/Plugins/SRFPlay/Image/{0}";
/// <summary>
/// Play V3 shows endpoint (relative to PlayV3 base URL).
/// </summary>
public const string PlayV3ShowsPath = "shows";
/// <summary>
/// Play V3 topics endpoint (relative to PlayV3 base URL).
/// </summary>
public const string PlayV3TopicsPath = "topics";
/// <summary>
/// Play V3 livestreams endpoint (relative to PlayV3 base URL).
/// </summary>
public const string PlayV3LivestreamsPath = "livestreams";
/// <summary>
/// Play V3 scheduled livestreams endpoint (relative to PlayV3 base URL).
/// </summary>
public const string PlayV3ScheduledLivestreamsPath = "scheduled-livestreams";
} }
@@ -0,0 +1,152 @@
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>
/// 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();
}
}
@@ -58,16 +58,28 @@ public class StreamProxyController : ControllerBase
/// Livestreams need frequent manifest refresh, VOD can be cached longer. /// Livestreams need frequent manifest refresh, VOD can be cached longer.
/// </summary> /// </summary>
/// <param name="itemId">The item ID to check.</param> /// <param name="itemId">The item ID to check.</param>
private void AddManifestCacheHeaders(string itemId) /// <param name="isVariantManifest">Whether this is a variant (sub) manifest with segment lists.</param>
private void AddManifestCacheHeaders(string itemId, bool isVariantManifest = false)
{ {
var metadata = _proxyService.GetStreamMetadata(itemId); var metadata = _proxyService.GetStreamMetadata(itemId);
var isLiveStream = metadata?.IsLiveStream ?? false; var isLiveStream = metadata?.IsLiveStream ?? false;
if (isLiveStream) if (isLiveStream)
{ {
// Livestreams need frequent manifest refresh (segments rotate every ~6-10s) if (isVariantManifest)
Response.Headers["Cache-Control"] = "max-age=2, must-revalidate"; {
_logger.LogDebug("Setting livestream cache headers for {ItemId}", itemId); // Variant manifests contain the segment list which changes every target duration.
// Use no-cache to ensure the player always gets the freshest segment list,
// preventing requests for segments that have been rotated out of the sliding window.
Response.Headers["Cache-Control"] = "no-cache, no-store";
}
else
{
// Master manifest is relatively stable (just lists quality variants)
Response.Headers["Cache-Control"] = "max-age=2, must-revalidate";
}
_logger.LogDebug("Setting livestream cache headers for {ItemId} (variant={IsVariant})", itemId, isVariantManifest);
} }
else else
{ {
@@ -196,10 +208,27 @@ public class StreamProxyController : ControllerBase
var manifestContent = System.Text.Encoding.UTF8.GetString(manifestData); var manifestContent = System.Text.Encoding.UTF8.GetString(manifestData);
var scheme = GetProxyScheme(); var scheme = GetProxyScheme();
var baseProxyUrl = $"{scheme}://{Request.Host}/Plugins/SRFPlay/Proxy/{itemId}"; var baseProxyUrl = $"{scheme}://{Request.Host}/Plugins/SRFPlay/Proxy/{itemId}";
var rewrittenContent = RewriteSegmentUrls(manifestContent, baseProxyUrl);
// Extract query parameters from the current request to propagate them
string queryParams;
if (Request.Query.TryGetValue("token", out var tokenVal) && !string.IsNullOrEmpty(tokenVal))
{
queryParams = $"?token={tokenVal}";
}
else if (Request.Query.TryGetValue("itemId", out var itemIdVal) && !string.IsNullOrEmpty(itemIdVal))
{
queryParams = $"?itemId={itemIdVal}";
}
else
{
queryParams = string.Empty;
}
var rewrittenContent = _proxyService.RewriteVariantManifestUrls(manifestContent, baseProxyUrl, queryParams);
// Set cache headers based on stream type (live vs VOD) // Set cache headers based on stream type (live vs VOD)
AddManifestCacheHeaders(actualItemId); // Variant manifests use stricter no-cache for live streams
AddManifestCacheHeaders(actualItemId, isVariantManifest: true);
_logger.LogDebug("Returning variant manifest for item {ItemId} ({Length} bytes)", itemId, rewrittenContent.Length); _logger.LogDebug("Returning variant manifest for item {ItemId} ({Length} bytes)", itemId, rewrittenContent.Length);
return Content(rewrittenContent, "application/vnd.apple.mpegurl; charset=utf-8"); return Content(rewrittenContent, "application/vnd.apple.mpegurl; charset=utf-8");
@@ -237,9 +266,12 @@ public class StreamProxyController : ControllerBase
{ {
// Pass the original query string to preserve segment-specific parameters (e.g., ?m=timestamp) // Pass the original query string to preserve segment-specific parameters (e.g., ?m=timestamp)
var queryString = Request.QueryString.HasValue ? Request.QueryString.Value : null; var queryString = Request.QueryString.HasValue ? Request.QueryString.Value : null;
var segmentData = await _proxyService.GetSegmentAsync(actualItemId, segmentPath, queryString, cancellationToken).ConfigureAwait(false);
if (segmentData == null) // Use streaming proxy: starts forwarding data to the client before the full segment
// is downloaded from the CDN, reducing time-to-first-byte for live streams
using var upstreamResponse = await _proxyService.GetSegmentStreamAsync(actualItemId, segmentPath, queryString, cancellationToken).ConfigureAwait(false);
if (upstreamResponse == null)
{ {
_logger.LogWarning("Segment not found - ItemId: {ItemId}, Path: {SegmentPath}", itemId, segmentPath); _logger.LogWarning("Segment not found - ItemId: {ItemId}, Path: {SegmentPath}", itemId, segmentPath);
return NotFound(); return NotFound();
@@ -247,13 +279,36 @@ public class StreamProxyController : ControllerBase
// Determine content type based on file extension // Determine content type based on file extension
var contentType = MimeTypeHelper.GetSegmentContentType(segmentPath); var contentType = MimeTypeHelper.GetSegmentContentType(segmentPath);
Response.ContentType = contentType;
_logger.LogDebug("Returning segment {SegmentPath} ({Length} bytes, {ContentType})", segmentPath, segmentData.Length, contentType); if (upstreamResponse.Content.Headers.ContentLength.HasValue)
return File(segmentData, contentType); {
Response.ContentLength = upstreamResponse.Content.Headers.ContentLength.Value;
}
// Stream directly from CDN to client without buffering the entire segment in memory
await upstreamResponse.Content.CopyToAsync(Response.Body, cancellationToken).ConfigureAwait(false);
_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) catch (Exception ex)
{ {
_logger.LogError(ex, "Error proxying segment - ItemId: {ItemId}, Path: {SegmentPath}", itemId, segmentPath); _logger.LogError(ex, "Error proxying segment - ItemId: {ItemId}, Path: {SegmentPath}", itemId, segmentPath);
// If we already started streaming data to the client, we can't change the status code
if (Response.HasStarted)
{
return new EmptyResult();
}
return StatusCode(StatusCodes.Status500InternalServerError); return StatusCode(StatusCodes.Status500InternalServerError);
} }
} }
@@ -311,81 +366,6 @@ public class StreamProxyController : ControllerBase
return pathItemId; return pathItemId;
} }
/// <summary>
/// Rewrites segment URLs in a manifest to point to proxy.
/// </summary>
/// <param name="manifestContent">The manifest content.</param>
/// <param name="baseProxyUrl">The base proxy URL.</param>
/// <returns>The rewritten manifest.</returns>
private string RewriteSegmentUrls(string manifestContent, string baseProxyUrl)
{
// Extract query parameters from the current request to propagate them
string queryParams;
if (Request.Query.TryGetValue("token", out var token) && !string.IsNullOrEmpty(token))
{
queryParams = $"?token={token}";
}
else if (Request.Query.TryGetValue("itemId", out var itemId) && !string.IsNullOrEmpty(itemId))
{
queryParams = $"?itemId={itemId}";
}
else
{
queryParams = string.Empty;
}
// Helper function to rewrite a single URL
string RewriteUrl(string url)
{
if (url.Contains("://", StringComparison.Ordinal))
{
// Absolute URL - extract filename and rewrite
var uri = new Uri(url.Trim());
var segments = uri.AbsolutePath.Split('/');
var fileName = segments[^1];
return $"{baseProxyUrl}/{fileName}{queryParams}";
}
// Relative URL - rewrite to proxy
return $"{baseProxyUrl}/{url.Trim()}{queryParams}";
}
var lines = manifestContent.Split('\n');
var result = new System.Text.StringBuilder();
foreach (var line in lines)
{
if (string.IsNullOrWhiteSpace(line))
{
result.AppendLine(line);
}
else if (line.StartsWith('#'))
{
// HLS tag line - check for URI="..." attributes (e.g., #EXT-X-MAP:URI="init.mp4")
if (line.Contains("URI=\"", StringComparison.Ordinal))
{
var rewrittenLine = System.Text.RegularExpressions.Regex.Replace(
line,
@"URI=""([^""]+)""",
match => $"URI=\"{RewriteUrl(match.Groups[1].Value)}\"");
result.AppendLine(rewrittenLine);
}
else
{
// Keep other metadata lines as-is
result.AppendLine(line);
}
}
else
{
// Non-tag line with URL - rewrite it
result.AppendLine(RewriteUrl(line));
}
}
return result.ToString();
}
/// <summary> /// <summary>
/// Proxies image requests from SRF CDN, fixing Content-Type headers. /// Proxies image requests from SRF CDN, fixing Content-Type headers.
/// </summary> /// </summary>
@@ -50,7 +50,7 @@ public class SRFEpisodeProvider : IRemoteMetadataProvider<Episode, EpisodeInfo>
var mediaComposition = await _compositionFetcher.GetMediaCompositionAsync(urn, cancellationToken).ConfigureAwait(false); var mediaComposition = await _compositionFetcher.GetMediaCompositionAsync(urn, cancellationToken).ConfigureAwait(false);
if (mediaComposition?.ChapterList != null && mediaComposition.ChapterList.Count > 0) if (mediaComposition?.HasChapters == true)
{ {
var chapter = mediaComposition.ChapterList[0]; var chapter = mediaComposition.ChapterList[0];
results.Add(new RemoteSearchResult results.Add(new RemoteSearchResult
@@ -93,7 +93,7 @@ public class SRFEpisodeProvider : IRemoteMetadataProvider<Episode, EpisodeInfo>
var mediaComposition = await _compositionFetcher.GetMediaCompositionAsync(urn, cancellationToken).ConfigureAwait(false); var mediaComposition = await _compositionFetcher.GetMediaCompositionAsync(urn, cancellationToken).ConfigureAwait(false);
if (mediaComposition?.ChapterList == null || mediaComposition.ChapterList.Count == 0) if (mediaComposition?.HasChapters != true)
{ {
_logger.LogWarning("No chapter information found for URN: {Urn}", urn); _logger.LogWarning("No chapter information found for URN: {Urn}", urn);
return result; return result;
@@ -92,7 +92,7 @@ public class SRFImageProvider : IRemoteImageProvider, IHasOrder
} }
// Extract images from chapters // Extract images from chapters
if (mediaComposition.ChapterList != null && mediaComposition.ChapterList.Count > 0) if (mediaComposition.HasChapters)
{ {
var chapter = mediaComposition.ChapterList[0]; var chapter = mediaComposition.ChapterList[0];
if (!string.IsNullOrEmpty(chapter.ImageUrl)) if (!string.IsNullOrEmpty(chapter.ImageUrl))
@@ -70,7 +70,7 @@ public class SRFMediaProvider : IMediaSourceProvider
var mediaComposition = await _compositionFetcher.GetMediaCompositionAsync(urn, cancellationToken, cacheDuration).ConfigureAwait(false); var mediaComposition = await _compositionFetcher.GetMediaCompositionAsync(urn, cancellationToken, cacheDuration).ConfigureAwait(false);
if (mediaComposition?.ChapterList == null || mediaComposition.ChapterList.Count == 0) if (mediaComposition?.HasChapters != true)
{ {
_logger.LogWarning("No chapters found for URN: {Urn}", urn); _logger.LogWarning("No chapters found for URN: {Urn}", urn);
return sources; return sources;
@@ -116,7 +116,7 @@ public class SRFMediaProvider : IMediaSourceProvider
// Force fresh fetch with short cache duration // Force fresh fetch with short cache duration
var freshMediaComposition = await _compositionFetcher.GetMediaCompositionAsync(urn, cancellationToken, 1).ConfigureAwait(false); var freshMediaComposition = await _compositionFetcher.GetMediaCompositionAsync(urn, cancellationToken, 1).ConfigureAwait(false);
if (freshMediaComposition?.ChapterList != null && freshMediaComposition.ChapterList.Count > 0) if (freshMediaComposition?.HasChapters == true)
{ {
var freshChapter = freshMediaComposition.ChapterList[0]; var freshChapter = freshMediaComposition.ChapterList[0];
mediaSource = await _mediaSourceFactory.CreateMediaSourceAsync( mediaSource = await _mediaSourceFactory.CreateMediaSourceAsync(
@@ -60,25 +60,8 @@ public class ExpirationCheckTask : IScheduledTask
return; return;
} }
// Get expiration statistics first // Check and remove expired content
progress?.Report(25); progress?.Report(25);
var (total, expired, expiringSoon) = await _expirationService.GetExpirationStatisticsAsync(cancellationToken).ConfigureAwait(false);
_logger.LogInformation(
"Expiration statistics - Total: {Total}, Expired: {Expired}, Expiring Soon: {ExpiringSoon}",
total,
expired,
expiringSoon);
if (expired == 0)
{
_logger.LogInformation("No expired content found");
progress?.Report(100);
return;
}
// Remove expired content
progress?.Report(50);
var removedCount = await _expirationService.CheckAndRemoveExpiredContentAsync(cancellationToken).ConfigureAwait(false); var removedCount = await _expirationService.CheckAndRemoveExpiredContentAsync(cancellationToken).ConfigureAwait(false);
// Clean up old stream proxy mappings // Clean up old stream proxy mappings
@@ -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.FromMinutes(2).Ticks
}
};
}
}
@@ -41,9 +41,13 @@ public class ServiceRegistrator : IPluginServiceRegistrator
// Register media source provider // Register media source provider
serviceCollection.AddSingleton<SRFMediaProvider>(); serviceCollection.AddSingleton<SRFMediaProvider>();
// Register recording service
serviceCollection.AddSingleton<IRecordingService, RecordingService>();
// Register scheduled tasks // Register scheduled tasks
serviceCollection.AddSingleton<IScheduledTask, ContentRefreshTask>(); serviceCollection.AddSingleton<IScheduledTask, ContentRefreshTask>();
serviceCollection.AddSingleton<IScheduledTask, ExpirationCheckTask>(); serviceCollection.AddSingleton<IScheduledTask, ExpirationCheckTask>();
serviceCollection.AddSingleton<IScheduledTask, RecordingSchedulerTask>();
// Register channel - must register as IChannel interface for Jellyfin to discover it // Register channel - must register as IChannel interface for Jellyfin to discover it
serviceCollection.AddSingleton<IChannel, SRFPlayChannel>(); serviceCollection.AddSingleton<IChannel, SRFPlayChannel>();
@@ -118,11 +118,12 @@ public class ContentExpirationService : IContentExpirationService
var mediaComposition = await _compositionFetcher.GetMediaCompositionAsync(urn, cancellationToken).ConfigureAwait(false); var mediaComposition = await _compositionFetcher.GetMediaCompositionAsync(urn, cancellationToken).ConfigureAwait(false);
if (mediaComposition?.ChapterList == null || mediaComposition.ChapterList.Count == 0) if (mediaComposition?.HasChapters != true)
{ {
// If we can't fetch the content, consider it expired // Don't treat API failures as expired - the content may still be available
_logger.LogWarning("Could not fetch media composition for URN: {Urn}, treating as expired", urn); // and a transient error (network issue, 403, API outage) shouldn't delete library items
return true; _logger.LogWarning("Could not fetch media composition for URN: {Urn}, skipping (not treating as expired)", urn);
return false;
} }
var chapter = mediaComposition.ChapterList[0]; var chapter = mediaComposition.ChapterList[0];
@@ -135,72 +136,4 @@ public class ContentExpirationService : IContentExpirationService
return isExpired; return isExpired;
} }
/// <summary>
/// Gets statistics about content expiration.
/// </summary>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>Tuple with total count, expired count, and items expiring soon.</returns>
public async Task<(int Total, int Expired, int ExpiringSoon)> GetExpirationStatisticsAsync(CancellationToken cancellationToken)
{
var total = 0;
var expired = 0;
var expiringSoon = 0;
var soonThreshold = DateTime.UtcNow.AddDays(7); // Items expiring within 7 days
try
{
var query = new InternalItemsQuery
{
HasAnyProviderId = new Dictionary<string, string> { { "SRF", string.Empty } },
IsVirtualItem = false
};
var items = _libraryManager.GetItemList(query);
total = items.Count;
foreach (var item in items)
{
if (cancellationToken.IsCancellationRequested)
{
break;
}
try
{
var urn = item.ProviderIds.GetValueOrDefault("SRF");
if (string.IsNullOrEmpty(urn))
{
continue;
}
var mediaComposition = await _compositionFetcher.GetMediaCompositionAsync(urn, cancellationToken).ConfigureAwait(false);
if (mediaComposition?.ChapterList != null && mediaComposition.ChapterList.Count > 0)
{
var chapter = mediaComposition.ChapterList[0];
if (_streamResolver.IsContentExpired(chapter))
{
expired++;
}
else if (chapter.ValidTo.HasValue && chapter.ValidTo.Value.ToUniversalTime() <= soonThreshold)
{
expiringSoon++;
}
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error checking expiration statistics for item: {Name}", item.Name);
}
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error getting expiration statistics");
}
return (total, expired, expiringSoon);
}
} }
@@ -5,6 +5,7 @@ using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using Jellyfin.Plugin.SRFPlay.Api; using Jellyfin.Plugin.SRFPlay.Api;
using Jellyfin.Plugin.SRFPlay.Services.Interfaces; using Jellyfin.Plugin.SRFPlay.Services.Interfaces;
using Jellyfin.Plugin.SRFPlay.Utilities;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.SRFPlay.Services; namespace Jellyfin.Plugin.SRFPlay.Services;
@@ -45,7 +46,7 @@ public class ContentRefreshService : IContentRefreshService
} }
return await FetchVideosFromShowsAsync( return await FetchVideosFromShowsAsync(
config.BusinessUnit.ToString().ToLowerInvariant(), config.BusinessUnit.ToLowerString(),
minEpisodeCount: 0, minEpisodeCount: 0,
maxShows: 20, maxShows: 20,
videosPerShow: 1, videosPerShow: 1,
@@ -69,7 +70,7 @@ public class ContentRefreshService : IContentRefreshService
} }
return await FetchVideosFromShowsAsync( return await FetchVideosFromShowsAsync(
config.BusinessUnit.ToString().ToLowerInvariant(), config.BusinessUnit.ToLowerString(),
minEpisodeCount: 10, minEpisodeCount: 10,
maxShows: 15, maxShows: 15,
videosPerShow: 2, videosPerShow: 2,
@@ -167,48 +168,4 @@ public class ContentRefreshService : IContentRefreshService
return urns; return urns;
} }
/// <summary>
/// Refreshes all content (latest and trending).
/// </summary>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>Tuple with counts of latest and trending items.</returns>
public async Task<(int LatestCount, int TrendingCount)> RefreshAllContentAsync(CancellationToken cancellationToken)
{
_logger.LogInformation("Starting full content refresh");
var latestUrns = await RefreshLatestContentAsync(cancellationToken).ConfigureAwait(false);
var trendingUrns = await RefreshTrendingContentAsync(cancellationToken).ConfigureAwait(false);
var latestCount = latestUrns.Count;
var trendingCount = trendingUrns.Count;
_logger.LogInformation(
"Content refresh completed. Latest: {LatestCount}, Trending: {TrendingCount}",
latestCount,
trendingCount);
return (latestCount, trendingCount);
}
/// <summary>
/// Gets content recommendations (combines latest and trending).
/// </summary>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>List of recommended URNs.</returns>
public async Task<List<string>> GetRecommendedContentAsync(CancellationToken cancellationToken)
{
var recommendations = new HashSet<string>();
var latestUrns = await RefreshLatestContentAsync(cancellationToken).ConfigureAwait(false);
var trendingUrns = await RefreshTrendingContentAsync(cancellationToken).ConfigureAwait(false);
foreach (var urn in latestUrns.Concat(trendingUrns))
{
recommendations.Add(urn);
}
_logger.LogInformation("Generated {Count} content recommendations", recommendations.Count);
return recommendations.ToList();
}
} }
@@ -14,11 +14,4 @@ public interface IContentExpirationService
/// <param name="cancellationToken">The cancellation token.</param> /// <param name="cancellationToken">The cancellation token.</param>
/// <returns>The number of items removed.</returns> /// <returns>The number of items removed.</returns>
Task<int> CheckAndRemoveExpiredContentAsync(CancellationToken cancellationToken); Task<int> CheckAndRemoveExpiredContentAsync(CancellationToken cancellationToken);
/// <summary>
/// Gets statistics about content expiration.
/// </summary>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>Tuple with total count, expired count, and items expiring soon.</returns>
Task<(int Total, int Expired, int ExpiringSoon)> GetExpirationStatisticsAsync(CancellationToken cancellationToken);
} }
@@ -22,18 +22,4 @@ public interface IContentRefreshService
/// <param name="cancellationToken">The cancellation token.</param> /// <param name="cancellationToken">The cancellation token.</param>
/// <returns>List of URNs for trending content.</returns> /// <returns>List of URNs for trending content.</returns>
Task<List<string>> RefreshTrendingContentAsync(CancellationToken cancellationToken); Task<List<string>> RefreshTrendingContentAsync(CancellationToken cancellationToken);
/// <summary>
/// Refreshes all content (latest and trending).
/// </summary>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>Tuple with counts of latest and trending items.</returns>
Task<(int LatestCount, int TrendingCount)> RefreshAllContentAsync(CancellationToken cancellationToken);
/// <summary>
/// Gets content recommendations (combines latest and trending).
/// </summary>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>List of recommended URNs.</returns>
Task<List<string>> GetRecommendedContentAsync(CancellationToken cancellationToken);
} }
@@ -22,20 +22,8 @@ public interface IMetadataCache
/// <param name="mediaComposition">The media composition to cache.</param> /// <param name="mediaComposition">The media composition to cache.</param>
void SetMediaComposition(string urn, MediaComposition mediaComposition); void SetMediaComposition(string urn, MediaComposition mediaComposition);
/// <summary>
/// Removes media composition from cache.
/// </summary>
/// <param name="urn">The URN.</param>
void RemoveMediaComposition(string urn);
/// <summary> /// <summary>
/// Clears all cached data. /// Clears all cached data.
/// </summary> /// </summary>
void Clear(); void Clear();
/// <summary>
/// Gets the cache statistics.
/// </summary>
/// <returns>A tuple with cache count and size estimate.</returns>
(int Count, long SizeEstimate) GetStatistics();
} }
@@ -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);
}
@@ -1,3 +1,4 @@
using System.Net.Http;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
@@ -8,15 +9,6 @@ namespace Jellyfin.Plugin.SRFPlay.Services.Interfaces;
/// </summary> /// </summary>
public interface IStreamProxyService public interface IStreamProxyService
{ {
/// <summary>
/// Registers a stream for proxying with an already-authenticated URL.
/// </summary>
/// <param name="itemId">The item ID.</param>
/// <param name="authenticatedUrl">The authenticated stream URL.</param>
/// <param name="urn">The SRF URN for this content (used for re-fetching fresh URLs).</param>
/// <param name="isLiveStream">Whether this is a livestream (livestreams always fetch fresh URLs).</param>
void RegisterStream(string itemId, string authenticatedUrl, string? urn = null, bool isLiveStream = false);
/// <summary> /// <summary>
/// Registers a stream for deferred authentication (authenticates on first playback request). /// Registers a stream for deferred authentication (authenticates on first playback request).
/// Use this when browsing to avoid wasting 30-second tokens before the user clicks play. /// Use this when browsing to avoid wasting 30-second tokens before the user clicks play.
@@ -61,6 +53,27 @@ public interface IStreamProxyService
/// <returns>The segment content as bytes.</returns> /// <returns>The segment content as bytes.</returns>
Task<byte[]?> GetSegmentAsync(string itemId, string segmentPath, string? queryString = null, CancellationToken cancellationToken = default); Task<byte[]?> GetSegmentAsync(string itemId, string segmentPath, string? queryString = null, CancellationToken cancellationToken = default);
/// <summary>
/// Fetches a segment from the original source as a streaming response.
/// Returns the HttpResponseMessage for streaming directly to the client, reducing TTFB.
/// The caller is responsible for disposing the response.
/// </summary>
/// <param name="itemId">The item ID.</param>
/// <param name="segmentPath">The segment path.</param>
/// <param name="queryString">The original query string from the request.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The HTTP response for streaming, or null if not found.</returns>
Task<HttpResponseMessage?> GetSegmentStreamAsync(string itemId, string segmentPath, string? queryString = null, CancellationToken cancellationToken = default);
/// <summary>
/// Rewrites URLs in a variant (sub) manifest to point to the proxy.
/// </summary>
/// <param name="manifestContent">The variant manifest content.</param>
/// <param name="baseProxyUrl">The base proxy URL (without query params).</param>
/// <param name="queryParams">Query parameters to append to rewritten URLs (e.g., "?token=abc").</param>
/// <returns>The rewritten manifest content.</returns>
string RewriteVariantManifestUrls(string manifestContent, string baseProxyUrl, string queryParams);
/// <summary> /// <summary>
/// Cleans up old and expired stream mappings. /// Cleans up old and expired stream mappings.
/// </summary> /// </summary>
+12 -132
View File
@@ -1,6 +1,5 @@
using System; using System;
using System.Collections.Concurrent; using System.Collections.Concurrent;
using System.Threading;
using Jellyfin.Plugin.SRFPlay.Api.Models; using Jellyfin.Plugin.SRFPlay.Api.Models;
using Jellyfin.Plugin.SRFPlay.Services.Interfaces; using Jellyfin.Plugin.SRFPlay.Services.Interfaces;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
@@ -10,12 +9,10 @@ namespace Jellyfin.Plugin.SRFPlay.Services;
/// <summary> /// <summary>
/// Service for caching metadata from SRF API. /// Service for caching metadata from SRF API.
/// </summary> /// </summary>
public sealed class MetadataCache : IMetadataCache, IDisposable public sealed class MetadataCache : IMetadataCache
{ {
private readonly ILogger<MetadataCache> _logger; private readonly ILogger<MetadataCache> _logger;
private readonly ConcurrentDictionary<string, CacheEntry<MediaComposition>> _mediaCompositionCache; private readonly ConcurrentDictionary<string, CacheEntry<MediaComposition>> _mediaCompositionCache;
private readonly ReaderWriterLockSlim _lock;
private bool _disposed;
/// <summary> /// <summary>
/// Initializes a new instance of the <see cref="MetadataCache"/> class. /// Initializes a new instance of the <see cref="MetadataCache"/> class.
@@ -25,19 +22,6 @@ public sealed class MetadataCache : IMetadataCache, IDisposable
{ {
_logger = logger; _logger = logger;
_mediaCompositionCache = new ConcurrentDictionary<string, CacheEntry<MediaComposition>>(); _mediaCompositionCache = new ConcurrentDictionary<string, CacheEntry<MediaComposition>>();
_lock = new ReaderWriterLockSlim();
}
/// <summary>
/// Disposes resources.
/// </summary>
public void Dispose()
{
if (!_disposed)
{
_lock?.Dispose();
_disposed = true;
}
} }
/// <summary> /// <summary>
@@ -53,30 +37,15 @@ public sealed class MetadataCache : IMetadataCache, IDisposable
return null; return null;
} }
try if (_mediaCompositionCache.TryGetValue(urn, out var entry))
{ {
_lock.EnterReadLock(); if (entry.IsValid(cacheDurationMinutes))
try
{ {
if (_mediaCompositionCache.TryGetValue(urn, out var entry)) _logger.LogDebug("Cache hit for URN: {Urn}", urn);
{ return entry.Value;
if (entry.IsValid(cacheDurationMinutes)) }
{
_logger.LogDebug("Cache hit for URN: {Urn}", urn);
return entry.Value;
}
_logger.LogDebug("Cache entry expired for URN: {Urn}", urn); _logger.LogDebug("Cache entry expired for URN: {Urn}", urn);
}
}
finally
{
_lock.ExitReadLock();
}
}
catch (ObjectDisposedException)
{
return null;
} }
return null; return null;
@@ -94,56 +63,9 @@ public sealed class MetadataCache : IMetadataCache, IDisposable
return; return;
} }
try var entry = new CacheEntry<MediaComposition>(mediaComposition);
{ _mediaCompositionCache.AddOrUpdate(urn, entry, (key, oldValue) => entry);
_lock.EnterWriteLock(); _logger.LogDebug("Cached media composition for URN: {Urn}", urn);
try
{
var entry = new CacheEntry<MediaComposition>(mediaComposition);
_mediaCompositionCache.AddOrUpdate(urn, entry, (key, oldValue) => entry);
_logger.LogDebug("Cached media composition for URN: {Urn}", urn);
}
finally
{
_lock.ExitWriteLock();
}
}
catch (ObjectDisposedException)
{
// Cache is disposed, ignore
}
}
/// <summary>
/// Removes media composition from cache.
/// </summary>
/// <param name="urn">The URN.</param>
public void RemoveMediaComposition(string urn)
{
if (string.IsNullOrEmpty(urn))
{
return;
}
try
{
_lock.EnterWriteLock();
try
{
if (_mediaCompositionCache.TryRemove(urn, out _))
{
_logger.LogDebug("Removed cached media composition for URN: {Urn}", urn);
}
}
finally
{
_lock.ExitWriteLock();
}
}
catch (ObjectDisposedException)
{
// Cache is disposed, ignore
}
} }
/// <summary> /// <summary>
@@ -151,50 +73,8 @@ public sealed class MetadataCache : IMetadataCache, IDisposable
/// </summary> /// </summary>
public void Clear() public void Clear()
{ {
try _mediaCompositionCache.Clear();
{ _logger.LogInformation("Cleared metadata cache");
_lock.EnterWriteLock();
try
{
_mediaCompositionCache.Clear();
_logger.LogInformation("Cleared metadata cache");
}
finally
{
_lock.ExitWriteLock();
}
}
catch (ObjectDisposedException)
{
// Cache is disposed, ignore
}
}
/// <summary>
/// Gets the cache statistics.
/// </summary>
/// <returns>A tuple with cache count and size estimate.</returns>
public (int Count, long SizeEstimate) GetStatistics()
{
try
{
_lock.EnterReadLock();
try
{
var count = _mediaCompositionCache.Count;
// Rough estimate: average 50KB per entry
var sizeEstimate = count * 50L * 1024;
return (count, sizeEstimate);
}
finally
{
_lock.ExitReadLock();
}
}
catch (ObjectDisposedException)
{
return (0, 0);
}
} }
/// <summary> /// <summary>
@@ -0,0 +1,517 @@
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 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 ConcurrentDictionary<string, Process> _activeProcesses = new();
private static readonly JsonSerializerOptions _jsonOptions = new() { WriteIndented = true };
private readonly SemaphoreSlim _persistLock = 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>
public RecordingService(
ILogger<RecordingService> logger,
ISRFApiClientFactory apiClientFactory,
IStreamProxyService proxyService,
IStreamUrlResolver streamUrlResolver,
IMediaCompositionFetcher mediaCompositionFetcher,
IServerApplicationHost appHost)
{
_logger = logger;
_apiClientFactory = apiClientFactory;
_proxyService = proxyService;
_streamUrlResolver = streamUrlResolver;
_mediaCompositionFetcher = mediaCompositionFetcher;
_appHost = appHost;
}
private string GetDataFilePath()
{
var dataPath = Plugin.Instance?.DataFolderPath ?? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "jellyfin", "plugins", "SRFPlay");
Directory.CreateDirectory(dataPath);
return Path.Combine(dataPath, "recordings.json");
}
private string GetRecordingOutputPath()
{
var config = Plugin.Instance?.Configuration;
var path = config?.RecordingOutputPath;
if (string.IsNullOrWhiteSpace(path))
{
path = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "SRFRecordings");
}
Directory.CreateDirectory(path);
return path;
}
private string GetServerBaseUrl()
{
var config = Plugin.Instance?.Configuration;
if (config != null && !string.IsNullOrWhiteSpace(config.PublicServerUrl))
{
return config.PublicServerUrl.TrimEnd('/');
}
// For local ffmpeg access, use localhost directly
return "http://localhost:8096";
}
private async Task LoadRecordingsAsync()
{
if (_loaded)
{
return;
}
var filePath = GetDataFilePath();
if (File.Exists(filePath))
{
try
{
var json = await File.ReadAllTextAsync(filePath).ConfigureAwait(false);
_recordings = JsonSerializer.Deserialize<List<RecordingEntry>>(json) ?? new List<RecordingEntry>();
_logger.LogInformation("Loaded {Count} recording entries from {Path}", _recordings.Count, filePath);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to load recordings from {Path}", filePath);
_recordings = new List<RecordingEntry>();
}
}
_loaded = true;
}
private async Task SaveRecordingsAsync()
{
await _persistLock.WaitAsync().ConfigureAwait(false);
try
{
var filePath = GetDataFilePath();
var json = JsonSerializer.Serialize(_recordings, _jsonOptions);
await File.WriteAllTextAsync(filePath, json).ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to save recordings");
}
finally
{
_persistLock.Release();
}
}
/// <inheritdoc />
public async Task<IReadOnlyList<PlayV3TvProgram>> GetUpcomingScheduleAsync(CancellationToken cancellationToken)
{
var config = Plugin.Instance?.Configuration;
var businessUnit = (config?.BusinessUnit ?? Configuration.BusinessUnit.SRF).ToString().ToLowerInvariant();
using var apiClient = _apiClientFactory.CreateClient();
var livestreams = await apiClient.GetScheduledLivestreamsAsync(businessUnit, "SPORT", cancellationToken).ConfigureAwait(false);
if (livestreams == null)
{
return Array.Empty<PlayV3TvProgram>();
}
// Filter to only future/current livestreams that aren't blocked
return livestreams
.Where(ls => ls.Blocked != true && (ls.ValidTo == null || ls.ValidTo > 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
var config = Plugin.Instance?.Configuration;
var businessUnit = (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,
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;
}
/// <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)
{
await LoadRecordingsAsync().ConfigureAwait(false);
var now = DateTime.UtcNow;
var changed = false;
foreach (var entry in _recordings.ToList())
{
switch (entry.State)
{
case RecordingState.Scheduled:
case RecordingState.WaitingForStream:
// Check if it's time to start recording
if (entry.ValidFrom.HasValue && entry.ValidFrom.Value <= now.AddMinutes(2))
{
changed |= await TryStartRecordingAsync(entry, cancellationToken).ConfigureAwait(false);
}
break;
case RecordingState.Recording:
// Check if recording should stop (ValidTo reached or process died)
if (entry.ValidTo.HasValue && entry.ValidTo.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 = "ffmpeg",
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();
}
_disposed = true;
}
}
@@ -46,45 +46,6 @@ public class StreamProxyService : IStreamProxyService
_streamMappings = new ConcurrentDictionary<string, StreamInfo>(); _streamMappings = new ConcurrentDictionary<string, StreamInfo>();
} }
/// <summary>
/// Registers a stream for proxying.
/// </summary>
/// <param name="itemId">The item ID.</param>
/// <param name="authenticatedUrl">The authenticated stream URL.</param>
/// <param name="urn">The SRF URN for this content (used for re-fetching fresh URLs).</param>
/// <param name="isLiveStream">Whether this is a livestream (livestreams always fetch fresh URLs).</param>
public void RegisterStream(string itemId, string authenticatedUrl, string? urn = null, bool isLiveStream = false)
{
var tokenExpiry = ExtractTokenExpiry(authenticatedUrl);
var unauthenticatedUrl = StripAuthenticationFromUrl(authenticatedUrl);
var streamInfo = new StreamInfo
{
AuthenticatedUrl = authenticatedUrl,
UnauthenticatedUrl = unauthenticatedUrl,
RegisteredAt = DateTime.UtcNow,
TokenExpiresAt = tokenExpiry,
Urn = urn,
IsLiveStream = isLiveStream,
LastLivestreamFetchAt = isLiveStream ? DateTime.UtcNow : null
};
RegisterWithGuidFormats(itemId, streamInfo);
if (tokenExpiry.HasValue)
{
_logger.LogDebug(
"Registered stream for item {ItemId} (token expires at {ExpiresAt} UTC): {Url}",
itemId,
tokenExpiry.Value,
authenticatedUrl);
}
else
{
_logger.LogDebug("Registered stream for item {ItemId}: {Url}", itemId, authenticatedUrl);
}
}
/// <summary> /// <summary>
/// Registers a stream for deferred authentication (authenticates on first playback request). /// Registers a stream for deferred authentication (authenticates on first playback request).
/// </summary> /// </summary>
@@ -412,6 +373,15 @@ public class StreamProxyService : IStreamProxyService
return refreshedUrl; return refreshedUrl;
} }
if (streamInfo.IsLiveStream)
{
// For livestreams, keep the mapping and flag for re-authentication
// rather than removing it — the next request will trigger a fresh auth
_logger.LogWarning("Failed to refresh token for livestream {ItemId}, will re-authenticate on next request", itemId);
streamInfo.NeedsAuthentication = true;
return streamInfo.AuthenticatedUrl;
}
_logger.LogWarning("Failed to refresh token for item {ItemId}, removing mapping", itemId); _logger.LogWarning("Failed to refresh token for item {ItemId}, removing mapping", itemId);
_streamMappings.TryRemove(itemId, out _); _streamMappings.TryRemove(itemId, out _);
return null; return null;
@@ -442,7 +412,7 @@ public class StreamProxyService : IStreamProxyService
// Use short cache duration (5 min) for livestreams // Use short cache duration (5 min) for livestreams
var mediaComposition = await _compositionFetcher.GetMediaCompositionAsync(streamInfo.Urn, cancellationToken, 5).ConfigureAwait(false); var mediaComposition = await _compositionFetcher.GetMediaCompositionAsync(streamInfo.Urn, cancellationToken, 5).ConfigureAwait(false);
if (mediaComposition?.ChapterList == null || mediaComposition.ChapterList.Count == 0) if (mediaComposition?.HasChapters != true)
{ {
_logger.LogWarning("No chapters found when refreshing livestream URL for URN: {Urn}", streamInfo.Urn); _logger.LogWarning("No chapters found when refreshing livestream URL for URN: {Urn}", streamInfo.Urn);
return null; return null;
@@ -686,6 +656,21 @@ public class StreamProxyService : IStreamProxyService
// Rewrite the manifest to replace Akamai URLs with proxy URLs // Rewrite the manifest to replace Akamai URLs with proxy URLs
var rewrittenContent = RewriteManifestUrls(manifestContent, authenticatedUrl, baseProxyUrl); var rewrittenContent = RewriteManifestUrls(manifestContent, authenticatedUrl, baseProxyUrl);
// For live streams, inject #EXT-X-START to tell the player to start near the live edge
// Without this, players may start at the beginning of the sliding window and stutter
// as old segments get rotated out by the CDN
if (_streamMappings.TryGetValue(itemId, out var streamInfoForManifest) && streamInfoForManifest.IsLiveStream)
{
if (!rewrittenContent.Contains("#EXT-X-START", StringComparison.Ordinal))
{
rewrittenContent = rewrittenContent.Replace(
"#EXTM3U",
"#EXTM3U\n#EXT-X-START:TIME-OFFSET=-6,PRECISE=NO",
StringComparison.Ordinal);
_logger.LogDebug("Injected #EXT-X-START tag for live stream {ItemId}", itemId);
}
}
_logger.LogDebug("Rewritten manifest for item {ItemId} ({Length} bytes):\n{Content}", itemId, rewrittenContent.Length, rewrittenContent); _logger.LogDebug("Rewritten manifest for item {ItemId} ({Length} bytes):\n{Content}", itemId, rewrittenContent.Length, rewrittenContent);
return rewrittenContent; return rewrittenContent;
} }
@@ -759,6 +744,67 @@ public class StreamProxyService : IStreamProxyService
} }
} }
/// <summary>
/// Fetches a segment as a streaming response for direct forwarding to the client.
/// Uses HttpCompletionOption.ResponseHeadersRead to start streaming before the full
/// segment is downloaded, reducing time-to-first-byte for live streams.
/// </summary>
/// <param name="itemId">The item ID.</param>
/// <param name="segmentPath">The segment path.</param>
/// <param name="queryString">The original query string from the request.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The HTTP response for streaming, or null if not found.</returns>
public async Task<HttpResponseMessage?> GetSegmentStreamAsync(
string itemId,
string segmentPath,
string? queryString = null,
CancellationToken cancellationToken = default)
{
var authenticatedUrl = await GetAuthenticatedUrlAsync(itemId, cancellationToken).ConfigureAwait(false);
if (authenticatedUrl == null)
{
return null;
}
try
{
var baseUri = new Uri(authenticatedUrl);
var baseUrl = $"{baseUri.Scheme}://{baseUri.Host}{string.Join('/', baseUri.AbsolutePath.Split('/')[..^1])}";
var queryParams = string.Empty;
if (!string.IsNullOrEmpty(queryString))
{
queryParams = queryString.StartsWith('?') ? queryString : $"?{queryString}";
}
else if (!segmentPath.Contains("hdntl=", StringComparison.OrdinalIgnoreCase))
{
queryParams = baseUri.Query;
}
var segmentUrl = $"{baseUrl}/{segmentPath}{queryParams}";
_logger.LogDebug("Streaming segment - SegmentPath: {SegmentPath}, FullUrl: {FullUrl}", segmentPath, segmentUrl);
var httpClient = _httpClientFactory.CreateClient(NamedClient.Default);
var request = new HttpRequestMessage(HttpMethod.Get, segmentUrl);
var response = await httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
_logger.LogWarning("Segment stream request failed with {StatusCode} for {SegmentPath}", response.StatusCode, segmentPath);
response.Dispose();
return null;
}
return response;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to stream segment {SegmentPath} for item {ItemId}", segmentPath, itemId);
return null;
}
}
/// <summary> /// <summary>
/// Rewrites URLs in HLS manifest to point to proxy. /// Rewrites URLs in HLS manifest to point to proxy.
/// </summary> /// </summary>
@@ -863,6 +909,65 @@ public class StreamProxyService : IStreamProxyService
} }
} }
/// <summary>
/// Rewrites URLs in a variant (sub) manifest to point to the proxy.
/// </summary>
/// <param name="manifestContent">The variant manifest content.</param>
/// <param name="baseProxyUrl">The base proxy URL (without query params).</param>
/// <param name="queryParams">Query parameters to append to rewritten URLs (e.g., "?token=abc").</param>
/// <returns>The rewritten manifest content.</returns>
public string RewriteVariantManifestUrls(string manifestContent, string baseProxyUrl, string queryParams)
{
string RewriteUrl(string url)
{
if (url.Contains("://", StringComparison.Ordinal))
{
// Absolute URL - extract filename and rewrite
var uri = new Uri(url.Trim());
var segments = uri.AbsolutePath.Split('/');
var fileName = segments[^1];
return $"{baseProxyUrl}/{fileName}{queryParams}";
}
// Relative URL - rewrite to proxy
return $"{baseProxyUrl}/{url.Trim()}{queryParams}";
}
var lines = manifestContent.Split('\n');
var result = new System.Text.StringBuilder();
foreach (var line in lines)
{
if (string.IsNullOrWhiteSpace(line))
{
result.AppendLine(line);
}
else if (line.StartsWith('#'))
{
// HLS tag line - check for URI="..." attributes (e.g., #EXT-X-MAP:URI="init.mp4")
if (line.Contains("URI=\"", StringComparison.Ordinal))
{
var rewrittenLine = Regex.Replace(
line,
@"URI=""([^""]+)""",
match => $"URI=\"{RewriteUrl(match.Groups[1].Value)}\"");
result.AppendLine(rewrittenLine);
}
else
{
result.AppendLine(line);
}
}
else
{
// Non-tag line with URL - rewrite it
result.AppendLine(RewriteUrl(line));
}
}
return result.ToString();
}
/// <summary> /// <summary>
/// Cleans up old and expired stream mappings. /// Cleans up old and expired stream mappings.
/// </summary> /// </summary>
@@ -883,8 +988,9 @@ public class StreamProxyService : IStreamProxyService
_logger.LogDebug("Marking item {ItemId} for cleanup (old registration)", kvp.Key); _logger.LogDebug("Marking item {ItemId} for cleanup (old registration)", kvp.Key);
} }
// Remove if token has expired // Remove if token has expired — but skip livestreams since their CDN tokens
if (kvp.Value.TokenExpiresAt.HasValue && kvp.Value.TokenExpiresAt.Value <= now) // expire every ~30s and get refreshed on-demand during playback
if (kvp.Value.TokenExpiresAt.HasValue && kvp.Value.TokenExpiresAt.Value <= now && !kvp.Value.IsLiveStream)
{ {
shouldRemove = true; shouldRemove = true;
_logger.LogDebug("Marking item {ItemId} for cleanup (expired token)", kvp.Key); _logger.LogDebug("Marking item {ItemId} for cleanup (expired token)", kvp.Key);
@@ -53,7 +53,7 @@ public class StreamUrlResolver : IStreamUrlResolver
// Filter out DRM-protected content // Filter out DRM-protected content
var nonDrmResources = chapter.ResourceList var nonDrmResources = chapter.ResourceList
.Where(r => r.DrmList == null || r.DrmList.ToString() == "[]") .Where(r => r.IsPlayable)
.ToList(); .ToList();
_logger.LogInformation( _logger.LogInformation(
@@ -136,8 +136,8 @@ public class StreamUrlResolver : IStreamUrlResolver
{ {
QualityPreference.HD => SelectHDResource(hlsResources) ?? SelectBestAvailableResource(hlsResources), QualityPreference.HD => SelectHDResource(hlsResources) ?? SelectBestAvailableResource(hlsResources),
QualityPreference.SD => SelectSDResource(hlsResources) ?? SelectBestAvailableResource(hlsResources), QualityPreference.SD => SelectSDResource(hlsResources) ?? SelectBestAvailableResource(hlsResources),
QualityPreference.Auto => SelectBestAvailableResource(hlsResources), QualityPreference.Auto => hlsResources.FirstOrDefault(),
_ => SelectBestAvailableResource(hlsResources) _ => hlsResources.FirstOrDefault()
}; };
if (selectedResource != null) if (selectedResource != null)
@@ -164,10 +164,33 @@ public class StreamUrlResolver : IStreamUrlResolver
{ {
if (chapter?.ResourceList == null || chapter.ResourceList.Count == 0) if (chapter?.ResourceList == null || chapter.ResourceList.Count == 0)
{ {
_logger.LogWarning(
"Chapter {ChapterId}: ResourceList is null or empty. ResourceList count: {Count}, ResourceList type: {Type}",
chapter?.Id ?? "null",
chapter?.ResourceList?.Count ?? -1,
chapter?.ResourceList?.GetType().Name ?? "null");
return false; return false;
} }
return chapter.ResourceList.Any(r => r.DrmList == null || r.DrmList.ToString() == "[]"); _logger.LogInformation(
"Chapter {ChapterId}: Found {ResourceCount} resources",
chapter.Id,
chapter.ResourceList.Count);
foreach (var resource in chapter.ResourceList)
{
var urlPreview = resource.Url == null ? "null" : resource.Url.AsSpan(0, Math.Min(60, resource.Url.Length)).ToString() + "...";
_logger.LogDebug(
"Resource - URL: {Url}, Quality: {Quality}, DrmList: {DrmList}, DrmList type: {DrmListType}",
urlPreview,
resource.Quality,
resource.DrmList?.ToString() ?? "null",
resource.DrmList?.GetType().Name ?? "null");
}
var hasPlayable = chapter.ResourceList.Any(r => r.IsPlayable);
_logger.LogInformation("Chapter {ChapterId}: Has playable content: {HasPlayable}", chapter.Id, hasPlayable);
return hasPlayable;
} }
/// <summary> /// <summary>
@@ -1,5 +1,4 @@
using Jellyfin.Plugin.SRFPlay.Configuration; using Jellyfin.Plugin.SRFPlay.Configuration;
using MediaBrowser.Controller.Entities;
namespace Jellyfin.Plugin.SRFPlay.Utilities; namespace Jellyfin.Plugin.SRFPlay.Utilities;
@@ -8,18 +7,6 @@ namespace Jellyfin.Plugin.SRFPlay.Utilities;
/// </summary> /// </summary>
public static class Extensions public static class Extensions
{ {
/// <summary>
/// Gets the SRF URN from the item's provider IDs.
/// </summary>
/// <param name="item">The base item.</param>
/// <returns>The SRF URN, or null if not found or empty.</returns>
public static string? GetSrfUrn(this BaseItem item)
{
return item.ProviderIds.TryGetValue("SRF", out var urn) && !string.IsNullOrEmpty(urn)
? urn
: null;
}
/// <summary> /// <summary>
/// Converts the BusinessUnit enum to its lowercase string representation. /// Converts the BusinessUnit enum to its lowercase string representation.
/// </summary> /// </summary>
@@ -29,13 +16,4 @@ public static class Extensions
{ {
return businessUnit.ToString().ToLowerInvariant(); return businessUnit.ToString().ToLowerInvariant();
} }
/// <summary>
/// Gets the plugin configuration safely, returning null if not available.
/// </summary>
/// <returns>The plugin configuration, or null if not available.</returns>
public static PluginConfiguration? GetPluginConfig()
{
return Plugin.Instance?.Configuration;
}
} }
+63 -32
View File
@@ -23,7 +23,7 @@ Then install "SRF Play" from the plugin catalog.
- Support for all Swiss broadcasting units (SRF, RTS, RSI, RTR, SWI) - Support for all Swiss broadcasting units (SRF, RTS, RSI, RTR, SWI)
- Automatic content expiration handling - Automatic content expiration handling
- Latest and trending content discovery - Latest and trending content discovery
- Quality selection (Auto, SD, HD) - Quality selection (Auto lets CDN decide, SD prefers 480p/360p, HD prefers 1080p/720p)
- HLS streaming support with Akamai token authentication - HLS streaming support with Akamai token authentication
- Proxy support for routing traffic through alternate gateways - Proxy support for routing traffic through alternate gateways
- Smart caching with reduced TTL for upcoming livestreams - Smart caching with reduced TTL for upcoming livestreams
@@ -126,7 +126,7 @@ The compiled plugin will be in `bin/Debug/net8.0/`
## Configuration ## Configuration
- **Business Unit**: Select the Swiss broadcasting unit (default: SRF) - **Business Unit**: Select the Swiss broadcasting unit (default: SRF)
- **Quality Preference**: Choose video quality (Auto/SD/HD) - **Quality Preference**: Choose video quality Auto (first available, CDN decides), SD, or HD
- **Content Refresh Interval**: How often to check for new content (1-168 hours) - **Content Refresh Interval**: How often to check for new content (1-168 hours)
- **Expiration Check Interval**: How often to check for expired content (1-168 hours) - **Expiration Check Interval**: How often to check for expired content (1-168 hours)
- **Cache Duration**: How long to cache metadata (5-1440 minutes) - **Cache Duration**: How long to cache metadata (5-1440 minutes)
@@ -154,49 +154,78 @@ If you encounter issues with the plugin:
``` ```
Jellyfin.Plugin.SRFPlay/ Jellyfin.Plugin.SRFPlay/
├── Api/ ├── Api/
│ ├── Models/ # API response models │ ├── Models/ # API response models
│ │ ├── MediaComposition.cs │ │ ├── MediaComposition.cs # Root composition (HasChapters helper)
│ │ ├── Chapter.cs │ │ ├── Chapter.cs # Video/episode chapter with resources
│ │ ├── Resource.cs │ │ ├── Resource.cs # Stream URL entry (IsPlayable helper)
│ │ ├── Show.cs │ │ ├── Show.cs
│ │ ├── Episode.cs │ │ ├── Episode.cs
│ │ └── PlayV3/ # Play v3 API models │ │ └── PlayV3/ # Play v3 API models
│ │ ├── PlayV3Show.cs
│ │ ├── PlayV3Topic.cs
│ │ ├── PlayV3Video.cs
│ │ ├── PlayV3TvProgram.cs │ │ ├── PlayV3TvProgram.cs
│ │ ── PlayV3TvProgramGuideResponse.cs │ │ ── PlayV3Response.cs
└── SRFApiClient.cs # HTTP client for SRF API │ ├── PlayV3DirectResponse.cs
│ │ └── PlayV3DataContainer.cs
│ ├── SRFApiClient.cs # HTTP client for SRF APIs
│ ├── ISRFApiClientFactory.cs # Factory interface
│ └── SRFApiClientFactory.cs # Factory implementation
├── Channels/ ├── Channels/
│ └── SRFPlayChannel.cs # Channel implementation │ └── SRFPlayChannel.cs # Channel implementation
├── Configuration/ ├── Configuration/
│ ├── PluginConfiguration.cs │ ├── PluginConfiguration.cs
│ └── configPage.html │ └── configPage.html
├── Constants/
│ └── ApiEndpoints.cs # API base URLs and endpoint constants
├── Controllers/
│ └── StreamProxyController.cs # HLS proxy endpoints (master/variant/segment)
├── Services/ ├── Services/
│ ├── StreamUrlResolver.cs # HLS stream resolution & authentication │ ├── Interfaces/ # Service contracts
│ ├── MetadataCache.cs # Caching layer │ ├── IStreamProxyService.cs
│ ├── ContentExpirationService.cs # Expiration management │ ├── IStreamUrlResolver.cs
│ ├── ContentRefreshService.cs # Content discovery │ ├── IMediaCompositionFetcher.cs
└── CategoryService.cs # Topic/category management │ ├── IMediaSourceFactory.cs
│ │ ├── IMetadataCache.cs
│ │ ├── IContentRefreshService.cs
│ │ ├── IContentExpirationService.cs
│ │ └── ICategoryService.cs
│ ├── StreamProxyService.cs # HLS proxy: auth, manifest rewriting, segments
│ ├── StreamUrlResolver.cs # Stream selection & Akamai authentication
│ ├── MediaCompositionFetcher.cs # Cached API fetcher
│ ├── MediaSourceFactory.cs # Jellyfin MediaSourceInfo builder
│ ├── MetadataCache.cs # Thread-safe ConcurrentDictionary cache
│ ├── ContentExpirationService.cs
│ ├── ContentRefreshService.cs
│ └── CategoryService.cs
├── Providers/ ├── Providers/
│ ├── SRFSeriesProvider.cs # Series metadata │ ├── SRFSeriesProvider.cs # Series metadata
│ ├── SRFEpisodeProvider.cs # Episode metadata │ ├── SRFEpisodeProvider.cs # Episode metadata
│ ├── SRFImageProvider.cs # Image fetching │ ├── SRFImageProvider.cs # Image fetching
│ └── SRFMediaProvider.cs # Playback URLs │ └── SRFMediaProvider.cs # Playback URLs
├── Utilities/
│ ├── Extensions.cs # BusinessUnit.ToLowerString() extension
│ ├── MimeTypeHelper.cs # Content-type detection
│ ├── PlaceholderImageGenerator.cs
│ └── UrnHelper.cs # URN parsing utilities
├── ScheduledTasks/ ├── ScheduledTasks/
│ ├── ContentRefreshTask.cs # Periodic content refresh │ ├── ContentRefreshTask.cs # Periodic content refresh
│ └── ExpirationCheckTask.cs # Periodic expiration check │ └── ExpirationCheckTask.cs # Periodic expiration check
├── ServiceRegistrator.cs # DI registration ├── ServiceRegistrator.cs # DI registration
└── Plugin.cs # Main plugin entry point └── Plugin.cs # Main plugin entry point
``` ```
### Key Components ### Key Components
1. **API Client**: Handles all HTTP requests to SRF Integration Layer and Play v3 API 1. **API Client** (`SRFApiClient`): HTTP requests to SRF Integration Layer and Play v3 API, with proxy support
2. **Channel**: SRF Play channel with Latest, Trending, and Live Sports folders 2. **Channel** (`SRFPlayChannel`): SRF Play channel with Latest, Trending, and Live Sports folders
3. **Stream Resolver**: Extracts and selects optimal HLS streams with Akamai authentication 3. **Stream Proxy** (`StreamProxyService` + `StreamProxyController`): HLS proxy that handles Akamai token auth, manifest URL rewriting, deferred authentication, and token refresh for both VOD and livestreams
4. **Configuration**: User-configurable settings via Jellyfin dashboard 4. **Stream Resolver** (`StreamUrlResolver`): Selects optimal HLS stream by quality preference, filters DRM content
5. **Metadata Cache**: Thread-safe caching with dynamic TTL for livestreams 5. **Metadata Cache** (`MetadataCache`): Thread-safe `ConcurrentDictionary` cache with dynamic TTL for livestreams
6. **Content Providers**: Jellyfin integration for series, episodes, images, and media sources 6. **Media Composition Fetcher** (`MediaCompositionFetcher`): Cached wrapper around API client for media composition requests
7. **Scheduled Tasks**: Automatic content refresh and expiration management 7. **Content Providers** (`SRFSeriesProvider`, `SRFEpisodeProvider`, `SRFImageProvider`, `SRFMediaProvider`): Jellyfin integration for series, episodes, images, and media sources
8. **Service Layer**: Content discovery, expiration handling, stream resolution, and category management 8. **Scheduled Tasks**: Automatic content refresh and expiration management
9. **Utilities**: Business unit extensions, MIME type helpers, URN parsing, placeholder image generation
## Important Notes ## Important Notes
@@ -249,7 +278,9 @@ See LICENSE file for details.
## Acknowledgments ## Acknowledgments
This plugin was developed with inspiration from the excellent [Kodi SRG SSR addon](https://github.com/goggle/script.module.srgssr) by [@goggle](https://github.com/goggle). The Kodi addon served as a fantastic reference for understanding the SRG SSR API structure, authentication mechanisms, and handling of scheduled livestreams. This plugin was developed partly using [Claude Code](https://docs.anthropic.com/en/docs/claude-code) by Anthropic.
Inspired by the excellent [Kodi SRG SSR addon](https://github.com/goggle/script.module.srgssr) by [@goggle](https://github.com/goggle), which served as a fantastic reference for understanding the SRG SSR API structure, authentication mechanisms, and handling of scheduled livestreams.
## References ## References
+40
View File
@@ -8,6 +8,46 @@
"category": "Live TV", "category": "Live TV",
"imageUrl": "https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/raw/branch/master/assests/main%20logo.png", "imageUrl": "https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/raw/branch/master/assests/main%20logo.png",
"versions": [ "versions": [
{
"version": "1.0.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",
"targetAbi": "10.9.0.0",
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/releases/download/v1.0.16/srfplay_1.0.16.0.zip",
"checksum": "0b3a142dd6cea1f00855bcb11bcd847c",
"timestamp": "2026-01-17T20:31:09Z"
},
{
"version": "1.0.15",
"changelog": "Release 1.0.15",
"targetAbi": "10.9.0.0",
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/releases/download/v1.0.15/srfplay_1.0.15.0.zip",
"checksum": "3140ef53a7460d7ce52daf48bbb37e4d",
"timestamp": "2026-01-17T09:54:39Z"
},
{ {
"version": "1.0.14", "version": "1.0.14",
"changelog": "Release 1.0.14", "changelog": "Release 1.0.14",