Compare commits

...
37 Commits
Author SHA1 Message Date
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
dtourolle 92dc6d8203 fix: change in server side API for live stream
🏗️ Build Plugin / build (push) Successful in 4m12s
🧪 Test Plugin / test (push) Successful in 1m24s
🚀 Release Plugin / build-and-release (push) Successful in 2m49s
2026-01-17 10:46:11 +01:00
Gitea Actions d313b68975 Update manifest.json for version 1.0.14 2025-12-30 12:38:41 +00:00
dtourolle 60434abd01 Use utf-8 decode everywhere
🏗️ Build Plugin / build (push) Successful in 3m4s
🧪 Test Plugin / test (push) Successful in 1m24s
🚀 Release Plugin / build-and-release (push) Successful in 2m52s
2025-12-30 13:31:13 +01:00
Gitea Actions 5ace3f4296 Update manifest.json for version 1.0.13 2025-12-21 13:02:38 +00:00
dtourolle 757aab1943 Break-line for placeholder titles when too long
🏗️ Build Plugin / build (push) Successful in 2m57s
🧪 Test Plugin / test (push) Successful in 1m26s
🚀 Release Plugin / build-and-release (push) Successful in 2m56s
2025-12-21 13:55:11 +01:00
Gitea Actions 23d8da9ae7 Update manifest.json for version 1.0.12 2025-12-20 13:35:38 +00:00
dtourolle 2631c93444 moved repo install guide to top 2025-12-20 14:32:13 +01:00
dtourolle 7f71d3419c Add repo manifest
🏗️ Build Plugin / build (push) Successful in 2m49s
🧪 Test Plugin / test (push) Successful in 1m21s
🚀 Release Plugin / build-and-release (push) Successful in 2m46s
Use title cards when non provided or is livestream
2025-12-20 14:28:39 +01:00
dtourolle dbbdd7eb6d utf-8 decdoding
🏗️ Build Plugin / build (push) Successful in 2m46s
🧪 Test Plugin / test (push) Successful in 1m21s
🚀 Release Plugin / build-and-release (push) Successful in 2m41s
2025-12-14 09:52:22 +01:00
dtourolle 9146830546 remove live-tv as it has issues with URI resolution
🏗️ Build Plugin / build (push) Successful in 2m43s
🧪 Test Plugin / test (push) Successful in 1m16s
2025-12-07 18:18:40 +01:00
dtourolle 0548fe7dec Use TV guide for livestreams
🏗️ Build Plugin / build (push) Successful in 2m53s
🧪 Test Plugin / test (push) Successful in 1m21s
2025-12-07 17:41:48 +01:00
dtourolle 198fc4c58d more consolidation
🚀 Release Plugin / build-and-release (push) Successful in 2m51s
🏗️ Build Plugin / build (push) Successful in 2m50s
🧪 Test Plugin / test (push) Successful in 1m20s
2025-12-07 13:29:13 +01:00
dtourolle ed4cc0990c mixed refactor
🏗️ Build Plugin / build (push) Successful in 2m47s
🧪 Test Plugin / test (push) Successful in 1m22s
🚀 Release Plugin / build-and-release (push) Successful in 2m39s
2025-12-06 20:18:43 +01:00
dtourolle 4f9ebe2bce enable query and provide stream assumed stream info if missing.
🏗️ Build Plugin / build (push) Successful in 2m38s
🧪 Test Plugin / test (push) Successful in 1m14s
2025-12-06 18:08:34 +01:00
dtourolle 14b6c33542 Proxy images to fix broken meta-data 2025-12-06 17:34:17 +01:00
dtourolle a0e7663323 refactor to unify data fetching and define abstract API for re-use
🏗️ Build Plugin / build (push) Successful in 2m35s
🧪 Test Plugin / test (push) Successful in 1m14s
2025-12-06 17:29:05 +01:00
dtourolle 0fea57a4f9 Dynamically fetch livestream info, resolves bug where stale data caused playback to fail.
🏗️ Build Plugin / build (push) Successful in 3m47s
🧪 Test Plugin / test (push) Successful in 1m43s
2025-12-06 16:35:36 +01:00
dtourolle 89c41842a7 Update readme 2025-11-23 14:07:25 +01:00
dtourolle 89a911b9c4 working livestreams!
🏗️ Build Plugin / build (push) Successful in 3m27s
🧪 Test Plugin / test (push) Successful in 1m38s
🚀 Release Plugin / build-and-release (push) Successful in 3m29s
2025-11-22 14:14:43 +01:00
dtourolle b8ac466c90 Fix issue with Jellyfin using local IP adress not server public URL, now pulic URL is set in plugin.
🏗️ Build Plugin / build (push) Successful in 3m21s
🧪 Test Plugin / test (push) Successful in 1m36s
🚀 Release Plugin / build-and-release (push) Successful in 3m9s
2025-11-21 20:17:05 +01:00
dtourolle e26f2a2ab1 passthrough not transcode
🚀 Release Plugin / build-and-release (push) Successful in 3m19s
🏗️ Build Plugin / build (push) Successful in 3m8s
🧪 Test Plugin / test (push) Successful in 1m43s
2025-11-16 20:53:23 +01:00
dtourolle cd0f680981 Improved token refresh mechanism
🏗️ Build Plugin / build (push) Successful in 3m29s
🧪 Test Plugin / test (push) Successful in 1m37s
🚀 Release Plugin / build-and-release (push) Successful in 3m9s
2025-11-16 20:15:30 +01:00
dtourolle cfe510e15c Finaly working version of livestreams
🏗️ Build Plugin / build (push) Successful in 2m30s
🧪 Test Plugin / test (push) Successful in 1m11s
🚀 Release Plugin / build-and-release (push) Successful in 2m31s
2025-11-15 22:34:21 +01:00
50 changed files with 3559 additions and 1557 deletions
+21 -20
View File
@@ -15,47 +15,48 @@ on:
jobs:
build:
runs-on: ubuntu-latest
runs-on: linux/amd64
container:
image: gitea.tourolle.paris/dtourolle/srfplay-builder:latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Verify .NET installation
run: dotnet --version
with:
path: build-${{ github.run_id }}
- name: Restore dependencies
working-directory: build-${{ github.run_id }}
run: dotnet restore Jellyfin.Plugin.SRFPlay.sln
- name: Build solution
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
working-directory: build-${{ github.run_id }}
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
id: jprm
working-directory: build-${{ github.run_id }}
run: |
# Create artifacts directory for JPRM output
mkdir -p artifacts
# Build plugin using JPRM
/tmp/jprm-venv/bin/jprm --verbosity=debug plugin build .
# Find the generated zip file
ARTIFACT=$(find . -name "*.zip" -type f -print -quit)
echo "artifact=${ARTIFACT}" >> $GITHUB_OUTPUT
echo "Found artifact: ${ARTIFACT}"
jprm --verbosity=debug plugin build .
ARTIFACT=$(find . -name "*.zip" -type f -print -quit | sed 's|^\./||')
LATEST="artifacts/srfplay_latest.zip"
cp "${ARTIFACT}" "${LATEST}"
echo "artifact=${LATEST}" >> $GITHUB_OUTPUT
echo "Found artifact: ${ARTIFACT} -> ${LATEST}"
- name: Upload build artifact
uses: actions/upload-artifact@v3
with:
name: jellyfin-srfplay-plugin
path: ${{ steps.jprm.outputs.artifact }}
path: build-${{ github.run_id }}/${{ steps.jprm.outputs.artifact }}
retention-days: 30
if-no-files-found: error
- name: Cleanup
if: always()
run: rm -rf build-${{ github.run_id }}
+64 -28
View File
@@ -13,14 +13,15 @@ on:
jobs:
build-and-release:
runs-on: ubuntu-latest
runs-on: linux/amd64
container:
image: gitea.tourolle.paris/dtourolle/srfplay-builder:latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Verify .NET installation
run: dotnet --version
with:
path: release-${{ github.run_id }}
- name: Get version
id: get_version
@@ -35,42 +36,46 @@ jobs:
echo "Building version: ${VERSION}"
- name: Update build.yaml with version
working-directory: release-${{ github.run_id }}
run: |
VERSION="${{ steps.get_version.outputs.version_number }}"
sed -i "s/^version:.*/version: \"${VERSION}\"/" build.yaml
cat build.yaml
- name: Restore dependencies
working-directory: release-${{ github.run_id }}
run: dotnet restore Jellyfin.Plugin.SRFPlay.sln
- 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
working-directory: release-${{ github.run_id }}
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
id: jprm
working-directory: release-${{ github.run_id }}
run: |
# Create artifacts directory for JPRM output
mkdir -p artifacts
# Build plugin using JPRM
/tmp/jprm-venv/bin/jprm --verbosity=debug plugin build ./
# Find the generated zip file
ARTIFACT=$(find . -name "*.zip" -type f -print -quit)
jprm --verbosity=debug plugin build ./
ARTIFACT=$(find . -name "*.zip" -type f -print -quit | sed 's|^\./||')
ARTIFACT_NAME=$(basename "${ARTIFACT}")
echo "artifact=${ARTIFACT}" >> $GITHUB_OUTPUT
echo "artifact_name=${ARTIFACT_NAME}" >> $GITHUB_OUTPUT
echo "Found artifact: ${ARTIFACT}"
- name: Calculate checksum
id: checksum
working-directory: release-${{ github.run_id }}
run: |
CHECKSUM=$(md5sum "${{ steps.jprm.outputs.artifact }}" | awk '{print $1}')
echo "checksum=${CHECKSUM}" >> $GITHUB_OUTPUT
echo "Checksum: ${CHECKSUM}"
- name: Create Release
working-directory: release-${{ github.run_id }}
env:
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
@@ -79,22 +84,13 @@ jobs:
REPO_NAME="${{ github.event.repository.name }}"
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
VERSION="${{ steps.get_version.outputs.version }}"
RESPONSE=$(curl -s -w "\n%{http_code}" -X POST \
-H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/json" \
"${GITEA_URL}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}/releases" \
-d "{
\"tag_name\": \"${{ steps.get_version.outputs.version }}\",
\"name\": \"Release ${{ steps.get_version.outputs.version }}\",
\"body\": ${RELEASE_BODY_JSON},
\"draft\": false,
\"prerelease\": false
}")
-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}')")
HTTP_CODE=$(echo "$RESPONSE" | tail -n1)
BODY=$(echo "$RESPONSE" | sed '$d')
@@ -126,3 +122,43 @@ jobs:
echo "✅ Release created successfully!"
echo "View at: ${GITEA_URL}/${REPO_OWNER}/${REPO_NAME}/releases/tag/${{ steps.get_version.outputs.version }}"
- name: Update manifest.json
working-directory: release-${{ github.run_id }}
env:
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
REPO_OWNER="${{ github.repository_owner }}"
REPO_NAME="${{ github.event.repository.name }}"
GITEA_URL="${{ github.server_url }}"
VERSION="${{ steps.get_version.outputs.version_number }}"
CHECKSUM="${{ steps.checksum.outputs.checksum }}"
ARTIFACT_NAME="${{ steps.jprm.outputs.artifact_name }}"
TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
DOWNLOAD_URL="${GITEA_URL}/${REPO_OWNER}/${REPO_NAME}/releases/download/${{ steps.get_version.outputs.version }}/${ARTIFACT_NAME}"
git config user.name "Gitea Actions"
git config user.email "actions@gitea.tourolle.paris"
git fetch origin master
git checkout master
NEW_VERSION=$(cat <<EOF
{
"version": "${VERSION}",
"changelog": "Release ${VERSION}",
"targetAbi": "10.9.0.0",
"sourceUrl": "${DOWNLOAD_URL}",
"checksum": "${CHECKSUM}",
"timestamp": "${TIMESTAMP}"
}
EOF
)
jq --argjson newver "${NEW_VERSION}" '.[0].versions = [$newver] + .[0].versions' manifest.json > manifest.tmp && mv manifest.tmp manifest.json
git add manifest.json
git commit -m "Update manifest.json for version ${VERSION}"
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:
test:
runs-on: ubuntu-latest
runs-on: linux/amd64
container:
image: gitea.tourolle.paris/dtourolle/srfplay-builder:latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Verify .NET installation
run: dotnet --version
with:
path: test-${{ github.run_id }}
- name: Restore dependencies
working-directory: test-${{ github.run_id }}
run: dotnet restore Jellyfin.Plugin.SRFPlay.sln
- 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
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"
- name: Upload test results
@@ -40,5 +44,9 @@ jobs:
uses: actions/upload-artifact@v3
with:
name: test-results
path: '**/test-results.trx'
path: test-${{ github.run_id }}/**/test-results.trx
retention-days: 7
- name: Cleanup
if: always()
run: rm -rf test-${{ github.run_id }}
+1
View File
@@ -3,3 +3,4 @@ obj/
.vs/
.idea/
artifacts
*.log
+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
+10 -1
View File
@@ -12,6 +12,14 @@ using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.SRFPlay.Tests
{
/// <summary>
/// Simple IHttpClientFactory implementation for tests.
/// </summary>
internal sealed class TestHttpClientFactory : IHttpClientFactory
{
public HttpClient CreateClient(string name) => new HttpClient();
}
class Program
{
static async Task Main(string[] args)
@@ -33,8 +41,9 @@ namespace Jellyfin.Plugin.SRFPlay.Tests
builder.SetMinimumLevel(LogLevel.Warning); // Only show warnings and errors
});
var httpClientFactory = new TestHttpClientFactory();
var apiClient = new SRFApiClient(loggerFactory);
var streamResolver = new StreamUrlResolver(loggerFactory.CreateLogger<StreamUrlResolver>());
var streamResolver = new StreamUrlResolver(loggerFactory.CreateLogger<StreamUrlResolver>(), httpClientFactory);
var cancellationToken = CancellationToken.None;
@@ -115,7 +115,7 @@ namespace Jellyfin.Plugin.SRFPlay.Tests
if (chapter.ResourceList != null && chapter.ResourceList.Any())
{
var hlsResource = chapter.ResourceList.FirstOrDefault(r =>
r.Protocol == "HLS" && (r.DrmList == null || r.DrmList.ToString() == "[]"));
r.Protocol == "HLS" && r.IsPlayable);
if (hlsResource != null)
{
@@ -0,0 +1,13 @@
namespace Jellyfin.Plugin.SRFPlay.Api;
/// <summary>
/// Factory interface for creating SRF API clients.
/// </summary>
public interface ISRFApiClientFactory
{
/// <summary>
/// Creates a new instance of the SRF API client.
/// </summary>
/// <returns>A new SRFApiClient instance.</returns>
SRFApiClient CreateClient();
}
@@ -74,7 +74,9 @@ public class Chapter
/// Gets or sets the list of available resources (streams).
/// </summary>
[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>
/// Gets or sets the episode number.
@@ -1,5 +1,4 @@
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Text.Json.Serialization;
namespace Jellyfin.Plugin.SRFPlay.Api.Models;
@@ -15,6 +14,12 @@ public class MediaComposition
[JsonPropertyName("chapterList")]
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>
/// Gets or sets the episode information.
/// </summary>
@@ -1,16 +0,0 @@
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace Jellyfin.Plugin.SRFPlay.Api.Models.PlayV3;
/// <summary>
/// TV Program Guide response from Play v3 API.
/// </summary>
public class PlayV3TvProgramGuideResponse
{
/// <summary>
/// Gets the list of TV program entries.
/// </summary>
[JsonPropertyName("data")]
public IReadOnlyList<PlayV3TvProgram>? Data { get; init; }
}
@@ -2,6 +2,9 @@ using System.Text.Json.Serialization;
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>
/// Represents a streaming resource (URL) in the SRF API response.
/// </summary>
@@ -48,4 +51,10 @@ public class Resource
/// </summary>
[JsonPropertyName("drmList")]
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() == "[]";
}
+57 -165
View File
@@ -2,12 +2,15 @@ using System;
using System.Globalization;
using System.Net;
using System.Net.Http;
using System.Text;
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Plugin.SRFPlay.Api.Models;
using Jellyfin.Plugin.SRFPlay.Api.Models.PlayV3;
using Jellyfin.Plugin.SRFPlay.Configuration;
using Jellyfin.Plugin.SRFPlay.Constants;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.SRFPlay.Api;
@@ -17,9 +20,7 @@ namespace Jellyfin.Plugin.SRFPlay.Api;
/// </summary>
public class SRFApiClient : IDisposable
{
private const string BaseUrl = "https://il.srgssr.ch/integrationlayer/2.0";
private const string PlayV3BaseUrlTemplate = "https://www.{0}.ch/play/v3/api/{0}/production/";
private static readonly System.Text.CompositeFormat PlayV3UrlFormat = System.Text.CompositeFormat.Parse(PlayV3BaseUrlTemplate);
private static readonly System.Text.CompositeFormat PlayV3UrlFormat = System.Text.CompositeFormat.Parse(ApiEndpoints.PlayV3BaseUrlTemplate);
private readonly HttpClient _httpClient;
private readonly HttpClient _playV3HttpClient;
private readonly ILogger _logger;
@@ -47,15 +48,28 @@ public class SRFApiClient : IDisposable
_logger.LogInformation("SRFApiClient initializing without proxy");
}
_httpClient = CreateHttpClient(BaseUrl);
_httpClient = CreateHttpClient(ApiEndpoints.IntegrationLayerBaseUrl);
_playV3HttpClient = CreateHttpClient(null);
_jsonOptions = new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true
PropertyNameCaseInsensitive = true,
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping
};
}
/// <summary>
/// Reads HTTP response content as UTF-8 string.
/// </summary>
/// <param name="content">The HTTP content to read.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>The content as a UTF-8 decoded string.</returns>
private async Task<string> ReadAsUtf8StringAsync(HttpContent content, CancellationToken cancellationToken = default)
{
var bytes = await content.ReadAsByteArrayAsync(cancellationToken).ConfigureAwait(false);
return Encoding.UTF8.GetString(bytes);
}
/// <summary>
/// Creates an HttpClient with optional proxy configuration.
/// </summary>
@@ -111,6 +125,7 @@ public class SRFApiClient : IDisposable
client.DefaultRequestHeaders.UserAgent.ParseAdd("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36");
client.DefaultRequestHeaders.Accept.ParseAdd("*/*");
client.DefaultRequestHeaders.AcceptLanguage.ParseAdd("en-US,en;q=0.9");
client.DefaultRequestHeaders.Add("Accept-Charset", "utf-8");
_logger.LogInformation(
"HttpClient created with HTTP/1.1 and headers - User-Agent: {UserAgent}, Accept: {Accept}, Accept-Language: {AcceptLanguage}",
@@ -136,58 +151,10 @@ public class SRFApiClient : IDisposable
{
try
{
var url = $"/mediaComposition/byUrn/{urn}.json";
var fullUrl = $"{BaseUrl}{url}";
_logger.LogInformation("Fetching media composition for URN: {Urn} from {Url}", urn, fullUrl);
var fullUrl = $"{ApiEndpoints.IntegrationLayerBaseUrl}/mediaComposition/byUrn/{urn}.json";
_logger.LogDebug("Fetching media composition for URN: {Urn} from {Url}", urn, fullUrl);
var response = await _httpClient.GetAsync(url, cancellationToken).ConfigureAwait(false);
// Log response headers to diagnose geo-blocking
var xLocation = response.Headers.Contains("x-location")
? string.Join(", ", response.Headers.GetValues("x-location"))
: "not present";
_logger.LogInformation(
"Media composition response for URN {Urn}: StatusCode={StatusCode}, x-location={XLocation}",
urn,
response.StatusCode,
xLocation);
// If HttpClient fails, try curl as fallback
if (!response.IsSuccessStatusCode)
{
_logger.LogWarning("HttpClient failed with {StatusCode}, trying curl fallback", response.StatusCode);
return await FetchWithCurlAsync(fullUrl, cancellationToken).ConfigureAwait(false);
}
response.EnsureSuccessStatusCode();
var content = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
var result = JsonSerializer.Deserialize<MediaComposition>(content, _jsonOptions);
if (result?.ChapterList != null && result.ChapterList.Count > 0)
{
_logger.LogInformation(
"Successfully fetched media composition for URN: {Urn} - Chapters: {ChapterCount}",
urn,
result.ChapterList.Count);
}
else
{
_logger.LogWarning("Media composition for URN {Urn} has no chapters", urn);
}
return result;
}
catch (HttpRequestException ex)
{
_logger.LogError(
ex,
"HTTP error fetching media composition for URN: {Urn} - StatusCode: {StatusCode}, trying curl fallback",
urn,
ex.StatusCode);
var fullUrl = $"{BaseUrl}/mediaComposition/byUrn/{urn}.json";
// Use curl - HttpClient returns 404 due to server routing/network configuration
return await FetchWithCurlAsync(fullUrl, cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
@@ -233,7 +200,8 @@ public class SRFApiClient : IDisposable
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true
CreateNoWindow = true,
StandardOutputEncoding = Encoding.UTF8
};
using var process = new System.Diagnostics.Process { StartInfo = processStartInfo };
@@ -260,7 +228,7 @@ public class SRFApiClient : IDisposable
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);
}
@@ -280,38 +248,8 @@ public class SRFApiClient : IDisposable
/// <param name="businessUnit">The business unit (e.g., srf, rts).</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>The media composition containing latest videos.</returns>
public async Task<MediaComposition?> GetLatestVideosAsync(string businessUnit, CancellationToken cancellationToken = default)
{
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 response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
_logger.LogError("API returned error {StatusCode}: {Error}", response.StatusCode, errorContent);
return null;
}
var content = await response.Content.ReadAsStringAsync(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;
}
}
public Task<MediaComposition?> GetLatestVideosAsync(string businessUnit, CancellationToken cancellationToken = default)
=> GetMediaCompositionListAsync(businessUnit, "latest", cancellationToken);
/// <summary>
/// Gets the trending videos for a business unit.
@@ -319,60 +257,38 @@ public class SRFApiClient : IDisposable
/// <param name="businessUnit">The business unit (e.g., srf, rts).</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <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
{
var url = $"/video/{businessUnit}/trending.json";
_logger.LogInformation("Fetching trending videos for business unit: {BusinessUnit} from URL: {Url}", businessUnit, url);
var url = $"/video/{businessUnit}/{endpoint}.json";
_logger.LogInformation("Fetching {Endpoint} videos for business unit: {BusinessUnit} from URL: {Url}", endpoint, businessUnit, url);
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)
{
var errorContent = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
var errorContent = await ReadAsUtf8StringAsync(response.Content, cancellationToken).ConfigureAwait(false);
_logger.LogError("API returned error {StatusCode}: {Error}", response.StatusCode, errorContent);
return null;
}
var content = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
_logger.LogDebug("Trending videos response length: {Length}", content.Length);
var content = await ReadAsUtf8StringAsync(response.Content, cancellationToken).ConfigureAwait(false);
_logger.LogDebug("{Endpoint} videos response length: {Length}", endpoint, content.Length);
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;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error fetching trending videos for business unit: {BusinessUnit}", 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 response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
return content;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error fetching JSON from URL: {Url}", url);
_logger.LogError(ex, "Error fetching {Endpoint} videos for business unit: {BusinessUnit}", endpoint, businessUnit);
return null;
}
}
@@ -383,35 +299,8 @@ public class SRFApiClient : IDisposable
/// <param name="businessUnit">The business unit (e.g., srf, rts).</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>List of shows.</returns>
public async Task<System.Collections.Generic.List<PlayV3Show>?> GetAllShowsAsync(string businessUnit, CancellationToken cancellationToken = default)
{
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 response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
_logger.LogError("API returned error {StatusCode}: {Error}", response.StatusCode, errorContent);
return null;
}
var content = await response.Content.ReadAsStringAsync(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;
}
}
public Task<System.Collections.Generic.List<PlayV3Show>?> GetAllShowsAsync(string businessUnit, CancellationToken cancellationToken = default)
=> GetPlayV3DirectListAsync<PlayV3Show>(businessUnit, "shows", cancellationToken);
/// <summary>
/// Gets all topics from the Play v3 API.
@@ -419,32 +308,35 @@ public class SRFApiClient : IDisposable
/// <param name="businessUnit">The business unit (e.g., srf, rts).</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <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
{
var baseUrl = string.Format(CultureInfo.InvariantCulture, PlayV3UrlFormat, businessUnit);
var url = $"{baseUrl}topics";
_logger.LogInformation("Fetching all topics for business unit: {BusinessUnit} from URL: {Url}", businessUnit, url);
var url = $"{baseUrl}{endpoint}";
_logger.LogInformation("Fetching all {Endpoint} for business unit: {BusinessUnit} from URL: {Url}", endpoint, businessUnit, url);
var response = await _playV3HttpClient.GetAsync(url, cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
var errorContent = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
var errorContent = await ReadAsUtf8StringAsync(response.Content, cancellationToken).ConfigureAwait(false);
_logger.LogError("API returned error {StatusCode}: {Error}", response.StatusCode, errorContent);
return null;
}
var content = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
var result = JsonSerializer.Deserialize<PlayV3DirectResponse<PlayV3Topic>>(content, _jsonOptions);
var content = await ReadAsUtf8StringAsync(response.Content, cancellationToken).ConfigureAwait(false);
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;
}
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;
}
}
@@ -468,12 +360,12 @@ public class SRFApiClient : IDisposable
if (!response.IsSuccessStatusCode)
{
var errorContent = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
var errorContent = await ReadAsUtf8StringAsync(response.Content, cancellationToken).ConfigureAwait(false);
_logger.LogError("API returned error {StatusCode}: {Error}", response.StatusCode, errorContent);
return null;
}
var content = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
var content = await ReadAsUtf8StringAsync(response.Content, cancellationToken).ConfigureAwait(false);
var result = JsonSerializer.Deserialize<PlayV3Response<PlayV3Video>>(content, _jsonOptions);
_logger.LogDebug("Successfully fetched {Count} videos for show {ShowId}", result?.Data?.Data?.Count ?? 0, showId);
@@ -508,12 +400,12 @@ public class SRFApiClient : IDisposable
if (!response.IsSuccessStatusCode)
{
var errorContent = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
var errorContent = await ReadAsUtf8StringAsync(response.Content, cancellationToken).ConfigureAwait(false);
_logger.LogError("API returned error {StatusCode}: {Error}", response.StatusCode, errorContent);
return null;
}
var content = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
var content = await ReadAsUtf8StringAsync(response.Content, cancellationToken).ConfigureAwait(false);
// The response structure is: { "data": { "scheduledLivestreams": [...] } }
var jsonDoc = JsonSerializer.Deserialize<JsonElement>(content, _jsonOptions);
@@ -0,0 +1,26 @@
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.SRFPlay.Api;
/// <summary>
/// Factory for creating SRF API clients.
/// </summary>
public class SRFApiClientFactory : ISRFApiClientFactory
{
private readonly ILoggerFactory _loggerFactory;
/// <summary>
/// Initializes a new instance of the <see cref="SRFApiClientFactory"/> class.
/// </summary>
/// <param name="loggerFactory">The logger factory.</param>
public SRFApiClientFactory(ILoggerFactory loggerFactory)
{
_loggerFactory = loggerFactory;
}
/// <inheritdoc />
public SRFApiClient CreateClient()
{
return new SRFApiClient(_loggerFactory);
}
}
+248 -189
View File
@@ -2,11 +2,14 @@ using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Plugin.SRFPlay.Services;
using Jellyfin.Plugin.SRFPlay.Api;
using Jellyfin.Plugin.SRFPlay.Constants;
using Jellyfin.Plugin.SRFPlay.Services.Interfaces;
using Jellyfin.Plugin.SRFPlay.Utilities;
using MediaBrowser.Controller;
using MediaBrowser.Controller.Channels;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.Channels;
@@ -22,10 +25,11 @@ namespace Jellyfin.Plugin.SRFPlay.Channels;
public class SRFPlayChannel : IChannel, IHasCacheKey
{
private readonly ILogger<SRFPlayChannel> _logger;
private readonly ILoggerFactory _loggerFactory;
private readonly ContentRefreshService _contentRefreshService;
private readonly StreamUrlResolver _streamResolver;
private readonly CategoryService? _categoryService;
private readonly IContentRefreshService _contentRefreshService;
private readonly IStreamUrlResolver _streamResolver;
private readonly IMediaSourceFactory _mediaSourceFactory;
private readonly ICategoryService? _categoryService;
private readonly ISRFApiClientFactory _apiClientFactory;
/// <summary>
/// Initializes a new instance of the <see cref="SRFPlayChannel"/> class.
@@ -33,25 +37,30 @@ public class SRFPlayChannel : IChannel, IHasCacheKey
/// <param name="loggerFactory">The logger factory.</param>
/// <param name="contentRefreshService">The content refresh service.</param>
/// <param name="streamResolver">The stream resolver.</param>
/// <param name="mediaSourceFactory">The media source factory.</param>
/// <param name="categoryService">The category service (optional).</param>
/// <param name="apiClientFactory">The API client factory.</param>
public SRFPlayChannel(
ILoggerFactory loggerFactory,
ContentRefreshService contentRefreshService,
StreamUrlResolver streamResolver,
CategoryService? categoryService = null)
IContentRefreshService contentRefreshService,
IStreamUrlResolver streamResolver,
IMediaSourceFactory mediaSourceFactory,
ICategoryService? categoryService,
ISRFApiClientFactory apiClientFactory)
{
_loggerFactory = loggerFactory;
_logger = loggerFactory.CreateLogger<SRFPlayChannel>();
_contentRefreshService = contentRefreshService;
_streamResolver = streamResolver;
_mediaSourceFactory = mediaSourceFactory;
_categoryService = categoryService;
_apiClientFactory = apiClientFactory;
if (_categoryService == null)
{
_logger.LogWarning("CategoryService not available - category folders will be disabled");
}
_logger.LogInformation("=== SRFPlayChannel constructor called! Channel is being instantiated ===");
_logger.LogDebug("SRFPlayChannel initialized");
}
/// <inheritdoc />
@@ -61,10 +70,10 @@ public class SRFPlayChannel : IChannel, IHasCacheKey
public string Description => "Swiss Radio and Television video-on-demand content";
/// <inheritdoc />
public string DataVersion => "1.1";
public string DataVersion => "2.0"; // Back to authenticating at channel refresh with auto-refresh for fresh tokens
/// <inheritdoc />
public string HomePageUrl => "https://www.srf.ch/play";
public string HomePageUrl => ApiEndpoints.SrfPlayHomepage;
/// <inheritdoc />
public ChannelParentalRating ParentalRating => ChannelParentalRating.GeneralAudience;
@@ -72,7 +81,7 @@ public class SRFPlayChannel : IChannel, IHasCacheKey
/// <inheritdoc />
public InternalChannelFeatures GetChannelFeatures()
{
_logger.LogInformation("=== GetChannelFeatures called for SRF Play channel ===");
_logger.LogDebug("GetChannelFeatures called");
return new InternalChannelFeatures
{
@@ -98,10 +107,19 @@ public class SRFPlayChannel : IChannel, IHasCacheKey
/// <inheritdoc />
public Task<DynamicImageResponse> GetChannelImage(ImageType type, CancellationToken cancellationToken)
{
// Could provide a channel logo here
var assembly = GetType().Assembly;
var resourceStream = assembly.GetManifestResourceStream("Jellyfin.Plugin.SRFPlay.Images.logo.png");
if (resourceStream == null)
{
return Task.FromResult(new DynamicImageResponse { HasImage = false });
}
return Task.FromResult(new DynamicImageResponse
{
HasImage = false
HasImage = true,
Stream = resourceStream,
Format = MediaBrowser.Model.Drawing.ImageFormat.Png
});
}
@@ -118,68 +136,73 @@ public class SRFPlayChannel : IChannel, IHasCacheKey
/// <inheritdoc />
public async Task<ChannelItemResult> GetChannelItems(InternalChannelItemQuery query, CancellationToken cancellationToken)
{
_logger.LogInformation("=== GetChannelItems called! FolderId: {FolderId} ===", query.FolderId);
var items = new List<ChannelItemInfo>();
var config = Plugin.Instance?.Configuration;
_logger.LogDebug("GetChannelItems called for folder {FolderId}", query.FolderId);
try
{
// Root level - show categories
if (string.IsNullOrEmpty(query.FolderId))
{
items.Add(new ChannelItemInfo
{
Id = "latest",
Name = "Latest Videos",
Type = ChannelItemType.Folder,
FolderType = ChannelFolderType.Container,
ImageUrl = null
});
var items = await GetFolderItemsAsync(query.FolderId, cancellationToken).ConfigureAwait(false);
_logger.LogDebug("Returning {Count} channel items for folder {FolderId}", items.Count, query.FolderId);
items.Add(new ChannelItemInfo
return new ChannelItemResult
{
Id = "trending",
Name = "Trending Videos",
Type = ChannelItemType.Folder,
FolderType = ChannelFolderType.Container,
ImageUrl = null
});
items.Add(new ChannelItemInfo
Items = items,
TotalRecordCount = items.Count
};
}
catch (Exception ex)
{
Id = "live_sports",
Name = "Live Sports & Events",
Type = ChannelItemType.Folder,
FolderType = ChannelFolderType.Container,
ImageUrl = null
});
_logger.LogError(ex, "Error getting channel items for folder {FolderId}", query.FolderId);
return new ChannelItemResult { Items = new List<ChannelItemInfo>(), TotalRecordCount = 0 };
}
}
// Add category folders if enabled and CategoryService is available
private async Task<List<ChannelItemInfo>> GetFolderItemsAsync(string? folderId, CancellationToken cancellationToken)
{
// Root level - show folder list
if (string.IsNullOrEmpty(folderId))
{
return await GetRootFoldersAsync(cancellationToken).ConfigureAwait(false);
}
// Handle known folder types
return folderId switch
{
"latest" => await GetLatestVideosAsync(cancellationToken).ConfigureAwait(false),
"trending" => await GetTrendingVideosAsync(cancellationToken).ConfigureAwait(false),
"live_sports" => await GetLiveSportsAsync(cancellationToken).ConfigureAwait(false),
_ when folderId.StartsWith("category_", StringComparison.Ordinal) => await GetCategoryVideosAsync(folderId, cancellationToken).ConfigureAwait(false),
_ => new List<ChannelItemInfo>()
};
}
private async Task<List<ChannelItemInfo>> GetRootFoldersAsync(CancellationToken cancellationToken)
{
var items = new List<ChannelItemInfo>
{
CreateFolder("latest", "Latest Videos"),
CreateFolder("trending", "Trending Videos"),
CreateFolder("live_sports", "Live Sports & Events")
};
// Add category folders if enabled
var config = Plugin.Instance?.Configuration;
if (config?.EnableCategoryFolders == true && _categoryService != null)
{
try
{
var businessUnit = config.BusinessUnit.ToString().ToLowerInvariant();
var businessUnit = config.BusinessUnit.ToLowerString();
var topics = await _categoryService.GetTopicsAsync(businessUnit, cancellationToken).ConfigureAwait(false);
foreach (var topic in topics.Where(t => !string.IsNullOrEmpty(t.Id)))
{
// Filter by enabled topics if configured
if (config.EnabledTopics != null && config.EnabledTopics.Count > 0 && !config.EnabledTopics.Contains(topic.Id!))
{
continue;
}
items.Add(new ChannelItemInfo
{
Id = $"category_{topic.Id}",
Name = topic.Title ?? topic.Id!,
Type = ChannelItemType.Folder,
FolderType = ChannelFolderType.Container,
ImageUrl = null,
Overview = 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);
@@ -190,40 +213,52 @@ public class SRFPlayChannel : IChannel, IHasCacheKey
}
}
return new ChannelItemResult
return items;
}
private static ChannelItemInfo CreateFolder(string id, string name, string? overview = null, string? imageUrl = null)
{
Items = items,
TotalRecordCount = items.Count
return new ChannelItemInfo
{
Id = id,
Name = name,
Type = ChannelItemType.Folder,
FolderType = ChannelFolderType.Container,
ImageUrl = imageUrl,
Overview = overview
};
}
// Latest videos
if (query.FolderId == "latest")
private async Task<List<ChannelItemInfo>> GetLatestVideosAsync(CancellationToken cancellationToken)
{
var urns = await _contentRefreshService.RefreshLatestContentAsync(cancellationToken).ConfigureAwait(false);
items = await ConvertUrnsToChannelItems(urns, cancellationToken).ConfigureAwait(false);
return await ConvertUrnsToChannelItems(urns, cancellationToken).ConfigureAwait(false);
}
// Trending videos
else if (query.FolderId == "trending")
private async Task<List<ChannelItemInfo>> GetTrendingVideosAsync(CancellationToken cancellationToken)
{
var urns = await _contentRefreshService.RefreshTrendingContentAsync(cancellationToken).ConfigureAwait(false);
items = await ConvertUrnsToChannelItems(urns, cancellationToken).ConfigureAwait(false);
return await ConvertUrnsToChannelItems(urns, cancellationToken).ConfigureAwait(false);
}
// Live Sports & Events
else if (query.FolderId == "live_sports")
private async Task<List<ChannelItemInfo>> GetLiveSportsAsync(CancellationToken cancellationToken)
{
var items = new List<ChannelItemInfo>();
var config = Plugin.Instance?.Configuration;
try
{
var businessUnit = config?.BusinessUnit.ToString().ToLowerInvariant() ?? "srf";
var businessUnit = config?.BusinessUnit.ToLowerString() ?? "srf";
using var apiClient = new Api.SRFApiClient(_loggerFactory);
using var apiClient = _apiClientFactory.CreateClient();
var scheduledLivestreams = await apiClient.GetScheduledLivestreamsAsync(businessUnit, "SPORT", cancellationToken).ConfigureAwait(false);
if (scheduledLivestreams != null)
if (scheduledLivestreams == null)
{
// Filter for upcoming/current events (within next 7 days) that have URNs
return items;
}
// Filter for upcoming/current events (within next 7 days)
var now = DateTime.UtcNow;
var weekFromNow = now.AddDays(7);
@@ -253,95 +288,54 @@ public class SRFPlayChannel : IChannel, IHasCacheKey
}
}
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to load live sports events");
}
return items;
}
// Category folder - show videos for this category
else if (query.FolderId?.StartsWith("category_", StringComparison.Ordinal) == true)
private async Task<List<ChannelItemInfo>> GetCategoryVideosAsync(string folderId, CancellationToken cancellationToken)
{
var items = new List<ChannelItemInfo>();
if (_categoryService == null)
{
_logger.LogWarning("CategoryService not available - cannot display category folder");
return items;
}
else
{
try
{
var topicId = query.FolderId.Substring("category_".Length);
var businessUnit = config?.BusinessUnit.ToString().ToLowerInvariant() ?? "srf";
var config = Plugin.Instance?.Configuration;
var topicId = folderId.Substring("category_".Length);
var businessUnit = config?.BusinessUnit.ToLowerString() ?? "srf";
var shows = await _categoryService.GetShowsByTopicAsync(topicId, businessUnit, 20, cancellationToken).ConfigureAwait(false);
var urns = new List<string>();
using var apiClient = new Api.SRFApiClient(_loggerFactory);
using var apiClient = _apiClientFactory.CreateClient();
foreach (var show in shows)
{
if (show.Id == null)
if (show.Id == null || cancellationToken.IsCancellationRequested)
{
continue;
}
try
{
var videos = await apiClient.GetVideosForShowAsync(businessUnit, show.Id, cancellationToken).ConfigureAwait(false);
if (videos != null && videos.Count > 0)
var latestUrn = await GetLatestVideoUrnForShowAsync(apiClient, businessUnit, show, topicId, cancellationToken).ConfigureAwait(false);
if (latestUrn != null)
{
_logger.LogDebug("Category {TopicId}, Show {Show} ({ShowId}): Found {Count} videos", topicId, show.Title, show.Id, videos.Count);
// Filter to videos that are actually published and not expired
var now = DateTime.UtcNow;
var availableVideos = videos.Where(v =>
(v.ValidFrom == null || v.ValidFrom.Value.ToUniversalTime() <= now) &&
(v.ValidTo == null || v.ValidTo.Value.ToUniversalTime() > now)).ToList();
_logger.LogDebug("Category {TopicId}, Show {Show}: {AvailableCount} available out of {TotalCount} videos", topicId, show.Title, availableVideos.Count, videos.Count);
if (availableVideos.Count > 0)
{
// Get most recent available video from this show
var latestVideo = availableVideos.OrderByDescending(v => v.Date).FirstOrDefault();
if (latestVideo?.Urn != null)
{
urns.Add(latestVideo.Urn);
_logger.LogInformation(
"Category {TopicId}: Added video from show {Show}: {Title} (URN: {Urn}, Date: {Date}, ValidFrom: {ValidFrom}, ValidTo: {ValidTo})",
topicId,
show.Title,
latestVideo.Title,
latestVideo.Urn,
latestVideo.Date,
latestVideo.ValidFrom,
latestVideo.ValidTo);
}
else
{
_logger.LogWarning("Category {TopicId}, Show {Show}: Latest available video has null URN", topicId, show.Title);
}
}
else
{
_logger.LogDebug("Category {TopicId}, Show {Show}: No available videos (all expired or not yet published)", topicId, show.Title);
}
}
else
{
_logger.LogDebug("Category {TopicId}, Show {Show} ({ShowId}): No videos returned from API", topicId, show.Title, show.Id);
urns.Add(latestUrn);
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Error fetching videos for show {ShowId} in category {TopicId}", show.Id, topicId);
}
if (cancellationToken.IsCancellationRequested)
{
break;
}
}
items = await ConvertUrnsToChannelItems(urns, cancellationToken).ConfigureAwait(false);
@@ -351,21 +345,56 @@ public class SRFPlayChannel : IChannel, IHasCacheKey
{
_logger.LogError(ex, "Failed to load category videos");
}
}
return items;
}
_logger.LogInformation("Returning {Count} channel items for folder {FolderId}", items.Count, query.FolderId);
}
catch (Exception ex)
private async Task<string?> GetLatestVideoUrnForShowAsync(
Api.SRFApiClient apiClient,
string businessUnit,
Api.Models.PlayV3Show show,
string topicId,
CancellationToken cancellationToken)
{
_logger.LogError(ex, "Error getting channel items for folder {FolderId}", query.FolderId);
var videos = await apiClient.GetVideosForShowAsync(businessUnit, show.Id!, cancellationToken).ConfigureAwait(false);
if (videos == null || videos.Count == 0)
{
_logger.LogDebug("Category {TopicId}, Show {Show} ({ShowId}): No videos returned from API", topicId, show.Title, show.Id);
return null;
}
return new ChannelItemResult
_logger.LogDebug("Category {TopicId}, Show {Show} ({ShowId}): Found {Count} videos", topicId, show.Title, show.Id, videos.Count);
// Filter to available videos
var now = DateTime.UtcNow;
var availableVideos = videos.Where(v =>
(v.ValidFrom == null || v.ValidFrom.Value.ToUniversalTime() <= now) &&
(v.ValidTo == null || v.ValidTo.Value.ToUniversalTime() > now)).ToList();
_logger.LogDebug("Category {TopicId}, Show {Show}: {AvailableCount} available out of {TotalCount} videos", topicId, show.Title, availableVideos.Count, videos.Count);
if (availableVideos.Count == 0)
{
Items = items,
TotalRecordCount = items.Count
};
_logger.LogDebug("Category {TopicId}, Show {Show}: No available videos (all expired or not yet published)", topicId, show.Title);
return null;
}
var latestVideo = availableVideos.OrderByDescending(v => v.Date).FirstOrDefault();
if (latestVideo?.Urn == null)
{
_logger.LogWarning("Category {TopicId}, Show {Show}: Latest available video has null URN", topicId, show.Title);
return null;
}
_logger.LogInformation(
"Category {TopicId}: Added video from show {Show}: {Title} (URN: {Urn}, Date: {Date})",
topicId,
show.Title,
latestVideo.Title,
latestVideo.Urn,
latestVideo.Date);
return latestVideo.Urn;
}
/// <inheritdoc />
@@ -375,8 +404,14 @@ public class SRFPlayChannel : IChannel, IHasCacheKey
var enabledTopics = config?.EnabledTopics != null && config.EnabledTopics.Count > 0
? string.Join(",", config.EnabledTopics)
: "all";
var date = DateTime.Now.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture);
return $"{config?.BusinessUnit}_{config?.EnableLatestContent}_{config?.EnableTrendingContent}_{config?.EnableCategoryFolders}_{enabledTopics}_{date}";
// Use 15-minute time buckets for cache key so live content refreshes frequently
// This ensures livestream folders update as programs start/end throughout the day
var now = DateTime.Now;
var timeBucket = new DateTime(now.Year, now.Month, now.Day, now.Hour, (now.Minute / 15) * 15, 0);
var timeKey = timeBucket.ToString("yyyy-MM-dd-HH-mm", CultureInfo.InvariantCulture);
return $"{config?.BusinessUnit}_{config?.EnableLatestContent}_{config?.EnableTrendingContent}_{config?.EnableCategoryFolders}_{enabledTopics}_{timeKey}";
}
private async Task<List<ChannelItemInfo>> ConvertUrnsToChannelItems(List<string> urns, CancellationToken cancellationToken)
@@ -392,7 +427,7 @@ public class SRFPlayChannel : IChannel, IHasCacheKey
_logger.LogInformation("Converting {Count} URNs to channel items", urns.Count);
using var apiClient = new Api.SRFApiClient(_loggerFactory);
using var apiClient = _apiClientFactory.CreateClient();
int successCount = 0;
int failedCount = 0;
int expiredCount = 0;
@@ -405,7 +440,7 @@ public class SRFPlayChannel : IChannel, IHasCacheKey
_logger.LogDebug("Processing URN: {Urn}", urn);
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);
failedCount++;
@@ -431,53 +466,77 @@ public class SRFPlayChannel : IChannel, IHasCacheKey
}
// Generate deterministic GUID from URN
var itemId = UrnToGuid(urn);
var itemId = UrnHelper.ToGuid(urn);
// Get stream URL (unauthenticated - token will be added at playback time by SRFMediaProvider)
var streamUrl = _streamResolver.GetStreamUrl(chapter, config.QualityPreference);
// Use factory to create MediaSourceInfo (handles stream URL, auth, proxy registration)
var mediaSource = await _mediaSourceFactory.CreateMediaSourceAsync(
chapter,
itemId,
urn,
config.QualityPreference,
cancellationToken).ConfigureAwait(false);
// Skip scheduled livestreams that haven't started yet (no stream URL available)
if (chapter.Type == "SCHEDULED_LIVESTREAM" && string.IsNullOrEmpty(streamUrl))
// Skip items without a valid media source (no stream URL available)
if (mediaSource == null)
{
if (chapter.Type == "SCHEDULED_LIVESTREAM")
{
_logger.LogDebug(
"URN {Urn}: Skipping upcoming livestream '{Title}' - stream not yet available (starts at {ValidFrom})",
urn,
chapter.Title,
chapter.ValidFrom);
continue;
}
// Skip items without a valid stream URL
if (string.IsNullOrEmpty(streamUrl))
else
{
_logger.LogWarning(
"URN {Urn}: Skipping '{Title}' - no valid stream URL available",
urn,
chapter.Title);
noStreamCount++;
}
continue;
}
// Build overview
var overview = chapter.Description ?? chapter.Lead;
// Get image URL - prefer chapter image, fall back to show image if available
var imageUrl = chapter.ImageUrl;
if (string.IsNullOrEmpty(imageUrl) && mediaComposition.Show != null)
// Determine image URL based on configuration
string? imageUrl;
var generateTitleCards = config.GenerateTitleCards;
if (generateTitleCards)
{
imageUrl = mediaComposition.Show.ImageUrl;
// Generate title card with content name
_logger.LogDebug("URN {Urn}: Generating title card for '{Title}'", urn, chapter.Title);
imageUrl = CreatePlaceholderImageUrl(chapter.Title ?? "SRF Play", _mediaSourceFactory.GetServerBaseUrl());
}
else
{
// Use SRF-provided images - prefer chapter image, fall back to show image
var originalImageUrl = chapter.ImageUrl;
if (string.IsNullOrEmpty(originalImageUrl) && mediaComposition.Show != null)
{
originalImageUrl = mediaComposition.Show.ImageUrl;
_logger.LogDebug("URN {Urn}: Using show image as fallback", urn);
}
if (string.IsNullOrEmpty(imageUrl))
if (string.IsNullOrEmpty(originalImageUrl))
{
_logger.LogWarning("URN {Urn}: No image URL available for '{Title}'", urn, chapter.Title);
_logger.LogDebug("URN {Urn}: No image URL available for '{Title}', using placeholder", urn, chapter.Title);
imageUrl = CreatePlaceholderImageUrl(chapter.Title ?? "SRF Play", _mediaSourceFactory.GetServerBaseUrl());
}
else
{
imageUrl = CreateProxiedImageUrl(originalImageUrl, _mediaSourceFactory.GetServerBaseUrl());
}
}
// Use ValidFrom for premiere date if this is a scheduled livestream, otherwise use Date
var premiereDate = chapter.Type == "SCHEDULED_LIVESTREAM" ? chapter.ValidFrom?.ToUniversalTime() : chapter.Date?.ToUniversalTime();
// Store unauthenticated URL as placeholder - SRFMediaProvider will provide authenticated version at playback
// Store authenticated URL - tokens refresh automatically via scheduled channel scans
var item = new ChannelItemInfo
{
Id = itemId,
@@ -495,23 +554,7 @@ public class SRFPlayChannel : IChannel, IHasCacheKey
{
{ "SRF", urn }
},
MediaSources = new List<MediaSourceInfo>
{
new MediaSourceInfo
{
Id = itemId,
Name = chapter.Title,
Path = streamUrl, // Unauthenticated URL - placeholder only
Protocol = MediaBrowser.Model.MediaInfo.MediaProtocol.Http,
Container = "m3u8",
SupportsDirectPlay = false, // Disable direct play - requires auth from provider
SupportsDirectStream = false, // Disable direct stream - requires auth from provider
SupportsTranscoding = false, // Force use of IMediaSourceProvider
IsRemote = true,
Type = MediaBrowser.Model.Dto.MediaSourceType.Placeholder, // Mark as placeholder
VideoType = VideoType.VideoFile
}
}
MediaSources = new List<MediaSourceInfo> { mediaSource }
};
// Add series info if available
@@ -523,6 +566,11 @@ public class SRFPlayChannel : IChannel, IHasCacheKey
items.Add(item);
successCount++;
_logger.LogInformation("URN {Urn}: Successfully converted to channel item - {Title}", urn, chapter.Title);
_logger.LogDebug(
"URN {Urn}: MediaSource created via factory - DirectPlay={DirectPlay}, Transcoding={Transcoding}",
urn,
mediaSource.SupportsDirectPlay,
mediaSource.SupportsTranscoding);
}
catch (Exception ex)
{
@@ -542,23 +590,34 @@ public class SRFPlayChannel : IChannel, IHasCacheKey
}
/// <summary>
/// Generates a deterministic GUID from a URN.
/// This ensures the same URN always produces the same GUID.
/// MD5 is used for non-cryptographic purposes only (generating IDs).
/// Creates a proxy URL for an image to fix Content-Type headers from SRF CDN.
/// </summary>
/// <param name="urn">The URN to convert.</param>
/// <returns>A deterministic GUID.</returns>
#pragma warning disable CA5351 // MD5 is used for non-cryptographic purposes (ID generation)
private static string UrnToGuid(string urn)
/// <param name="originalUrl">The original SRF image URL.</param>
/// <param name="serverUrl">The server base URL.</param>
/// <returns>The proxied image URL, or null if original is null.</returns>
private static string? CreateProxiedImageUrl(string? originalUrl, string serverUrl)
{
// Use MD5 to generate a deterministic hash from the URN
var hash = MD5.HashData(Encoding.UTF8.GetBytes(urn));
// Convert the first 16 bytes to a GUID
var guid = new Guid(hash);
return guid.ToString();
if (string.IsNullOrEmpty(originalUrl))
{
return null;
}
// Encode the original URL as base64 for safe transport
var encodedUrl = Convert.ToBase64String(Encoding.UTF8.GetBytes(originalUrl));
return $"{serverUrl}/Plugins/SRFPlay/Proxy/Image?url={encodedUrl}";
}
/// <summary>
/// Creates a placeholder image URL with the given text.
/// </summary>
/// <param name="text">The text to display on the placeholder.</param>
/// <param name="serverUrl">The server base URL.</param>
/// <returns>The placeholder image URL.</returns>
private static string CreatePlaceholderImageUrl(string text, string serverUrl)
{
var encodedText = Convert.ToBase64String(Encoding.UTF8.GetBytes(text));
return $"{serverUrl}/Plugins/SRFPlay/Proxy/Placeholder?text={encodedText}";
}
#pragma warning restore CA5351
/// <inheritdoc />
public bool IsEnabledFor(string userId)
@@ -74,6 +74,7 @@ public class PluginConfiguration : BasePluginConfiguration
EnableTrendingContent = true;
EnableCategoryFolders = true;
EnabledTopics = new System.Collections.Generic.List<string>();
GenerateTitleCards = true;
}
/// <summary>
@@ -142,4 +143,17 @@ public class PluginConfiguration : BasePluginConfiguration
[System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "CA2227:Collection properties should be read only", Justification = "Required for configuration serialization")]
[System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "CA1002:Do not expose generic lists", Justification = "Configuration DTO")]
public System.Collections.Generic.List<string> EnabledTopics { get; set; }
/// <summary>
/// Gets or sets the public/external server URL for remote clients (e.g., https://jellyfin.example.com:8920).
/// If not set, the plugin will use Jellyfin's GetSmartApiUrl() which may return local addresses.
/// This is important for Android and other remote clients to access streams.
/// </summary>
public string PublicServerUrl { get; set; } = string.Empty;
/// <summary>
/// Gets or sets a value indicating whether to generate title card images with the content title.
/// When enabled, generates custom thumbnails instead of using SRF-provided images.
/// </summary>
public bool GenerateTitleCards { get; set; }
}
@@ -58,6 +58,13 @@
</label>
<div class="fieldDescription">Automatically discover and add trending videos</div>
</div>
<div class="checkboxContainer checkboxContainer-withDescription">
<label class="emby-checkbox-label">
<input id="GenerateTitleCards" name="GenerateTitleCards" type="checkbox" is="emby-checkbox" />
<span>Generate Title Cards</span>
</label>
<div class="fieldDescription">Generate custom thumbnails with the content title instead of using SRF-provided images</div>
</div>
<br />
<h2>Proxy Settings</h2>
<div class="checkboxContainer checkboxContainer-withDescription">
@@ -82,6 +89,17 @@
<input id="ProxyPassword" name="ProxyPassword" type="password" is="emby-input" autocomplete="off" />
<div class="fieldDescription">Password for proxy authentication (leave empty if not required)</div>
</div>
<br />
<h2>Network Settings</h2>
<div class="inputContainer">
<label class="inputLabel inputLabelUnfocused" for="PublicServerUrl">Public Server URL (Optional)</label>
<input id="PublicServerUrl" name="PublicServerUrl" type="text" is="emby-input" placeholder="e.g., https://jellyfin.example.com:8920" />
<div class="fieldDescription">
The public/external URL for remote clients (Android, iOS, etc.) to access streaming proxy.
<br />If not set, the plugin will use Jellyfin's automatic URL detection which may return local addresses.
<br /><strong>Important for Android/remote playback!</strong>
</div>
</div>
<div>
<button is="emby-button" type="submit" class="raised button-submit block emby-button">
<span>Save</span>
@@ -106,10 +124,12 @@
document.querySelector('#CacheDurationMinutes').value = config.CacheDurationMinutes;
document.querySelector('#EnableLatestContent').checked = config.EnableLatestContent;
document.querySelector('#EnableTrendingContent').checked = config.EnableTrendingContent;
document.querySelector('#GenerateTitleCards').checked = config.GenerateTitleCards !== false;
document.querySelector('#UseProxy').checked = config.UseProxy || false;
document.querySelector('#ProxyAddress').value = config.ProxyAddress || '';
document.querySelector('#ProxyUsername').value = config.ProxyUsername || '';
document.querySelector('#ProxyPassword').value = config.ProxyPassword || '';
document.querySelector('#PublicServerUrl').value = config.PublicServerUrl || '';
Dashboard.hideLoadingMsg();
});
});
@@ -125,10 +145,12 @@
config.CacheDurationMinutes = parseInt(document.querySelector('#CacheDurationMinutes').value);
config.EnableLatestContent = document.querySelector('#EnableLatestContent').checked;
config.EnableTrendingContent = document.querySelector('#EnableTrendingContent').checked;
config.GenerateTitleCards = document.querySelector('#GenerateTitleCards').checked;
config.UseProxy = document.querySelector('#UseProxy').checked;
config.ProxyAddress = document.querySelector('#ProxyAddress').value;
config.ProxyUsername = document.querySelector('#ProxyUsername').value;
config.ProxyPassword = document.querySelector('#ProxyPassword').value;
config.PublicServerUrl = document.querySelector('#PublicServerUrl').value;
ApiClient.updatePluginConfiguration(SRFPlayConfig.pluginUniqueId, config).then(function (result) {
Dashboard.processPluginConfigurationUpdateResult(result);
});
@@ -0,0 +1,41 @@
namespace Jellyfin.Plugin.SRFPlay.Constants;
/// <summary>
/// Centralized API endpoints and URL templates used throughout the plugin.
/// </summary>
public static class ApiEndpoints
{
/// <summary>
/// SRG SSR Integration Layer API base URL.
/// Used for fetching media compositions, video metadata, and stream URLs.
/// </summary>
public const string IntegrationLayerBaseUrl = "https://il.srgssr.ch/integrationlayer/2.0";
/// <summary>
/// Play V3 API URL template. Format: {0} = business unit (srf, rts, rsi, rtr, swi).
/// Used for fetching shows, topics, and latest content.
/// </summary>
public const string PlayV3BaseUrlTemplate = "https://www.{0}.ch/play/v3/api/{0}/production/";
/// <summary>
/// Akamai token authentication endpoint.
/// Used to get hdnts tokens for stream authentication.
/// </summary>
public const string AkamaiTokenEndpoint = "https://tp.srgssr.ch/akahd/token";
/// <summary>
/// SRF Play homepage URL.
/// </summary>
public const string SrfPlayHomepage = "https://www.srf.ch/play";
/// <summary>
/// Base proxy route for stream proxying (relative to server root).
/// </summary>
public const string ProxyBasePath = "/Plugins/SRFPlay/Proxy";
/// <summary>
/// HLS master manifest route template (relative to server root).
/// Format: {0} = item ID.
/// </summary>
public const string ProxyMasterManifestPath = "/Plugins/SRFPlay/Proxy/{0}/master.m3u8";
}
@@ -0,0 +1,467 @@
using System;
using System.Globalization;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Plugin.SRFPlay.Services.Interfaces;
using Jellyfin.Plugin.SRFPlay.Utilities;
using MediaBrowser.Common.Net;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.SRFPlay.Controllers;
/// <summary>
/// Controller for proxying SRF Play streams.
/// </summary>
[ApiController]
[Route("Plugins/SRFPlay/Proxy")]
public class StreamProxyController : ControllerBase
{
private readonly ILogger<StreamProxyController> _logger;
private readonly IStreamProxyService _proxyService;
private readonly IHttpClientFactory _httpClientFactory;
/// <summary>
/// Initializes a new instance of the <see cref="StreamProxyController"/> class.
/// </summary>
/// <param name="logger">The logger.</param>
/// <param name="proxyService">The proxy service.</param>
/// <param name="httpClientFactory">The HTTP client factory.</param>
public StreamProxyController(
ILogger<StreamProxyController> logger,
IStreamProxyService proxyService,
IHttpClientFactory httpClientFactory)
{
_logger = logger;
_proxyService = proxyService;
_httpClientFactory = httpClientFactory;
}
/// <summary>
/// Adds CORS headers to allow cross-origin requests from hls.js in browsers.
/// </summary>
private void AddCorsHeaders()
{
Response.Headers["Access-Control-Allow-Origin"] = "*";
Response.Headers["Access-Control-Allow-Methods"] = "GET, HEAD, OPTIONS";
Response.Headers["Access-Control-Allow-Headers"] = "Content-Type, Range, Accept, Origin";
Response.Headers["Access-Control-Expose-Headers"] = "Content-Length, Content-Range, Accept-Ranges";
Response.Headers["Access-Control-Max-Age"] = "86400"; // Cache preflight for 24 hours
}
/// <summary>
/// Adds Cache-Control headers appropriate for the stream type.
/// Livestreams need frequent manifest refresh, VOD can be cached longer.
/// </summary>
/// <param name="itemId">The item ID to check.</param>
/// <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 isLiveStream = metadata?.IsLiveStream ?? false;
if (isLiveStream)
{
if (isVariantManifest)
{
// 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
{
// VOD manifests are static, can cache longer
Response.Headers["Cache-Control"] = "max-age=3600";
}
}
/// <summary>
/// Handles CORS preflight OPTIONS requests for all proxy endpoints.
/// </summary>
/// <returns>Empty response with CORS headers.</returns>
[HttpOptions("{itemId}/master.m3u8")]
[HttpOptions("{itemId}/{manifestPath}.m3u8")]
[HttpOptions("{itemId}/{*segmentPath}")]
[AllowAnonymous]
public IActionResult HandleOptions()
{
AddCorsHeaders();
return Ok();
}
/// <summary>
/// Proxies HLS master manifest requests.
/// </summary>
/// <param name="itemId">The item ID from URL path.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The HLS manifest with rewritten URLs.</returns>
[HttpGet("{itemId}/master.m3u8")]
[AllowAnonymous] // Allow anonymous since Jellyfin handles auth upstream
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetMasterManifest(
[FromRoute] string itemId,
CancellationToken cancellationToken)
{
AddCorsHeaders();
_logger.LogInformation("Proxy request for master manifest - Path ItemId: {PathItemId}, Query params: {QueryString}", itemId, Request.QueryString);
// Try to resolve the actual item ID (path ID might be a session ID during transcoding)
var actualItemId = ResolveItemId(itemId);
try
{
// Get the correct scheme (https if configured, otherwise use request scheme)
var scheme = GetProxyScheme();
// Build the base proxy URL for this item
// Preserve query parameters (token or itemId) from the original request
string baseProxyUrl;
if (Request.Query.TryGetValue("token", out var token) && !string.IsNullOrEmpty(token))
{
baseProxyUrl = $"{scheme}://{Request.Host}/Plugins/SRFPlay/Proxy/{itemId}?token={token}";
_logger.LogDebug("Using token-based proxy URL with token: {Token}", token.ToString());
}
else if (actualItemId != itemId)
{
// Legacy: If path ID differs from resolved ID, add itemId query parameter
baseProxyUrl = $"{scheme}://{Request.Host}/Plugins/SRFPlay/Proxy/{itemId}?itemId={actualItemId}";
_logger.LogInformation("Path itemId {PathId} differs from resolved itemId {ResolvedId}, adding query parameter", itemId, actualItemId);
}
else
{
// Simple case: no query parameters needed
baseProxyUrl = $"{scheme}://{Request.Host}/Plugins/SRFPlay/Proxy/{itemId}";
_logger.LogDebug("Path itemId matches resolved itemId: {ItemId}", itemId);
}
var manifestContent = await _proxyService.GetRewrittenManifestAsync(actualItemId, baseProxyUrl, cancellationToken).ConfigureAwait(false);
if (manifestContent == null)
{
_logger.LogWarning("Manifest not found for path itemId {PathItemId}, resolved itemId {ResolvedItemId} - stream may not be registered", itemId, actualItemId);
return NotFound();
}
// Set cache headers based on stream type (live vs VOD)
AddManifestCacheHeaders(actualItemId);
_logger.LogDebug("Returning master manifest for item {ItemId} ({Length} bytes)", itemId, manifestContent.Length);
return Content(manifestContent, "application/vnd.apple.mpegurl; charset=utf-8");
}
catch (Exception ex)
{
_logger.LogError(ex, "Error proxying master manifest for item {ItemId}", itemId);
return StatusCode(StatusCodes.Status500InternalServerError);
}
}
/// <summary>
/// Proxies HLS variant manifest requests.
/// </summary>
/// <param name="itemId">The item ID.</param>
/// <param name="manifestPath">The manifest path (e.g., "index_0_av").</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The HLS manifest with rewritten segment URLs.</returns>
[HttpGet("{itemId}/{manifestPath}.m3u8")]
[AllowAnonymous]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetVariantManifest(
[FromRoute] string itemId,
[FromRoute] string manifestPath,
CancellationToken cancellationToken)
{
AddCorsHeaders();
var fullPath = $"{manifestPath}.m3u8";
_logger.LogInformation("Proxy request for variant manifest - ItemId: {ItemId}, Path: {Path}", itemId, fullPath);
// Try to resolve the actual item ID
var actualItemId = ResolveItemId(itemId);
try
{
// Fetch the variant manifest as a segment
var queryString = Request.QueryString.HasValue ? Request.QueryString.Value : null;
var manifestData = await _proxyService.GetSegmentAsync(actualItemId, fullPath, queryString, cancellationToken).ConfigureAwait(false);
if (manifestData == null)
{
_logger.LogWarning("Variant manifest not found - ItemId: {ItemId}, Path: {Path}", itemId, fullPath);
return NotFound();
}
// Convert to string and rewrite URLs
var manifestContent = System.Text.Encoding.UTF8.GetString(manifestData);
var scheme = GetProxyScheme();
var baseProxyUrl = $"{scheme}://{Request.Host}/Plugins/SRFPlay/Proxy/{itemId}";
// 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)
// 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);
return Content(rewrittenContent, "application/vnd.apple.mpegurl; charset=utf-8");
}
catch (Exception ex)
{
_logger.LogError(ex, "Error proxying variant manifest - ItemId: {ItemId}, Path: {Path}", itemId, fullPath);
return StatusCode(StatusCodes.Status500InternalServerError);
}
}
/// <summary>
/// Proxies HLS segment requests (.ts, .mp4, .m4s, .aac files).
/// </summary>
/// <param name="itemId">The item ID.</param>
/// <param name="segmentPath">The segment path.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The segment data.</returns>
[HttpGet("{itemId}/{*segmentPath}")]
[AllowAnonymous]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetSegment(
[FromRoute] string itemId,
[FromRoute] string segmentPath,
CancellationToken cancellationToken)
{
AddCorsHeaders();
_logger.LogDebug("Proxy request for segment - ItemId: {ItemId}, Path: {SegmentPath}", itemId, segmentPath);
// Try to resolve the actual item ID
var actualItemId = ResolveItemId(itemId);
try
{
// Pass the original query string to preserve segment-specific parameters (e.g., ?m=timestamp)
var queryString = Request.QueryString.HasValue ? Request.QueryString.Value : 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);
return NotFound();
}
// Determine content type based on file extension
var contentType = MimeTypeHelper.GetSegmentContentType(segmentPath);
Response.ContentType = contentType;
if (upstreamResponse.Content.Headers.ContentLength.HasValue)
{
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 (Exception ex)
{
_logger.LogError(ex, "Error proxying segment - ItemId: {ItemId}, Path: {SegmentPath}", itemId, segmentPath);
return StatusCode(StatusCodes.Status500InternalServerError);
}
}
/// <summary>
/// Gets the correct scheme for proxy URLs (https if public URL is configured with https).
/// </summary>
/// <returns>The scheme to use (http or https).</returns>
private string GetProxyScheme()
{
// Check if PublicServerUrl is configured and uses HTTPS
var config = Plugin.Instance?.Configuration;
if (config != null && !string.IsNullOrWhiteSpace(config.PublicServerUrl))
{
if (config.PublicServerUrl.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
{
return "https";
}
}
// Fall back to request scheme, but prefer https if forwarded headers indicate it
if (Request.Headers.TryGetValue("X-Forwarded-Proto", out var forwardedProto))
{
return forwardedProto.ToString().ToLowerInvariant();
}
return Request.Scheme;
}
/// <summary>
/// Resolves the actual item ID from the request.
/// </summary>
/// <param name="pathItemId">The item ID from the URL path.</param>
/// <returns>The resolved item ID.</returns>
private string ResolveItemId(string pathItemId)
{
// Check for token parameter first (preferred method)
if (Request.Query.TryGetValue("token", out var token) && !string.IsNullOrEmpty(token))
{
// Try to resolve the original item ID from the token via the proxy service
// We'll need to add a method to StreamProxyService to look up by token
_logger.LogInformation("Found token parameter: {Token}, will use path ID {PathItemId} for lookup", token.ToString(), pathItemId);
return pathItemId; // Use path ID for now; token prevents Jellyfin from rewriting the URL
}
// Check if there's an itemId query parameter (legacy fallback)
if (Request.Query.TryGetValue("itemId", out var queryItemId) && !string.IsNullOrEmpty(queryItemId))
{
_logger.LogInformation("Using itemId from query parameter: {QueryItemId} (path had: {PathItemId})", queryItemId.ToString(), pathItemId);
return queryItemId.ToString();
}
// No query parameters - use path ID as-is (normal case for segments and transcoding sessions)
_logger.LogDebug("No query parameters, using path ID as-is: {PathItemId}", pathItemId);
return pathItemId;
}
/// <summary>
/// Proxies image requests from SRF CDN, fixing Content-Type headers.
/// </summary>
/// <param name="url">The original image URL (base64 encoded).</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The image data with correct Content-Type.</returns>
[HttpGet("Image")]
[AllowAnonymous]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetImage(
[FromQuery] string url,
CancellationToken cancellationToken)
{
if (string.IsNullOrEmpty(url))
{
return BadRequest("URL parameter is required");
}
string decodedUrl;
try
{
// Decode base64 URL
var bytes = Convert.FromBase64String(url);
decodedUrl = System.Text.Encoding.UTF8.GetString(bytes);
}
catch (FormatException)
{
_logger.LogWarning("Invalid base64 URL parameter: {Url}", url);
return BadRequest("Invalid URL encoding");
}
_logger.LogDebug("Proxying image from: {Url}", decodedUrl);
try
{
var httpClient = _httpClientFactory.CreateClient(NamedClient.Default);
// Create request with proper headers
var request = new HttpRequestMessage(HttpMethod.Get, new Uri(decodedUrl));
request.Headers.UserAgent.ParseAdd(
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36");
request.Headers.Accept.ParseAdd("image/jpeg, image/png, image/webp, image/*;q=0.8");
var response = await httpClient.SendAsync(
request,
HttpCompletionOption.ResponseHeadersRead,
cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
_logger.LogWarning(
"Image fetch failed with status {StatusCode} for {Url}",
response.StatusCode,
decodedUrl);
return NotFound();
}
var imageData = await response.Content.ReadAsByteArrayAsync(cancellationToken)
.ConfigureAwait(false);
// Determine correct content type
var contentType = MimeTypeHelper.GetImageContentType(decodedUrl);
_logger.LogDebug(
"Returning proxied image ({Length} bytes, {ContentType})",
imageData.Length,
contentType);
return File(imageData, contentType);
}
catch (HttpRequestException ex)
{
_logger.LogError(ex, "Error fetching image from {Url}", decodedUrl);
return StatusCode(StatusCodes.Status502BadGateway);
}
}
/// <summary>
/// Generates a placeholder image with the given text centered.
/// </summary>
/// <param name="text">The text to display (base64 encoded).</param>
/// <returns>A PNG image with the text centered.</returns>
[HttpGet("Placeholder")]
[AllowAnonymous]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public IActionResult GetPlaceholder([FromQuery] string text)
{
if (string.IsNullOrEmpty(text))
{
return BadRequest("Text parameter is required");
}
string decodedText;
try
{
var bytes = Convert.FromBase64String(text);
decodedText = System.Text.Encoding.UTF8.GetString(bytes);
}
catch (FormatException)
{
_logger.LogWarning("Invalid base64 text parameter: {Text}", text);
return BadRequest("Invalid text encoding");
}
_logger.LogDebug("Generating placeholder image for: {Text}", decodedText);
var imageStream = PlaceholderImageGenerator.GeneratePlaceholder(decodedText);
return File(imageStream, "image/png");
}
}
@@ -14,6 +14,7 @@
<PackageReference Include="Jellyfin.Controller" Version="10.9.11" />
<PackageReference Include="Jellyfin.Model" Version="10.9.11" />
<PackageReference Include="Socks5" Version="1.1.0" />
<PackageReference Include="SkiaSharp" Version="2.88.8" />
</ItemGroup>
<ItemGroup>
@@ -27,4 +28,9 @@
<EmbeddedResource Include="Configuration\configPage.html" />
</ItemGroup>
<ItemGroup>
<None Remove="..\assests\main logo.png" />
<EmbeddedResource Include="..\assests\main logo.png" LogicalName="Jellyfin.Plugin.SRFPlay.Images.logo.png" />
</ItemGroup>
</Project>
@@ -1,11 +1,9 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Plugin.SRFPlay.Api;
using Jellyfin.Plugin.SRFPlay.Services;
using Jellyfin.Plugin.SRFPlay.Services.Interfaces;
using MediaBrowser.Controller.Entities.TV;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.Entities;
@@ -20,25 +18,19 @@ namespace Jellyfin.Plugin.SRFPlay.Providers;
public class SRFEpisodeProvider : IRemoteMetadataProvider<Episode, EpisodeInfo>
{
private readonly ILogger<SRFEpisodeProvider> _logger;
private readonly ILoggerFactory _loggerFactory;
private readonly IHttpClientFactory _httpClientFactory;
private readonly MetadataCache _metadataCache;
private readonly IMediaCompositionFetcher _compositionFetcher;
/// <summary>
/// Initializes a new instance of the <see cref="SRFEpisodeProvider"/> class.
/// </summary>
/// <param name="loggerFactory">The logger factory.</param>
/// <param name="httpClientFactory">The HTTP client factory.</param>
/// <param name="metadataCache">The metadata cache.</param>
/// <param name="logger">The logger.</param>
/// <param name="compositionFetcher">The media composition fetcher.</param>
public SRFEpisodeProvider(
ILoggerFactory loggerFactory,
IHttpClientFactory httpClientFactory,
MetadataCache metadataCache)
ILogger<SRFEpisodeProvider> logger,
IMediaCompositionFetcher compositionFetcher)
{
_loggerFactory = loggerFactory;
_logger = loggerFactory.CreateLogger<SRFEpisodeProvider>();
_httpClientFactory = httpClientFactory;
_metadataCache = metadataCache;
_logger = logger;
_compositionFetcher = compositionFetcher;
}
/// <inheritdoc />
@@ -56,28 +48,9 @@ public class SRFEpisodeProvider : IRemoteMetadataProvider<Episode, EpisodeInfo>
{
_logger.LogDebug("Searching for episode with URN: {Urn}", urn);
var config = Plugin.Instance?.Configuration;
if (config == null)
{
return results;
}
var mediaComposition = await _compositionFetcher.GetMediaCompositionAsync(urn, cancellationToken).ConfigureAwait(false);
// Try cache first
var mediaComposition = _metadataCache.GetMediaComposition(urn, config.CacheDurationMinutes);
// If not in cache, fetch from API
if (mediaComposition == null)
{
using var apiClient = new SRFApiClient(_loggerFactory);
mediaComposition = await apiClient.GetMediaCompositionByUrnAsync(urn, cancellationToken).ConfigureAwait(false);
if (mediaComposition != null)
{
_metadataCache.SetMediaComposition(urn, mediaComposition);
}
}
if (mediaComposition?.ChapterList != null && mediaComposition.ChapterList.Count > 0)
if (mediaComposition?.HasChapters == true)
{
var chapter = mediaComposition.ChapterList[0];
results.Add(new RemoteSearchResult
@@ -118,28 +91,9 @@ public class SRFEpisodeProvider : IRemoteMetadataProvider<Episode, EpisodeInfo>
_logger.LogDebug("Fetching metadata for episode URN: {Urn}", urn);
var config = Plugin.Instance?.Configuration;
if (config == null)
{
return result;
}
var mediaComposition = await _compositionFetcher.GetMediaCompositionAsync(urn, cancellationToken).ConfigureAwait(false);
// Try cache first
var mediaComposition = _metadataCache.GetMediaComposition(urn, config.CacheDurationMinutes);
// If not in cache, fetch from API
if (mediaComposition == null)
{
using var apiClient = new SRFApiClient(_loggerFactory);
mediaComposition = await apiClient.GetMediaCompositionByUrnAsync(urn, cancellationToken).ConfigureAwait(false);
if (mediaComposition != null)
{
_metadataCache.SetMediaComposition(urn, mediaComposition);
}
}
if (mediaComposition?.ChapterList == null || mediaComposition.ChapterList.Count == 0)
if (mediaComposition?.HasChapters != true)
{
_logger.LogWarning("No chapter information found for URN: {Urn}", urn);
return result;
@@ -1,10 +1,11 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Plugin.SRFPlay.Api;
using Jellyfin.Plugin.SRFPlay.Services.Interfaces;
using Jellyfin.Plugin.SRFPlay.Utilities;
using MediaBrowser.Common.Net;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Providers;
@@ -21,18 +22,22 @@ public class SRFImageProvider : IRemoteImageProvider, IHasOrder
{
private readonly IHttpClientFactory _httpClientFactory;
private readonly ILogger<SRFImageProvider> _logger;
private readonly ILoggerFactory _loggerFactory;
private readonly IMediaCompositionFetcher _compositionFetcher;
/// <summary>
/// Initializes a new instance of the <see cref="SRFImageProvider"/> class.
/// </summary>
/// <param name="httpClientFactory">The HTTP client factory.</param>
/// <param name="loggerFactory">The logger factory.</param>
public SRFImageProvider(IHttpClientFactory httpClientFactory, ILoggerFactory loggerFactory)
/// <param name="compositionFetcher">The media composition fetcher.</param>
public SRFImageProvider(
IHttpClientFactory httpClientFactory,
ILoggerFactory loggerFactory,
IMediaCompositionFetcher compositionFetcher)
{
_httpClientFactory = httpClientFactory;
_loggerFactory = loggerFactory;
_logger = loggerFactory.CreateLogger<SRFImageProvider>();
_compositionFetcher = compositionFetcher;
}
/// <inheritdoc />
@@ -78,8 +83,7 @@ public class SRFImageProvider : IRemoteImageProvider, IHasOrder
_logger.LogDebug("Fetching images for SRF URN: {Urn}", urn);
// Fetch media composition to get image URLs
using var apiClient = new SRFApiClient(_loggerFactory);
var mediaComposition = await apiClient.GetMediaCompositionByUrnAsync(urn, cancellationToken).ConfigureAwait(false);
var mediaComposition = await _compositionFetcher.GetMediaCompositionAsync(urn, cancellationToken).ConfigureAwait(false);
if (mediaComposition == null)
{
@@ -88,7 +92,7 @@ public class SRFImageProvider : IRemoteImageProvider, IHasOrder
}
// Extract images from chapters
if (mediaComposition.ChapterList != null && mediaComposition.ChapterList.Count > 0)
if (mediaComposition.HasChapters)
{
var chapter = mediaComposition.ChapterList[0];
if (!string.IsNullOrEmpty(chapter.ImageUrl))
@@ -151,9 +155,51 @@ public class SRFImageProvider : IRemoteImageProvider, IHasOrder
}
/// <inheritdoc />
public Task<HttpResponseMessage> GetImageResponse(string url, CancellationToken cancellationToken)
public async Task<HttpResponseMessage> GetImageResponse(string url, CancellationToken cancellationToken)
{
_logger.LogDebug("Fetching image from URL: {Url}", url);
var httpClient = _httpClientFactory.CreateClient(NamedClient.Default);
return httpClient.GetAsync(new Uri(url), cancellationToken);
// Create request with proper headers - SRF CDN requires User-Agent
var request = new HttpRequestMessage(HttpMethod.Get, new Uri(url));
request.Headers.UserAgent.ParseAdd("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36");
request.Headers.Accept.ParseAdd("image/jpeg, image/png, image/webp, image/*;q=0.8");
var response = await httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
var originalContentType = response.Content.Headers.ContentType?.MediaType;
_logger.LogDebug(
"Image response status: {StatusCode}, Content-Type: {ContentType}, Content-Length: {Length}",
response.StatusCode,
originalContentType ?? "null",
response.Content.Headers.ContentLength);
// Fix Content-Type if it's not a proper image type - SRF CDN often returns wrong content type
// Jellyfin needs correct Content-Type to process images
if (response.IsSuccessStatusCode)
{
var needsContentTypeFix = string.IsNullOrEmpty(originalContentType) ||
originalContentType == "binary/octet-stream" ||
originalContentType == "application/octet-stream" ||
!originalContentType.StartsWith("image/", StringComparison.OrdinalIgnoreCase);
if (needsContentTypeFix)
{
// Determine correct content type from URL extension or default to JPEG
var contentType = MimeTypeHelper.GetImageContentType(url);
if (!string.IsNullOrEmpty(contentType))
{
_logger.LogInformation(
"Fixing Content-Type from '{OriginalType}' to '{NewType}' for {Url}",
originalContentType ?? "null",
contentType,
url);
response.Content.Headers.ContentType = new MediaTypeHeaderValue(contentType);
}
}
}
return response;
}
}
@@ -1,15 +1,12 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Plugin.SRFPlay.Api;
using Jellyfin.Plugin.SRFPlay.Services;
using Jellyfin.Plugin.SRFPlay.Configuration;
using Jellyfin.Plugin.SRFPlay.Services.Interfaces;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Library;
using MediaBrowser.Model.Dto;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.MediaInfo;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.SRFPlay.Providers;
@@ -20,25 +17,27 @@ namespace Jellyfin.Plugin.SRFPlay.Providers;
public class SRFMediaProvider : IMediaSourceProvider
{
private readonly ILogger<SRFMediaProvider> _logger;
private readonly ILoggerFactory _loggerFactory;
private readonly MetadataCache _metadataCache;
private readonly StreamUrlResolver _streamResolver;
private readonly IMediaCompositionFetcher _compositionFetcher;
private readonly IStreamUrlResolver _streamResolver;
private readonly IMediaSourceFactory _mediaSourceFactory;
/// <summary>
/// Initializes a new instance of the <see cref="SRFMediaProvider"/> class.
/// </summary>
/// <param name="loggerFactory">The logger factory.</param>
/// <param name="metadataCache">The metadata cache.</param>
/// <param name="compositionFetcher">The media composition fetcher.</param>
/// <param name="streamResolver">The stream URL resolver.</param>
/// <param name="mediaSourceFactory">The media source factory.</param>
public SRFMediaProvider(
ILoggerFactory loggerFactory,
MetadataCache metadataCache,
StreamUrlResolver streamResolver)
IMediaCompositionFetcher compositionFetcher,
IStreamUrlResolver streamResolver,
IMediaSourceFactory mediaSourceFactory)
{
_loggerFactory = loggerFactory;
_logger = loggerFactory.CreateLogger<SRFMediaProvider>();
_metadataCache = metadataCache;
_compositionFetcher = compositionFetcher;
_streamResolver = streamResolver;
_mediaSourceFactory = mediaSourceFactory;
}
/// <summary>
@@ -52,7 +51,7 @@ public class SRFMediaProvider : IMediaSourceProvider
/// <param name="item">The item.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>List of media sources.</returns>
public Task<IEnumerable<MediaSourceInfo>> GetMediaSources(BaseItem item, CancellationToken cancellationToken)
public async Task<IEnumerable<MediaSourceInfo>> GetMediaSources(BaseItem item, CancellationToken cancellationToken)
{
var sources = new List<MediaSourceInfo>();
@@ -61,42 +60,20 @@ public class SRFMediaProvider : IMediaSourceProvider
// Check if this is an SRF item
if (!item.ProviderIds.TryGetValue("SRF", out var urn) || string.IsNullOrEmpty(urn))
{
return Task.FromResult<IEnumerable<MediaSourceInfo>>(sources);
return sources;
}
_logger.LogDebug("Getting media sources for URN: {Urn}", urn);
var config = Plugin.Instance?.Configuration;
if (config == null)
{
return Task.FromResult<IEnumerable<MediaSourceInfo>>(sources);
}
_logger.LogDebug("GetMediaSources for URN: {Urn}, Item: {ItemName}", urn, item.Name);
// For scheduled livestreams, use shorter cache TTL (5 minutes) so they refresh when they go live
// For regular content, use configured cache duration
var cacheDuration = urn.Contains("scheduled_livestream", StringComparison.OrdinalIgnoreCase)
? 5
: config.CacheDurationMinutes;
var cacheDuration = urn.Contains("scheduled_livestream", StringComparison.OrdinalIgnoreCase) ? 5 : (int?)null;
// Try cache first
var mediaComposition = _metadataCache.GetMediaComposition(urn, cacheDuration);
var mediaComposition = await _compositionFetcher.GetMediaCompositionAsync(urn, cancellationToken, cacheDuration).ConfigureAwait(false);
// If not in cache, fetch from API
if (mediaComposition == null)
{
using var apiClient = new SRFApiClient(_loggerFactory);
mediaComposition = apiClient.GetMediaCompositionByUrnAsync(urn, cancellationToken).GetAwaiter().GetResult();
if (mediaComposition != null)
{
_metadataCache.SetMediaComposition(urn, mediaComposition);
}
}
if (mediaComposition?.ChapterList == null || mediaComposition.ChapterList.Count == 0)
if (mediaComposition?.HasChapters != true)
{
_logger.LogWarning("No chapters found for URN: {Urn}", urn);
return Task.FromResult<IEnumerable<MediaSourceInfo>>(sources);
return sources;
}
// Get the first chapter (main video)
@@ -106,97 +83,77 @@ public class SRFMediaProvider : IMediaSourceProvider
if (_streamResolver.IsContentExpired(chapter))
{
_logger.LogWarning("Content expired for URN: {Urn}, ValidTo: {ValidTo}", urn, chapter.ValidTo);
return Task.FromResult<IEnumerable<MediaSourceInfo>>(sources);
return sources;
}
// Check if content has playable streams
if (!_streamResolver.HasPlayableContent(chapter))
{
_logger.LogWarning("No playable content found for URN: {Urn}", urn);
return Task.FromResult<IEnumerable<MediaSourceInfo>>(sources);
return sources;
}
// Get stream URL based on quality preference
var streamUrl = _streamResolver.GetStreamUrl(chapter, config.QualityPreference);
// Get quality preference from config
var config = Plugin.Instance?.Configuration;
var qualityPref = config?.QualityPreference ?? QualityPreference.HD;
// For scheduled livestreams, always fetch fresh data to ensure stream URL is current
if (chapter.Type == "SCHEDULED_LIVESTREAM" && string.IsNullOrEmpty(streamUrl))
// Use item ID in hex format without dashes
var itemIdStr = item.Id.ToString("N");
// Use factory to create MediaSourceInfo
var mediaSource = await _mediaSourceFactory.CreateMediaSourceAsync(
chapter,
itemIdStr,
urn,
qualityPref,
cancellationToken).ConfigureAwait(false);
// For scheduled livestreams, retry with fresh data if no stream URL
if (mediaSource == null && chapter.Type == "SCHEDULED_LIVESTREAM")
{
_logger.LogDebug("URN {Urn}: Scheduled livestream has no stream URL, fetching fresh data", urn);
using var freshApiClient = new SRFApiClient(_loggerFactory);
var freshMediaComposition = freshApiClient.GetMediaCompositionByUrnAsync(urn, cancellationToken).GetAwaiter().GetResult();
// Force fresh fetch with short cache duration
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];
streamUrl = _streamResolver.GetStreamUrl(freshChapter, config.QualityPreference);
mediaSource = await _mediaSourceFactory.CreateMediaSourceAsync(
freshChapter,
itemIdStr,
urn,
qualityPref,
cancellationToken).ConfigureAwait(false);
if (!string.IsNullOrEmpty(streamUrl))
if (mediaSource != null)
{
// Update cache with fresh data
_metadataCache.SetMediaComposition(urn, freshMediaComposition);
chapter = freshChapter;
_logger.LogInformation("URN {Urn}: Got fresh stream URL for scheduled livestream", urn);
}
}
}
if (string.IsNullOrEmpty(streamUrl))
if (mediaSource == null)
{
_logger.LogWarning("Could not resolve stream URL for URN: {Urn}", urn);
return Task.FromResult<IEnumerable<MediaSourceInfo>>(sources);
return sources;
}
// Authenticate the stream URL (required for all SRF streams, especially livestreams)
if (!string.IsNullOrEmpty(streamUrl))
{
streamUrl = _streamResolver.GetAuthenticatedStreamUrlAsync(streamUrl, cancellationToken).GetAwaiter().GetResult();
_logger.LogDebug("Authenticated stream URL for URN: {Urn}", urn);
}
// Create media source
var mediaSource = new MediaSourceInfo
{
Id = item.Id.ToString(), // Use item GUID, not URN string (required for transcoding)
Name = chapter.Title,
Path = streamUrl,
Protocol = MediaProtocol.Http,
Container = "m3u8",
SupportsDirectStream = true,
SupportsDirectPlay = false, // Disabled: auth tokens don't carry over to HLS segments in browser
SupportsTranscoding = true,
IsRemote = true,
Type = MediaSourceType.Default,
RunTimeTicks = chapter.Duration > 0 ? TimeSpan.FromMilliseconds(chapter.Duration).Ticks : null,
VideoType = VideoType.VideoFile,
IsInfiniteStream = false,
RequiresOpening = false,
RequiresClosing = false,
SupportsProbing = true
};
// Add video stream info
mediaSource.MediaStreams = new List<MediaStream>
{
new MediaStream
{
Type = MediaStreamType.Video,
Codec = "h264",
IsInterlaced = false,
IsDefault = true
}
};
sources.Add(mediaSource);
_logger.LogInformation("Resolved stream URL for {Title}: {Url}", chapter.Title, streamUrl);
_logger.LogDebug(
"MediaSource created for {Title} - Id={Id}, DirectPlay={DirectPlay}, Transcoding={Transcoding}",
chapter.Title,
mediaSource.Id,
mediaSource.SupportsDirectPlay,
mediaSource.SupportsTranscoding);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error getting media sources for item: {Name}", item.Name);
}
return Task.FromResult<IEnumerable<MediaSourceInfo>>(sources);
return sources;
}
/// <summary>
@@ -207,6 +164,7 @@ public class SRFMediaProvider : IMediaSourceProvider
/// <returns>The direct stream provider.</returns>
public Task<IDirectStreamProvider?> GetDirectStreamProviderByUniqueId(string uniqueId, CancellationToken cancellationToken)
{
_logger.LogInformation("GetDirectStreamProviderByUniqueId called with uniqueId: {UniqueId}", uniqueId);
// Not needed for HTTP streams
return Task.FromResult<IDirectStreamProvider?>(null);
}
@@ -214,7 +172,8 @@ public class SRFMediaProvider : IMediaSourceProvider
/// <inheritdoc />
public Task<ILiveStream> OpenMediaSource(string openToken, List<ILiveStream> currentLiveStreams, CancellationToken cancellationToken)
{
// Not needed for static HTTP streams
throw new NotImplementedException();
// Not used - RequiresOpening is false, proxy handles authentication directly
_logger.LogWarning("OpenMediaSource unexpectedly called with openToken: {OpenToken}", openToken);
throw new NotSupportedException("OpenMediaSource not supported - streams use direct proxy access");
}
}
@@ -1,14 +1,11 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Plugin.SRFPlay.Api;
using Jellyfin.Plugin.SRFPlay.Services;
using Jellyfin.Plugin.SRFPlay.Services.Interfaces;
using MediaBrowser.Controller.Entities.TV;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.Providers;
using Microsoft.Extensions.Logging;
@@ -20,27 +17,19 @@ namespace Jellyfin.Plugin.SRFPlay.Providers;
public class SRFSeriesProvider : IRemoteMetadataProvider<Series, SeriesInfo>
{
private readonly ILogger<SRFSeriesProvider> _logger;
private readonly ILoggerFactory _loggerFactory;
private readonly IHttpClientFactory _httpClientFactory;
private readonly MetadataCache _metadataCache;
private readonly IMediaCompositionFetcher _compositionFetcher;
/// <summary>
/// Initializes a new instance of the <see cref="SRFSeriesProvider"/> class.
/// </summary>
/// <param name="logger">The logger instance.</param>
/// <param name="loggerFactory">The logger factory.</param>
/// <param name="httpClientFactory">The HTTP client factory.</param>
/// <param name="metadataCache">The metadata cache.</param>
/// <param name="logger">The logger.</param>
/// <param name="compositionFetcher">The media composition fetcher.</param>
public SRFSeriesProvider(
ILogger<SRFSeriesProvider> logger,
ILoggerFactory loggerFactory,
IHttpClientFactory httpClientFactory,
MetadataCache metadataCache)
IMediaCompositionFetcher compositionFetcher)
{
_logger = logger;
_loggerFactory = loggerFactory;
_httpClientFactory = httpClientFactory;
_metadataCache = metadataCache;
_compositionFetcher = compositionFetcher;
}
/// <inheritdoc />
@@ -58,26 +47,7 @@ public class SRFSeriesProvider : IRemoteMetadataProvider<Series, SeriesInfo>
{
_logger.LogDebug("Searching for series with URN: {Urn}", urn);
var config = Plugin.Instance?.Configuration;
if (config == null)
{
return results;
}
// Try cache first
var mediaComposition = _metadataCache.GetMediaComposition(urn, config.CacheDurationMinutes);
// If not in cache, fetch from API
if (mediaComposition == null)
{
using var apiClient = new SRFApiClient(_loggerFactory);
mediaComposition = await apiClient.GetMediaCompositionByUrnAsync(urn, cancellationToken).ConfigureAwait(false);
if (mediaComposition != null)
{
_metadataCache.SetMediaComposition(urn, mediaComposition);
}
}
var mediaComposition = await _compositionFetcher.GetMediaCompositionAsync(urn, cancellationToken).ConfigureAwait(false);
if (mediaComposition?.Show != null)
{
@@ -125,26 +95,7 @@ public class SRFSeriesProvider : IRemoteMetadataProvider<Series, SeriesInfo>
_logger.LogDebug("Fetching metadata for series URN: {Urn}", urn);
var config = Plugin.Instance?.Configuration;
if (config == null)
{
return result;
}
// Try cache first
var mediaComposition = _metadataCache.GetMediaComposition(urn, config.CacheDurationMinutes);
// If not in cache, fetch from API
if (mediaComposition == null)
{
using var apiClient = new SRFApiClient(_loggerFactory);
mediaComposition = await apiClient.GetMediaCompositionByUrnAsync(urn, cancellationToken).ConfigureAwait(false);
if (mediaComposition != null)
{
_metadataCache.SetMediaComposition(urn, mediaComposition);
}
}
var mediaComposition = await _compositionFetcher.GetMediaCompositionAsync(urn, cancellationToken).ConfigureAwait(false);
if (mediaComposition?.Show == null)
{
@@ -2,7 +2,7 @@ using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Plugin.SRFPlay.Services;
using Jellyfin.Plugin.SRFPlay.Services.Interfaces;
using MediaBrowser.Model.Tasks;
using Microsoft.Extensions.Logging;
@@ -14,7 +14,7 @@ namespace Jellyfin.Plugin.SRFPlay.ScheduledTasks;
public class ContentRefreshTask : IScheduledTask
{
private readonly ILogger<ContentRefreshTask> _logger;
private readonly ContentRefreshService _contentRefreshService;
private readonly IContentRefreshService _contentRefreshService;
/// <summary>
/// Initializes a new instance of the <see cref="ContentRefreshTask"/> class.
@@ -23,7 +23,7 @@ public class ContentRefreshTask : IScheduledTask
/// <param name="contentRefreshService">The content refresh service.</param>
public ContentRefreshTask(
ILogger<ContentRefreshTask> logger,
ContentRefreshService contentRefreshService)
IContentRefreshService contentRefreshService)
{
_logger = logger;
_contentRefreshService = contentRefreshService;
@@ -2,7 +2,7 @@ using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Plugin.SRFPlay.Services;
using Jellyfin.Plugin.SRFPlay.Services.Interfaces;
using MediaBrowser.Model.Tasks;
using Microsoft.Extensions.Logging;
@@ -14,19 +14,23 @@ namespace Jellyfin.Plugin.SRFPlay.ScheduledTasks;
public class ExpirationCheckTask : IScheduledTask
{
private readonly ILogger<ExpirationCheckTask> _logger;
private readonly ContentExpirationService _expirationService;
private readonly IContentExpirationService _expirationService;
private readonly IStreamProxyService _streamProxyService;
/// <summary>
/// Initializes a new instance of the <see cref="ExpirationCheckTask"/> class.
/// </summary>
/// <param name="logger">The logger instance.</param>
/// <param name="expirationService">The content expiration service.</param>
/// <param name="streamProxyService">The stream proxy service.</param>
public ExpirationCheckTask(
ILogger<ExpirationCheckTask> logger,
ContentExpirationService expirationService)
IContentExpirationService expirationService,
IStreamProxyService streamProxyService)
{
_logger = logger;
_expirationService = expirationService;
_streamProxyService = streamProxyService;
}
/// <inheritdoc />
@@ -56,27 +60,14 @@ public class ExpirationCheckTask : IScheduledTask
return;
}
// Get expiration statistics first
// Check and remove expired content
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);
// Clean up old stream proxy mappings
progress?.Report(75);
_streamProxyService.CleanupOldMappings();
progress?.Report(100);
_logger.LogInformation("SRF Play expiration check task completed. Removed {Count} expired items", removedCount);
}
+14 -7
View File
@@ -1,13 +1,14 @@
using Jellyfin.Plugin.SRFPlay.Api;
using Jellyfin.Plugin.SRFPlay.Channels;
using Jellyfin.Plugin.SRFPlay.Providers;
using Jellyfin.Plugin.SRFPlay.ScheduledTasks;
using Jellyfin.Plugin.SRFPlay.Services;
using Jellyfin.Plugin.SRFPlay.Services.Interfaces;
using MediaBrowser.Controller;
using MediaBrowser.Controller.Channels;
using MediaBrowser.Controller.Plugins;
using MediaBrowser.Model.Tasks;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.SRFPlay;
@@ -19,12 +20,18 @@ public class ServiceRegistrator : IPluginServiceRegistrator
/// <inheritdoc />
public void RegisterServices(IServiceCollection serviceCollection, IServerApplicationHost applicationHost)
{
// Register services as singletons
serviceCollection.AddSingleton<MetadataCache>();
serviceCollection.AddSingleton<StreamUrlResolver>();
serviceCollection.AddSingleton<ContentExpirationService>();
serviceCollection.AddSingleton<ContentRefreshService>();
serviceCollection.AddSingleton<CategoryService>();
// Register API client factory
serviceCollection.AddSingleton<ISRFApiClientFactory, SRFApiClientFactory>();
// Register core services with interfaces
serviceCollection.AddSingleton<IMetadataCache, MetadataCache>();
serviceCollection.AddSingleton<IStreamUrlResolver, StreamUrlResolver>();
serviceCollection.AddSingleton<IMediaCompositionFetcher, MediaCompositionFetcher>();
serviceCollection.AddSingleton<IStreamProxyService, StreamProxyService>();
serviceCollection.AddSingleton<IMediaSourceFactory, MediaSourceFactory>();
serviceCollection.AddSingleton<IContentExpirationService, ContentExpirationService>();
serviceCollection.AddSingleton<IContentRefreshService, ContentRefreshService>();
serviceCollection.AddSingleton<ICategoryService, CategoryService>();
// Register metadata providers
serviceCollection.AddSingleton<SRFSeriesProvider>();
@@ -1,11 +1,11 @@
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Plugin.SRFPlay.Api;
using Jellyfin.Plugin.SRFPlay.Api.Models;
using Jellyfin.Plugin.SRFPlay.Services.Interfaces;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.SRFPlay.Services;
@@ -13,10 +13,10 @@ namespace Jellyfin.Plugin.SRFPlay.Services;
/// <summary>
/// Service for managing topic/category data and filtering.
/// </summary>
public class CategoryService
public class CategoryService : ICategoryService
{
private readonly ILogger _logger;
private readonly ILoggerFactory _loggerFactory;
private readonly ILogger<CategoryService> _logger;
private readonly ISRFApiClientFactory _apiClientFactory;
private readonly TimeSpan _topicsCacheDuration = TimeSpan.FromHours(24);
private Dictionary<string, PlayV3Topic>? _topicsCache;
private DateTime _topicsCacheExpiry = DateTime.MinValue;
@@ -24,19 +24,15 @@ public class CategoryService
/// <summary>
/// Initializes a new instance of the <see cref="CategoryService"/> class.
/// </summary>
/// <param name="loggerFactory">The logger factory.</param>
public CategoryService(ILoggerFactory loggerFactory)
/// <param name="logger">The logger.</param>
/// <param name="apiClientFactory">The API client factory.</param>
public CategoryService(ILogger<CategoryService> logger, ISRFApiClientFactory apiClientFactory)
{
_loggerFactory = loggerFactory;
_logger = loggerFactory.CreateLogger<CategoryService>();
_logger = logger;
_apiClientFactory = apiClientFactory;
}
/// <summary>
/// Gets all topics for a business unit.
/// </summary>
/// <param name="businessUnit">The business unit.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>List of topics.</returns>
/// <inheritdoc />
public async Task<List<PlayV3Topic>> GetTopicsAsync(string businessUnit, CancellationToken cancellationToken = default)
{
// Return cached topics if still valid
@@ -47,7 +43,7 @@ public class CategoryService
}
_logger.LogInformation("Fetching topics for business unit: {BusinessUnit}", businessUnit);
using var apiClient = new SRFApiClient(_loggerFactory);
using var apiClient = _apiClientFactory.CreateClient();
var topics = await apiClient.GetAllTopicsAsync(businessUnit, cancellationToken).ConfigureAwait(false);
if (topics != null && topics.Count > 0)
@@ -64,13 +60,7 @@ public class CategoryService
return topics ?? new List<PlayV3Topic>();
}
/// <summary>
/// Gets a topic by ID.
/// </summary>
/// <param name="topicId">The topic ID.</param>
/// <param name="businessUnit">The business unit.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>The topic, or null if not found.</returns>
/// <inheritdoc />
public async Task<PlayV3Topic?> GetTopicByIdAsync(string topicId, string businessUnit, CancellationToken cancellationToken = default)
{
// Ensure topics are loaded
@@ -82,70 +72,14 @@ public class CategoryService
return _topicsCache?.GetValueOrDefault(topicId);
}
/// <summary>
/// Filters shows by topic ID.
/// </summary>
/// <param name="shows">The shows to filter.</param>
/// <param name="topicId">The topic ID to filter by.</param>
/// <returns>Filtered list of shows.</returns>
public IReadOnlyList<PlayV3Show> FilterShowsByTopic(IReadOnlyList<PlayV3Show> shows, string topicId)
{
if (string.IsNullOrEmpty(topicId))
{
return shows;
}
return shows
.Where(s => s.TopicList != null && s.TopicList.Contains(topicId))
.ToList();
}
/// <summary>
/// Groups shows by their topics.
/// </summary>
/// <param name="shows">The shows to group.</param>
/// <returns>Dictionary mapping topic IDs to shows.</returns>
public IReadOnlyDictionary<string, List<PlayV3Show>> GroupShowsByTopics(IReadOnlyList<PlayV3Show> shows)
{
var groupedShows = new Dictionary<string, List<PlayV3Show>>();
foreach (var show in shows)
{
if (show.TopicList == null || show.TopicList.Count == 0)
{
continue;
}
foreach (var topicId in show.TopicList)
{
if (!groupedShows.TryGetValue(topicId, out var showList))
{
showList = new List<PlayV3Show>();
groupedShows[topicId] = showList;
}
showList.Add(show);
}
}
return groupedShows;
}
/// <summary>
/// Gets shows for a specific topic, sorted by number of episodes.
/// </summary>
/// <param name="topicId">The topic ID.</param>
/// <param name="businessUnit">The business unit.</param>
/// <param name="maxResults">Maximum number of results to return.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>List of shows for the topic.</returns>
/// <inheritdoc />
public async Task<List<PlayV3Show>> GetShowsByTopicAsync(
string topicId,
string businessUnit,
int maxResults = 50,
CancellationToken cancellationToken = default)
{
using var apiClient = new SRFApiClient(_loggerFactory);
using var apiClient = _apiClientFactory.CreateClient();
var allShows = await apiClient.GetAllShowsAsync(businessUnit, cancellationToken).ConfigureAwait(false);
if (allShows == null || allShows.Count == 0)
@@ -164,43 +98,29 @@ public class CategoryService
return filteredShows;
}
/// <summary>
/// Gets video count for each topic.
/// </summary>
/// <param name="shows">The shows to analyze.</param>
/// <returns>Dictionary mapping topic IDs to video counts.</returns>
public IReadOnlyDictionary<string, int> GetVideoCountByTopic(IReadOnlyList<PlayV3Show> shows)
{
var topicCounts = new Dictionary<string, int>();
foreach (var show in shows)
{
if (show.TopicList == null || show.TopicList.Count == 0)
{
continue;
}
foreach (var topicId in show.TopicList)
{
if (!topicCounts.TryGetValue(topicId, out var count))
{
count = 0;
}
topicCounts[topicId] = count + show.NumberOfEpisodes;
}
}
return topicCounts;
}
/// <summary>
/// Clears the topics cache.
/// </summary>
/// <inheritdoc />
public void ClearCache()
{
_topicsCache = null;
_topicsCacheExpiry = DateTime.MinValue;
_logger.LogInformation("Topics cache cleared");
}
/// <summary>
/// Filters shows by topic ID.
/// </summary>
/// <param name="shows">The shows to filter.</param>
/// <param name="topicId">The topic ID to filter by.</param>
/// <returns>Filtered list of shows.</returns>
private static IReadOnlyList<PlayV3Show> FilterShowsByTopic(IReadOnlyList<PlayV3Show> shows, string topicId)
{
if (string.IsNullOrEmpty(topicId))
{
return shows;
}
return shows
.Where(s => s.TopicList != null && s.TopicList.Contains(topicId))
.ToList();
}
}
@@ -3,10 +3,9 @@ using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Plugin.SRFPlay.Api;
using Jellyfin.Plugin.SRFPlay.Services.Interfaces;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Library;
using MediaBrowser.Model.IO;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.SRFPlay.Services;
@@ -14,13 +13,12 @@ namespace Jellyfin.Plugin.SRFPlay.Services;
/// <summary>
/// Service for managing content expiration.
/// </summary>
public class ContentExpirationService
public class ContentExpirationService : IContentExpirationService
{
private readonly ILogger<ContentExpirationService> _logger;
private readonly ILoggerFactory _loggerFactory;
private readonly ILibraryManager _libraryManager;
private readonly StreamUrlResolver _streamResolver;
private readonly MetadataCache _metadataCache;
private readonly IStreamUrlResolver _streamResolver;
private readonly IMediaCompositionFetcher _compositionFetcher;
/// <summary>
/// Initializes a new instance of the <see cref="ContentExpirationService"/> class.
@@ -28,18 +26,17 @@ public class ContentExpirationService
/// <param name="loggerFactory">The logger factory.</param>
/// <param name="libraryManager">The library manager.</param>
/// <param name="streamResolver">The stream URL resolver.</param>
/// <param name="metadataCache">The metadata cache.</param>
/// <param name="compositionFetcher">The media composition fetcher.</param>
public ContentExpirationService(
ILoggerFactory loggerFactory,
ILibraryManager libraryManager,
StreamUrlResolver streamResolver,
MetadataCache metadataCache)
IStreamUrlResolver streamResolver,
IMediaCompositionFetcher compositionFetcher)
{
_loggerFactory = loggerFactory;
_logger = loggerFactory.CreateLogger<ContentExpirationService>();
_libraryManager = libraryManager;
_streamResolver = streamResolver;
_metadataCache = metadataCache;
_compositionFetcher = compositionFetcher;
}
/// <summary>
@@ -119,28 +116,9 @@ public class ContentExpirationService
return false;
}
var config = Plugin.Instance?.Configuration;
if (config == null)
{
return false;
}
var mediaComposition = await _compositionFetcher.GetMediaCompositionAsync(urn, cancellationToken).ConfigureAwait(false);
// Try cache first
var mediaComposition = _metadataCache.GetMediaComposition(urn, config.CacheDurationMinutes);
// If not in cache, fetch from API
if (mediaComposition == null)
{
using var apiClient = new SRFApiClient(_loggerFactory);
mediaComposition = await apiClient.GetMediaCompositionByUrnAsync(urn, cancellationToken).ConfigureAwait(false);
if (mediaComposition != null)
{
_metadataCache.SetMediaComposition(urn, mediaComposition);
}
}
if (mediaComposition?.ChapterList == null || mediaComposition.ChapterList.Count == 0)
if (mediaComposition?.HasChapters != true)
{
// If we can't fetch the content, consider it expired
_logger.LogWarning("Could not fetch media composition for URN: {Urn}, treating as expired", urn);
@@ -157,89 +135,4 @@ public class ContentExpirationService
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 config = Plugin.Instance?.Configuration;
if (config == null)
{
continue;
}
var mediaComposition = _metadataCache.GetMediaComposition(urn, config.CacheDurationMinutes);
if (mediaComposition == null)
{
using var apiClient = new SRFApiClient(_loggerFactory);
mediaComposition = await apiClient.GetMediaCompositionByUrnAsync(urn, cancellationToken).ConfigureAwait(false);
if (mediaComposition != null)
{
_metadataCache.SetMediaComposition(urn, mediaComposition);
}
}
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);
}
}
@@ -4,7 +4,8 @@ using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Plugin.SRFPlay.Api;
using Jellyfin.Plugin.SRFPlay.Api.Models;
using Jellyfin.Plugin.SRFPlay.Services.Interfaces;
using Jellyfin.Plugin.SRFPlay.Utilities;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.SRFPlay.Services;
@@ -12,24 +13,22 @@ namespace Jellyfin.Plugin.SRFPlay.Services;
/// <summary>
/// Service for refreshing content from SRF API.
/// </summary>
public class ContentRefreshService
public class ContentRefreshService : IContentRefreshService
{
private readonly ILogger<ContentRefreshService> _logger;
private readonly ILoggerFactory _loggerFactory;
private readonly MetadataCache _metadataCache;
private readonly ISRFApiClientFactory _apiClientFactory;
/// <summary>
/// Initializes a new instance of the <see cref="ContentRefreshService"/> class.
/// </summary>
/// <param name="loggerFactory">The logger factory.</param>
/// <param name="metadataCache">The metadata cache.</param>
/// <param name="apiClientFactory">The API client factory.</param>
public ContentRefreshService(
ILoggerFactory loggerFactory,
MetadataCache metadataCache)
ISRFApiClientFactory apiClientFactory)
{
_loggerFactory = loggerFactory;
_logger = loggerFactory.CreateLogger<ContentRefreshService>();
_metadataCache = metadataCache;
_apiClientFactory = apiClientFactory;
}
/// <summary>
@@ -38,112 +37,21 @@ public class ContentRefreshService
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>List of URNs for new content.</returns>
public async Task<List<string>> RefreshLatestContentAsync(CancellationToken cancellationToken)
{
var urns = new List<string>();
try
{
var config = Plugin.Instance?.Configuration;
if (config == null || !config.EnableLatestContent)
{
_logger.LogDebug("Latest content refresh is disabled");
return urns;
return new List<string>();
}
_logger.LogInformation("Refreshing latest content for business unit: {BusinessUnit}", config.BusinessUnit);
using var apiClient = new SRFApiClient(_loggerFactory);
var businessUnit = config.BusinessUnit.ToString().ToLowerInvariant();
// Get all shows from Play v3 API
var shows = await apiClient.GetAllShowsAsync(businessUnit, cancellationToken).ConfigureAwait(false);
if (shows == null || shows.Count == 0)
{
_logger.LogWarning("No shows found for business unit: {BusinessUnit}", config.BusinessUnit);
return urns;
}
_logger.LogInformation("Found {Count} shows, fetching latest episodes from each", shows.Count);
// Get latest episodes from each show (limit to 20 shows to avoid overwhelming)
var showsToFetch = shows.Where(s => s.NumberOfEpisodes > 0)
.OrderByDescending(s => s.NumberOfEpisodes)
.Take(20)
.ToList();
foreach (var show in showsToFetch)
{
if (show.Id == null)
{
continue;
}
try
{
var videos = await apiClient.GetVideosForShowAsync(businessUnit, show.Id, cancellationToken).ConfigureAwait(false);
if (videos != null && videos.Count > 0)
{
_logger.LogDebug("Show {Show} ({ShowId}): Found {Count} videos", show.Title, show.Id, videos.Count);
// Filter to videos that are actually published (validFrom in the past)
var now = DateTime.UtcNow;
var publishedVideos = videos.Where(v =>
v.ValidFrom == null || v.ValidFrom.Value.ToUniversalTime() <= now).ToList();
_logger.LogDebug("Show {Show}: {PublishedCount} published out of {TotalCount} videos", show.Title, publishedVideos.Count, videos.Count);
if (publishedVideos.Count > 0)
{
// Take only the most recent published video from each show
var latestVideo = publishedVideos.OrderByDescending(v => v.Date).FirstOrDefault();
if (latestVideo?.Urn != null)
{
urns.Add(latestVideo.Urn);
_logger.LogInformation(
"Added latest video from show {Show}: {Title} (URN: {Urn}, Date: {Date}, ValidFrom: {ValidFrom}, ValidTo: {ValidTo})",
show.Title,
latestVideo.Title,
latestVideo.Urn,
latestVideo.Date,
latestVideo.ValidFrom,
latestVideo.ValidTo);
}
else
{
_logger.LogWarning("Show {Show}: Latest video has null URN", show.Title);
}
}
else
{
_logger.LogDebug("Show {Show} has no published videos yet", show.Title);
}
}
else
{
_logger.LogDebug("Show {Show} ({ShowId}): No videos returned from API", show.Title, show.Id);
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Error fetching videos for show {ShowId}", show.Id);
}
// Respect cancellation
if (cancellationToken.IsCancellationRequested)
{
break;
}
}
_logger.LogInformation("Refreshed {Count} latest content items from {ShowCount} shows", urns.Count, showsToFetch.Count);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error refreshing latest content");
}
return urns;
return await FetchVideosFromShowsAsync(
config.BusinessUnit.ToLowerString(),
minEpisodeCount: 0,
maxShows: 20,
videosPerShow: 1,
contentType: "latest",
cancellationToken).ConfigureAwait(false);
}
/// <summary>
@@ -153,43 +61,62 @@ public class ContentRefreshService
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>List of URNs for trending content.</returns>
public async Task<List<string>> RefreshTrendingContentAsync(CancellationToken cancellationToken)
{
var urns = new List<string>();
try
{
var config = Plugin.Instance?.Configuration;
if (config == null || !config.EnableTrendingContent)
{
_logger.LogDebug("Trending content refresh is disabled");
return urns;
return new List<string>();
}
_logger.LogInformation("Refreshing trending content for business unit: {BusinessUnit}", config.BusinessUnit);
return await FetchVideosFromShowsAsync(
config.BusinessUnit.ToLowerString(),
minEpisodeCount: 10,
maxShows: 15,
videosPerShow: 2,
contentType: "trending",
cancellationToken).ConfigureAwait(false);
}
using var apiClient = new SRFApiClient(_loggerFactory);
var businessUnit = config.BusinessUnit.ToString().ToLowerInvariant();
/// <summary>
/// Fetches videos from shows based on filter criteria.
/// </summary>
private async Task<List<string>> FetchVideosFromShowsAsync(
string businessUnit,
int minEpisodeCount,
int maxShows,
int videosPerShow,
string contentType,
CancellationToken cancellationToken)
{
var urns = new List<string>();
// Get all shows from Play v3 API
try
{
_logger.LogInformation("Refreshing {ContentType} content for business unit: {BusinessUnit}", contentType, businessUnit);
using var apiClient = _apiClientFactory.CreateClient();
var shows = await apiClient.GetAllShowsAsync(businessUnit, cancellationToken).ConfigureAwait(false);
if (shows == null || shows.Count == 0)
{
_logger.LogWarning("No shows found for business unit: {BusinessUnit}", config.BusinessUnit);
_logger.LogWarning("No shows found for business unit: {BusinessUnit}", businessUnit);
return urns;
}
_logger.LogInformation("Found {Count} shows, fetching popular content", shows.Count);
_logger.LogInformation("Found {Count} shows, fetching {ContentType} content", shows.Count, contentType);
// Get videos from popular shows (those with many episodes)
var popularShows = shows.Where(s => s.NumberOfEpisodes > 10)
var filteredShows = shows
.Where(s => s.NumberOfEpisodes > minEpisodeCount)
.OrderByDescending(s => s.NumberOfEpisodes)
.Take(15)
.Take(maxShows)
.ToList();
foreach (var show in popularShows)
var now = DateTime.UtcNow;
foreach (var show in filteredShows)
{
if (show.Id == null)
if (show.Id == null || cancellationToken.IsCancellationRequested)
{
continue;
}
@@ -197,110 +124,48 @@ public class ContentRefreshService
try
{
var videos = await apiClient.GetVideosForShowAsync(businessUnit, show.Id, cancellationToken).ConfigureAwait(false);
if (videos != null && videos.Count > 0)
if (videos == null || videos.Count == 0)
{
_logger.LogDebug("Show {Show} ({ShowId}): Found {Count} videos for trending", show.Title, show.Id, videos.Count);
_logger.LogDebug("Show {Show} ({ShowId}): No videos returned from API", show.Title, show.Id);
continue;
}
_logger.LogDebug("Show {Show} ({ShowId}): Found {Count} videos", show.Title, show.Id, videos.Count);
// Filter to videos that are actually published (validFrom in the past)
var now = DateTime.UtcNow;
var publishedVideos = videos.Where(v =>
v.ValidFrom == null || v.ValidFrom.Value.ToUniversalTime() <= now).ToList();
var publishedVideos = videos
.Where(v => v.ValidFrom == null || v.ValidFrom.Value.ToUniversalTime() <= now)
.OrderByDescending(v => v.Date)
.Take(videosPerShow)
.ToList();
_logger.LogDebug("Show {Show}: {PublishedCount} published out of {TotalCount} videos for trending", show.Title, publishedVideos.Count, videos.Count);
if (publishedVideos.Count > 0)
{
// Take 2 recent published videos from each popular show
var recentVideos = publishedVideos.OrderByDescending(v => v.Date).Take(2);
foreach (var video in recentVideos)
foreach (var video in publishedVideos)
{
if (video.Urn != null)
{
urns.Add(video.Urn);
_logger.LogInformation(
"Added trending video from show {Show}: {Title} (URN: {Urn}, Date: {Date}, ValidFrom: {ValidFrom}, ValidTo: {ValidTo})",
_logger.LogDebug(
"Added {ContentType} video from show {Show}: {Title} (URN: {Urn})",
contentType,
show.Title,
video.Title,
video.Urn,
video.Date,
video.ValidFrom,
video.ValidTo);
video.Urn);
}
else
{
_logger.LogWarning("Show {Show}: Trending video has null URN - {Title}", show.Title, video.Title);
}
}
}
}
else
{
_logger.LogDebug("Show {Show} ({ShowId}): No videos returned from API for trending", show.Title, show.Id);
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Error fetching videos for show {ShowId}", show.Id);
}
// Respect cancellation
if (cancellationToken.IsCancellationRequested)
{
break;
}
}
_logger.LogInformation("Refreshed {Count} trending content items from {ShowCount} shows", urns.Count, popularShows.Count);
_logger.LogInformation("Refreshed {Count} {ContentType} content items from {ShowCount} shows", urns.Count, contentType, filteredShows.Count);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error refreshing trending content");
_logger.LogError(ex, "Error refreshing {ContentType} content", contentType);
}
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();
}
}
@@ -0,0 +1,48 @@
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Plugin.SRFPlay.Api.Models;
namespace Jellyfin.Plugin.SRFPlay.Services.Interfaces;
/// <summary>
/// Interface for managing topic/category data and filtering.
/// </summary>
public interface ICategoryService
{
/// <summary>
/// Gets all topics for a business unit.
/// </summary>
/// <param name="businessUnit">The business unit.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>List of topics.</returns>
Task<List<PlayV3Topic>> GetTopicsAsync(string businessUnit, CancellationToken cancellationToken = default);
/// <summary>
/// Gets a topic by ID.
/// </summary>
/// <param name="topicId">The topic ID.</param>
/// <param name="businessUnit">The business unit.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>The topic, or null if not found.</returns>
Task<PlayV3Topic?> GetTopicByIdAsync(string topicId, string businessUnit, CancellationToken cancellationToken = default);
/// <summary>
/// Gets shows for a specific topic, sorted by number of episodes.
/// </summary>
/// <param name="topicId">The topic ID.</param>
/// <param name="businessUnit">The business unit.</param>
/// <param name="maxResults">Maximum number of results to return.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>List of shows for the topic.</returns>
Task<List<PlayV3Show>> GetShowsByTopicAsync(
string topicId,
string businessUnit,
int maxResults = 50,
CancellationToken cancellationToken = default);
/// <summary>
/// Clears the topics cache.
/// </summary>
void ClearCache();
}
@@ -0,0 +1,17 @@
using System.Threading;
using System.Threading.Tasks;
namespace Jellyfin.Plugin.SRFPlay.Services.Interfaces;
/// <summary>
/// Interface for managing content expiration.
/// </summary>
public interface IContentExpirationService
{
/// <summary>
/// Checks for expired content and removes it from the library.
/// </summary>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>The number of items removed.</returns>
Task<int> CheckAndRemoveExpiredContentAsync(CancellationToken cancellationToken);
}
@@ -0,0 +1,25 @@
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
namespace Jellyfin.Plugin.SRFPlay.Services.Interfaces;
/// <summary>
/// Interface for refreshing content from SRF API.
/// </summary>
public interface IContentRefreshService
{
/// <summary>
/// Refreshes latest content from SRF API using Play v3.
/// </summary>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>List of URNs for new content.</returns>
Task<List<string>> RefreshLatestContentAsync(CancellationToken cancellationToken);
/// <summary>
/// Refreshes trending content from SRF API using Play v3.
/// </summary>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>List of URNs for trending content.</returns>
Task<List<string>> RefreshTrendingContentAsync(CancellationToken cancellationToken);
}
@@ -0,0 +1,23 @@
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Plugin.SRFPlay.Api.Models;
namespace Jellyfin.Plugin.SRFPlay.Services.Interfaces;
/// <summary>
/// Interface for fetching media composition with caching support.
/// </summary>
public interface IMediaCompositionFetcher
{
/// <summary>
/// Gets media composition by URN, using cache if available.
/// </summary>
/// <param name="urn">The URN to fetch.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <param name="cacheDurationOverride">Optional override for cache duration (e.g., 5 min for livestreams).</param>
/// <returns>The media composition, or null if not found.</returns>
Task<MediaComposition?> GetMediaCompositionAsync(
string urn,
CancellationToken cancellationToken,
int? cacheDurationOverride = null);
}
@@ -0,0 +1,51 @@
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Plugin.SRFPlay.Api.Models;
using Jellyfin.Plugin.SRFPlay.Configuration;
using MediaBrowser.Model.Dto;
using MediaBrowser.Model.Entities;
namespace Jellyfin.Plugin.SRFPlay.Services.Interfaces;
/// <summary>
/// Factory for creating MediaSourceInfo objects with consistent configuration.
/// </summary>
public interface IMediaSourceFactory
{
/// <summary>
/// Creates a MediaSourceInfo for a chapter with proper stream authentication and proxy registration.
/// </summary>
/// <param name="chapter">The chapter containing stream resources.</param>
/// <param name="itemId">The unique item ID for proxy registration.</param>
/// <param name="urn">The URN of the content.</param>
/// <param name="qualityPreference">The preferred quality.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>A configured MediaSourceInfo, or null if stream URL cannot be resolved.</returns>
Task<MediaSourceInfo?> CreateMediaSourceAsync(
Chapter chapter,
string itemId,
string urn,
QualityPreference qualityPreference,
CancellationToken cancellationToken = default);
/// <summary>
/// Builds a proxy URL for an item.
/// </summary>
/// <param name="itemId">The item ID.</param>
/// <returns>The full proxy URL including server address.</returns>
string BuildProxyUrl(string itemId);
/// <summary>
/// Gets the server base URL (configured public URL or smart URL).
/// </summary>
/// <returns>The server base URL without trailing slash.</returns>
string GetServerBaseUrl();
/// <summary>
/// Creates MediaStream metadata based on quality preference.
/// </summary>
/// <param name="quality">The quality preference.</param>
/// <returns>List of MediaStream objects for video and audio.</returns>
IReadOnlyList<MediaStream> CreateMediaStreams(QualityPreference quality);
}
@@ -0,0 +1,29 @@
using Jellyfin.Plugin.SRFPlay.Api.Models;
namespace Jellyfin.Plugin.SRFPlay.Services.Interfaces;
/// <summary>
/// Interface for caching metadata from SRF API.
/// </summary>
public interface IMetadataCache
{
/// <summary>
/// Gets cached media composition by URN.
/// </summary>
/// <param name="urn">The URN.</param>
/// <param name="cacheDurationMinutes">The cache duration in minutes.</param>
/// <returns>The cached media composition, or null if not found or expired.</returns>
MediaComposition? GetMediaComposition(string urn, int cacheDurationMinutes);
/// <summary>
/// Sets media composition in cache.
/// </summary>
/// <param name="urn">The URN.</param>
/// <param name="mediaComposition">The media composition to cache.</param>
void SetMediaComposition(string urn, MediaComposition mediaComposition);
/// <summary>
/// Clears all cached data.
/// </summary>
void Clear();
}
@@ -0,0 +1,81 @@
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
namespace Jellyfin.Plugin.SRFPlay.Services.Interfaces;
/// <summary>
/// Interface for proxying SRF Play streams and managing authentication.
/// </summary>
public interface IStreamProxyService
{
/// <summary>
/// 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.
/// </summary>
/// <param name="itemId">The item ID.</param>
/// <param name="unauthenticatedUrl">The unauthenticated stream URL.</param>
/// <param name="urn">The SRF URN for this content.</param>
/// <param name="isLiveStream">Whether this is a livestream.</param>
void RegisterStreamDeferred(string itemId, string unauthenticatedUrl, string? urn = null, bool isLiveStream = false);
/// <summary>
/// Gets stream metadata for an item (URN and isLiveStream flag).
/// </summary>
/// <param name="itemId">The item ID.</param>
/// <returns>A tuple of (URN, IsLiveStream), or null if not found.</returns>
(string? Urn, bool IsLiveStream)? GetStreamMetadata(string itemId);
/// <summary>
/// Gets the authenticated URL for an item.
/// </summary>
/// <param name="itemId">The item ID.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The authenticated URL, or null if not found or expired.</returns>
Task<string?> GetAuthenticatedUrlAsync(string itemId, CancellationToken cancellationToken = default);
/// <summary>
/// Fetches and rewrites an HLS manifest to use proxy URLs.
/// </summary>
/// <param name="itemId">The item ID.</param>
/// <param name="baseProxyUrl">The base proxy URL.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The rewritten manifest content.</returns>
Task<string?> GetRewrittenManifestAsync(string itemId, string baseProxyUrl, CancellationToken cancellationToken = default);
/// <summary>
/// Fetches a segment from the original source.
/// </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 (preserves segment-specific parameters like timestamps).</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The segment content as bytes.</returns>
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>
/// Cleans up old and expired stream mappings.
/// </summary>
void CleanupOldMappings();
}
@@ -0,0 +1,42 @@
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Plugin.SRFPlay.Api.Models;
using Jellyfin.Plugin.SRFPlay.Configuration;
namespace Jellyfin.Plugin.SRFPlay.Services.Interfaces;
/// <summary>
/// Interface for resolving stream URLs from media composition resources.
/// </summary>
public interface IStreamUrlResolver
{
/// <summary>
/// Gets the best stream URL from a chapter based on quality preference.
/// </summary>
/// <param name="chapter">The chapter containing resources.</param>
/// <param name="qualityPreference">The quality preference.</param>
/// <returns>The stream URL, or null if no suitable stream found.</returns>
string? GetStreamUrl(Chapter chapter, QualityPreference qualityPreference);
/// <summary>
/// Checks if a chapter has non-DRM playable content.
/// </summary>
/// <param name="chapter">The chapter to check.</param>
/// <returns>True if playable content is available.</returns>
bool HasPlayableContent(Chapter chapter);
/// <summary>
/// Checks if content is expired based on ValidTo date.
/// </summary>
/// <param name="chapter">The chapter to check.</param>
/// <returns>True if the content is expired.</returns>
bool IsContentExpired(Chapter chapter);
/// <summary>
/// Authenticates a stream URL by fetching an Akamai token.
/// </summary>
/// <param name="streamUrl">The unauthenticated stream URL.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The authenticated stream URL with token.</returns>
Task<string> GetAuthenticatedStreamUrlAsync(string streamUrl, CancellationToken cancellationToken = default);
}
@@ -0,0 +1,82 @@
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Plugin.SRFPlay.Api;
using Jellyfin.Plugin.SRFPlay.Api.Models;
using Jellyfin.Plugin.SRFPlay.Services.Interfaces;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.SRFPlay.Services;
/// <summary>
/// Service for fetching media composition with caching support.
/// This consolidates the cache-check → API-fetch → cache-store pattern.
/// </summary>
public class MediaCompositionFetcher : IMediaCompositionFetcher
{
private readonly ILogger<MediaCompositionFetcher> _logger;
private readonly IMetadataCache _metadataCache;
private readonly ISRFApiClientFactory _apiClientFactory;
/// <summary>
/// Initializes a new instance of the <see cref="MediaCompositionFetcher"/> class.
/// </summary>
/// <param name="logger">The logger.</param>
/// <param name="metadataCache">The metadata cache.</param>
/// <param name="apiClientFactory">The API client factory.</param>
public MediaCompositionFetcher(
ILogger<MediaCompositionFetcher> logger,
IMetadataCache metadataCache,
ISRFApiClientFactory apiClientFactory)
{
_logger = logger;
_metadataCache = metadataCache;
_apiClientFactory = apiClientFactory;
}
/// <inheritdoc />
public async Task<MediaComposition?> GetMediaCompositionAsync(
string urn,
CancellationToken cancellationToken,
int? cacheDurationOverride = null)
{
if (string.IsNullOrEmpty(urn))
{
_logger.LogDebug("GetMediaCompositionAsync called with null/empty URN");
return null;
}
var config = Plugin.Instance?.Configuration;
if (config == null)
{
_logger.LogWarning("Plugin configuration is null, cannot fetch media composition");
return null;
}
var cacheDuration = cacheDurationOverride ?? config.CacheDurationMinutes;
// Try cache first
var mediaComposition = _metadataCache.GetMediaComposition(urn, cacheDuration);
if (mediaComposition != null)
{
_logger.LogDebug("Cache hit for URN: {Urn}", urn);
return mediaComposition;
}
// Fetch from API
_logger.LogDebug("Cache miss for URN: {Urn}, fetching from API", urn);
using var apiClient = _apiClientFactory.CreateClient();
mediaComposition = await apiClient.GetMediaCompositionByUrnAsync(urn, cancellationToken).ConfigureAwait(false);
if (mediaComposition != null)
{
_metadataCache.SetMediaComposition(urn, mediaComposition);
_logger.LogDebug("Cached media composition for URN: {Urn}", urn);
}
else
{
_logger.LogWarning("Failed to fetch media composition for URN: {Urn}", urn);
}
return mediaComposition;
}
}
@@ -0,0 +1,175 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Plugin.SRFPlay.Api.Models;
using Jellyfin.Plugin.SRFPlay.Configuration;
using Jellyfin.Plugin.SRFPlay.Constants;
using Jellyfin.Plugin.SRFPlay.Services.Interfaces;
using MediaBrowser.Controller;
using MediaBrowser.Model.Dto;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.MediaInfo;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.SRFPlay.Services;
/// <summary>
/// Factory for creating MediaSourceInfo objects with consistent configuration.
/// Consolidates duplicated logic from SRFMediaProvider and SRFPlayChannel.
/// </summary>
public class MediaSourceFactory : IMediaSourceFactory
{
private readonly ILogger<MediaSourceFactory> _logger;
private readonly IStreamUrlResolver _streamResolver;
private readonly IStreamProxyService _proxyService;
private readonly IServerApplicationHost _appHost;
/// <summary>
/// Initializes a new instance of the <see cref="MediaSourceFactory"/> class.
/// </summary>
/// <param name="logger">The logger.</param>
/// <param name="streamResolver">The stream URL resolver.</param>
/// <param name="proxyService">The stream proxy service.</param>
/// <param name="appHost">The server application host.</param>
public MediaSourceFactory(
ILogger<MediaSourceFactory> logger,
IStreamUrlResolver streamResolver,
IStreamProxyService proxyService,
IServerApplicationHost appHost)
{
_logger = logger;
_streamResolver = streamResolver;
_proxyService = proxyService;
_appHost = appHost;
}
/// <inheritdoc />
public Task<MediaSourceInfo?> CreateMediaSourceAsync(
Chapter chapter,
string itemId,
string urn,
QualityPreference qualityPreference,
CancellationToken cancellationToken = default)
{
// Get stream URL based on quality preference (unauthenticated)
var streamUrl = _streamResolver.GetStreamUrl(chapter, qualityPreference);
if (string.IsNullOrEmpty(streamUrl))
{
_logger.LogWarning("Could not resolve stream URL for chapter: {ChapterId}", chapter.Id);
return Task.FromResult<MediaSourceInfo?>(null);
}
// Detect if this is a live stream
var isLiveStream = chapter.Type == "SCHEDULED_LIVESTREAM" ||
urn.Contains("livestream", StringComparison.OrdinalIgnoreCase);
// Register stream with UNAUTHENTICATED URL - proxy will authenticate on-demand
// This avoids wasting 30-second tokens during category browsing
_proxyService.RegisterStreamDeferred(itemId, streamUrl, urn, isLiveStream);
// Build proxy URL
var proxyUrl = BuildProxyUrl(itemId);
_logger.LogDebug(
"Created media source for {Title} - ItemId: {ItemId}, IsLiveStream: {IsLiveStream}",
chapter.Title,
itemId,
isLiveStream);
// Create MediaSourceInfo with codec info so clients know they can direct play
// Provide MediaStreams with H.264+AAC so Android TV/ExoPlayer doesn't trigger transcoding
var mediaStreams = CreateMediaStreams(qualityPreference);
var mediaSource = new MediaSourceInfo
{
Id = itemId,
Name = chapter.Title,
Path = proxyUrl,
Protocol = MediaProtocol.Http,
// Use "hls" to trigger hls.js player in web client
Container = "hls",
SupportsDirectStream = true,
SupportsDirectPlay = true,
SupportsTranscoding = false,
IsRemote = true,
Type = MediaSourceType.Default,
RunTimeTicks = chapter.Duration > 0 ? TimeSpan.FromMilliseconds(chapter.Duration).Ticks : null,
VideoType = VideoType.VideoFile,
IsInfiniteStream = isLiveStream,
// Don't use RequiresOpening - it forces Jellyfin to transcode which breaks token auth
RequiresOpening = false,
RequiresClosing = false,
// Disable probing - we provide stream info directly
SupportsProbing = false,
ReadAtNativeFramerate = isLiveStream,
// Provide codec info so clients know they can direct play H.264+AAC
MediaStreams = mediaStreams.ToList(),
AnalyzeDurationMs = isLiveStream ? 1000 : 3000,
IgnoreDts = isLiveStream,
IgnoreIndex = isLiveStream,
};
return Task.FromResult<MediaSourceInfo?>(mediaSource);
}
/// <inheritdoc />
public string BuildProxyUrl(string itemId)
{
return $"{GetServerBaseUrl()}{ApiEndpoints.ProxyMasterManifestPath.Replace("{0}", itemId, StringComparison.Ordinal)}";
}
/// <inheritdoc />
public string GetServerBaseUrl()
{
var config = Plugin.Instance?.Configuration;
return config != null && !string.IsNullOrWhiteSpace(config.PublicServerUrl)
? config.PublicServerUrl.TrimEnd('/')
: _appHost.GetSmartApiUrl(string.Empty);
}
/// <inheritdoc />
public IReadOnlyList<MediaStream> CreateMediaStreams(QualityPreference quality)
{
// Set resolution/bitrate based on quality preference
var (width, height, videoBitrate) = quality switch
{
QualityPreference.SD => (1280, 720, 2500000),
QualityPreference.HD => (1920, 1080, 5000000),
_ => (1280, 720, 3000000)
};
return new List<MediaStream>
{
new MediaStream
{
Type = MediaStreamType.Video,
Codec = "h264",
Profile = "high",
Level = 40,
Width = width,
Height = height,
BitRate = videoBitrate,
BitDepth = 8,
IsInterlaced = false,
IsDefault = true,
Index = 0,
IsAVC = true,
PixelFormat = "yuv420p"
},
new MediaStream
{
Type = MediaStreamType.Audio,
Codec = "aac",
Profile = "LC",
Channels = 2,
SampleRate = 48000,
BitRate = 128000,
IsDefault = true,
Index = 1
}
};
}
}
@@ -1,7 +1,7 @@
using System;
using System.Collections.Concurrent;
using System.Threading;
using Jellyfin.Plugin.SRFPlay.Api.Models;
using Jellyfin.Plugin.SRFPlay.Services.Interfaces;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.SRFPlay.Services;
@@ -9,12 +9,10 @@ namespace Jellyfin.Plugin.SRFPlay.Services;
/// <summary>
/// Service for caching metadata from SRF API.
/// </summary>
public sealed class MetadataCache : IDisposable
public sealed class MetadataCache : IMetadataCache
{
private readonly ILogger<MetadataCache> _logger;
private readonly ConcurrentDictionary<string, CacheEntry<MediaComposition>> _mediaCompositionCache;
private readonly ReaderWriterLockSlim _lock;
private bool _disposed;
/// <summary>
/// Initializes a new instance of the <see cref="MetadataCache"/> class.
@@ -24,19 +22,6 @@ public sealed class MetadataCache : IDisposable
{
_logger = logger;
_mediaCompositionCache = new ConcurrentDictionary<string, CacheEntry<MediaComposition>>();
_lock = new ReaderWriterLockSlim();
}
/// <summary>
/// Disposes resources.
/// </summary>
public void Dispose()
{
if (!_disposed)
{
_lock?.Dispose();
_disposed = true;
}
}
/// <summary>
@@ -52,11 +37,6 @@ public sealed class MetadataCache : IDisposable
return null;
}
try
{
_lock.EnterReadLock();
try
{
if (_mediaCompositionCache.TryGetValue(urn, out var entry))
{
if (entry.IsValid(cacheDurationMinutes))
@@ -67,16 +47,6 @@ public sealed class MetadataCache : IDisposable
_logger.LogDebug("Cache entry expired for URN: {Urn}", urn);
}
}
finally
{
_lock.ExitReadLock();
}
}
catch (ObjectDisposedException)
{
return null;
}
return null;
}
@@ -93,108 +63,19 @@ public sealed class MetadataCache : IDisposable
return;
}
try
{
_lock.EnterWriteLock();
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>
/// Clears all cached data.
/// </summary>
public void Clear()
{
try
{
_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>
/// Represents a cached entry with timestamp.
File diff suppressed because it is too large Load Diff
@@ -6,6 +6,9 @@ using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Plugin.SRFPlay.Api.Models;
using Jellyfin.Plugin.SRFPlay.Configuration;
using Jellyfin.Plugin.SRFPlay.Constants;
using Jellyfin.Plugin.SRFPlay.Services.Interfaces;
using MediaBrowser.Common.Net;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.SRFPlay.Services;
@@ -13,20 +16,20 @@ namespace Jellyfin.Plugin.SRFPlay.Services;
/// <summary>
/// Service for resolving stream URLs from media composition resources.
/// </summary>
public class StreamUrlResolver : IDisposable
public class StreamUrlResolver : IStreamUrlResolver
{
private readonly ILogger<StreamUrlResolver> _logger;
private readonly HttpClient _httpClient;
private bool _disposed;
private readonly IHttpClientFactory _httpClientFactory;
/// <summary>
/// Initializes a new instance of the <see cref="StreamUrlResolver"/> class.
/// </summary>
/// <param name="logger">The logger instance.</param>
public StreamUrlResolver(ILogger<StreamUrlResolver> logger)
/// <param name="httpClientFactory">The HTTP client factory.</param>
public StreamUrlResolver(ILogger<StreamUrlResolver> logger, IHttpClientFactory httpClientFactory)
{
_logger = logger;
_httpClient = new HttpClient();
_httpClientFactory = httpClientFactory;
}
/// <summary>
@@ -50,7 +53,7 @@ public class StreamUrlResolver : IDisposable
// Filter out DRM-protected content
var nonDrmResources = chapter.ResourceList
.Where(r => r.DrmList == null || r.DrmList.ToString() == "[]")
.Where(r => r.IsPlayable)
.ToList();
_logger.LogInformation(
@@ -87,6 +90,17 @@ public class StreamUrlResolver : IDisposable
chapter.Id,
hlsResources.Count);
// Log all HLS resources with their quality info to help debug quality selection
foreach (var resource in hlsResources)
{
_logger.LogInformation(
"Available HLS resource - Quality={Quality}, Protocol={Protocol}, Streaming={Streaming}, URL={Url}",
resource.Quality ?? "NULL",
resource.Protocol ?? "NULL",
resource.Streaming ?? "NULL",
resource.Url);
}
if (hlsResources.Count == 0)
{
_logger.LogWarning("No HLS resources found for chapter: {ChapterId}", chapter.Id);
@@ -114,12 +128,16 @@ public class StreamUrlResolver : IDisposable
}
// Select based on quality preference
_logger.LogInformation(
"Selecting stream with quality preference: {QualityPreference}",
qualityPreference);
Resource? selectedResource = qualityPreference switch
{
QualityPreference.HD => SelectHDResource(hlsResources) ?? SelectBestAvailableResource(hlsResources),
QualityPreference.SD => SelectSDResource(hlsResources) ?? SelectBestAvailableResource(hlsResources),
QualityPreference.Auto => SelectBestAvailableResource(hlsResources),
_ => SelectBestAvailableResource(hlsResources)
QualityPreference.Auto => hlsResources.FirstOrDefault(),
_ => hlsResources.FirstOrDefault()
};
if (selectedResource != null)
@@ -127,7 +145,7 @@ public class StreamUrlResolver : IDisposable
_logger.LogDebug(
"Selected stream for chapter {ChapterId}: Quality={Quality}, Protocol={Protocol}, URL={Url}",
chapter.Id,
selectedResource.Quality,
selectedResource.Quality ?? "NULL",
selectedResource.Protocol,
selectedResource.Url);
return selectedResource.Url;
@@ -146,10 +164,33 @@ public class StreamUrlResolver : IDisposable
{
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 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>
@@ -226,11 +267,12 @@ public class StreamUrlResolver : IDisposable
// Build ACL path: /{segment1}/{segment2}/*
var aclPath = $"/{pathSegments[0]}/{pathSegments[1]}/*";
var tokenUrl = $"https://tp.srgssr.ch/akahd/token?acl={Uri.EscapeDataString(aclPath)}";
var tokenUrl = $"{ApiEndpoints.AkamaiTokenEndpoint}?acl={Uri.EscapeDataString(aclPath)}";
_logger.LogDebug("Fetching auth token from: {TokenUrl}", tokenUrl);
var response = await _httpClient.GetAsync(tokenUrl, cancellationToken).ConfigureAwait(false);
using var httpClient = _httpClientFactory.CreateClient(NamedClient.Default);
var response = await httpClient.GetAsync(tokenUrl, cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
var jsonContent = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
@@ -261,28 +303,4 @@ public class StreamUrlResolver : IDisposable
return streamUrl; // Return original URL as fallback
}
}
/// <inheritdoc/>
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}
/// <summary>
/// Releases the unmanaged resources and optionally releases the managed resources.
/// </summary>
/// <param name="disposing">True to release both managed and unmanaged resources; false to release only unmanaged resources.</param>
protected virtual void Dispose(bool disposing)
{
if (!_disposed)
{
if (disposing)
{
_httpClient?.Dispose();
}
_disposed = true;
}
}
}
@@ -0,0 +1,19 @@
using Jellyfin.Plugin.SRFPlay.Configuration;
namespace Jellyfin.Plugin.SRFPlay.Utilities;
/// <summary>
/// Extension methods for common plugin operations.
/// </summary>
public static class Extensions
{
/// <summary>
/// Converts the BusinessUnit enum to its lowercase string representation.
/// </summary>
/// <param name="businessUnit">The business unit.</param>
/// <returns>The lowercase string representation.</returns>
public static string ToLowerString(this BusinessUnit businessUnit)
{
return businessUnit.ToString().ToLowerInvariant();
}
}
@@ -0,0 +1,74 @@
using System;
namespace Jellyfin.Plugin.SRFPlay.Utilities;
/// <summary>
/// Helper class for determining MIME content types.
/// </summary>
public static class MimeTypeHelper
{
/// <summary>
/// Gets the MIME content type for an image based on URL or file extension.
/// </summary>
/// <param name="url">The image URL.</param>
/// <returns>The MIME content type, defaulting to "image/jpeg" for SRF images.</returns>
public static string GetImageContentType(string? url)
{
if (string.IsNullOrEmpty(url))
{
return "image/jpeg";
}
var uri = new Uri(url);
var path = uri.AbsolutePath.ToLowerInvariant();
if (path.EndsWith(".png", StringComparison.Ordinal))
{
return "image/png";
}
if (path.EndsWith(".gif", StringComparison.Ordinal))
{
return "image/gif";
}
if (path.EndsWith(".webp", StringComparison.Ordinal))
{
return "image/webp";
}
if (path.EndsWith(".jpg", StringComparison.Ordinal) || path.EndsWith(".jpeg", StringComparison.Ordinal))
{
return "image/jpeg";
}
// Default to JPEG for SRF images (most common)
return "image/jpeg";
}
/// <summary>
/// Gets the MIME content type for a media segment based on file extension.
/// </summary>
/// <param name="path">The segment path or filename.</param>
/// <returns>The MIME content type.</returns>
public static string GetSegmentContentType(string path)
{
if (path.EndsWith(".ts", StringComparison.OrdinalIgnoreCase))
{
return "video/MP2T";
}
if (path.EndsWith(".mp4", StringComparison.OrdinalIgnoreCase) ||
path.EndsWith(".m4s", StringComparison.OrdinalIgnoreCase))
{
return "video/mp4";
}
if (path.EndsWith(".aac", StringComparison.OrdinalIgnoreCase))
{
return "audio/aac";
}
return "application/octet-stream";
}
}
@@ -0,0 +1,141 @@
using System.IO;
using SkiaSharp;
namespace Jellyfin.Plugin.SRFPlay.Utilities;
/// <summary>
/// Generates placeholder images for content without thumbnails.
/// </summary>
public static class PlaceholderImageGenerator
{
private const int Width = 640;
private const int Height = 360; // 16:9 aspect ratio
/// <summary>
/// Generates a placeholder image with the given text centered.
/// </summary>
/// <param name="text">The text to display (typically channel/show name).</param>
/// <returns>A memory stream containing the PNG image.</returns>
public static MemoryStream GeneratePlaceholder(string text)
{
using var surface = SKSurface.Create(new SKImageInfo(Width, Height));
var canvas = surface.Canvas;
// Dark gradient background
using var backgroundPaint = new SKPaint();
using var shader = SKShader.CreateLinearGradient(
new SKPoint(0, 0),
new SKPoint(Width, Height),
new[] { new SKColor(45, 45, 48), new SKColor(28, 28, 30) },
null,
SKShaderTileMode.Clamp);
backgroundPaint.Shader = shader;
canvas.DrawRect(0, 0, Width, Height, backgroundPaint);
// Text
using var textPaint = new SKPaint
{
Color = SKColors.White,
IsAntialias = true,
TextAlign = SKTextAlign.Center,
Typeface = SKTypeface.FromFamilyName("Arial", SKFontStyle.Bold)
};
// Calculate font size and wrap text if needed
float fontSize = 48;
textPaint.TextSize = fontSize;
var maxWidth = Width * 0.85f;
var maxHeight = Height * 0.8f;
var lines = WrapText(text, textPaint, maxWidth, maxHeight, ref fontSize);
// Draw each line centered
var lineHeight = fontSize * 1.2f;
var totalHeight = lines.Count * lineHeight;
var startY = ((Height - totalHeight) / 2) + fontSize;
foreach (var line in lines)
{
canvas.DrawText(line, Width / 2, startY, textPaint);
startY += lineHeight;
}
// Encode to PNG
using var image = surface.Snapshot();
using var data = image.Encode(SKEncodedImageFormat.Png, 100);
var stream = new MemoryStream();
data.SaveTo(stream);
stream.Position = 0;
return stream;
}
/// <summary>
/// Wraps text to fit within the specified width and height constraints.
/// </summary>
/// <param name="text">The text to wrap.</param>
/// <param name="paint">The paint to use for measuring.</param>
/// <param name="maxWidth">Maximum width for each line.</param>
/// <param name="maxHeight">Maximum total height.</param>
/// <param name="fontSize">Font size (will be adjusted if needed).</param>
/// <returns>List of text lines.</returns>
private static System.Collections.Generic.List<string> WrapText(
string text,
SKPaint paint,
float maxWidth,
float maxHeight,
ref float fontSize)
{
const float minFontSize = 20;
var words = text.Split(' ');
var lines = new System.Collections.Generic.List<string>();
while (fontSize >= minFontSize)
{
paint.TextSize = fontSize;
lines.Clear();
var currentLine = string.Empty;
foreach (var word in words)
{
var testLine = string.IsNullOrEmpty(currentLine) ? word : $"{currentLine} {word}";
var testWidth = paint.MeasureText(testLine);
if (testWidth > maxWidth && !string.IsNullOrEmpty(currentLine))
{
lines.Add(currentLine);
currentLine = word;
}
else
{
currentLine = testLine;
}
}
if (!string.IsNullOrEmpty(currentLine))
{
lines.Add(currentLine);
}
// Check if total height fits
var lineHeight = fontSize * 1.2f;
var totalHeight = lines.Count * lineHeight;
if (totalHeight <= maxHeight)
{
break;
}
// Reduce font size and try again
fontSize -= 2;
}
// If still doesn't fit, just return what we have
if (lines.Count == 0)
{
lines.Add(text);
}
return lines;
}
}
@@ -0,0 +1,27 @@
using System;
using System.Security.Cryptography;
using System.Text;
namespace Jellyfin.Plugin.SRFPlay.Utilities;
/// <summary>
/// Helper class for URN-related operations.
/// </summary>
public static class UrnHelper
{
/// <summary>
/// Generates a deterministic GUID from a URN.
/// This ensures the same URN always produces the same GUID.
/// MD5 is used for non-cryptographic purposes only (generating stable IDs).
/// </summary>
/// <param name="urn">The URN to convert.</param>
/// <returns>A deterministic GUID string.</returns>
#pragma warning disable CA5351 // MD5 is used for non-cryptographic purposes (ID generation)
public static string ToGuid(string urn)
{
var hash = MD5.HashData(Encoding.UTF8.GetBytes(urn));
var guid = new Guid(hash);
return guid.ToString();
}
#pragma warning restore CA5351
}
+79 -135
View File
@@ -1,6 +1,20 @@
# Jellyfin SRF Play Plugin
A Jellyfin plugin for accessing SRF Play (Swiss Radio and Television) video-on-demand content.
A Jellyfin plugin for accessing SRF Play (Swiss Radio and Television) video-on-demand content and live sports streaming.
## Status
**Beta/Alpha** - This plugin has been tested on two Jellyfin instances and is working. Some clients may experience issues with hardware decoding, which appears to be client-specific behavior.
## Quick Install
Add this repository URL in Jellyfin (Dashboard → Plugins → Repositories):
```
https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/raw/branch/master/manifest.json
```
Then install "SRF Play" from the plugin catalog.
## Features
@@ -9,7 +23,7 @@ A Jellyfin plugin for accessing SRF Play (Swiss Radio and Television) video-on-d
- Support for all Swiss broadcasting units (SRF, RTS, RSI, RTR, SWI)
- Automatic content expiration handling
- 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
- Proxy support for routing traffic through alternate gateways
- Smart caching with reduced TTL for upcoming livestreams
@@ -25,95 +39,13 @@ The main channel interface showing the content folders.
Video playback with HLS streaming support and quality selection.
## Project Status
## Testing
### ✅ Completed Components
The plugin includes comprehensive testing:
#### Phase 1: Project Setup
- ✅ Renamed from Template to SRFPlay
- ✅ Updated all namespaces and identifiers
- ✅ Configured plugin metadata (ID, name)
#### Phase 2: Core API Infrastructure
- ✅ API Models (MediaComposition, Chapter, Resource, Show, Episode)
- ✅ SRF API Client with HTTP client wrapper
- ✅ JSON deserialization support
- ✅ Error handling and logging
#### Phase 3: Configuration
- ✅ Business unit selection (SRF/RTS/RSI/RTR/SWI)
- ✅ Quality preferences (Auto/SD/HD)
- ✅ Content refresh intervals
- ✅ Expiration check settings
- ✅ Cache duration configuration
- ✅ HTML configuration page
#### Phase 4: Services
- ✅ Stream URL Resolver
- HLS stream selection
- Quality-based filtering
- DRM content filtering
- Content expiration checking
- Akamai token authentication
- Upcoming livestream detection
- ✅ Metadata Cache Service
- Efficient caching with configurable duration
- Thread-safe with ReaderWriterLockSlim
- IDisposable implementation
- Dynamic TTL for scheduled livestreams
- ✅ Content Expiration Service
- Automatic expiration checking
- Library cleanup of expired content
- Statistics and monitoring
- ✅ Content Refresh Service
- Latest and trending content discovery
- Scheduled sports livestreams
- Automatic cache population
- Recommendations system
#### Phase 5: Content Providers
- ✅ Series Provider (for show metadata)
- ✅ Episode Provider (for episode metadata)
- ✅ Image Provider (for thumbnails and artwork)
- ✅ Media Provider (for playback URLs and HLS streams)
#### Phase 6: Scheduled Tasks
- ✅ Content Refresh Task
- Periodic discovery of new content
- Configurable refresh intervals
- ✅ Expiration Check Task
- Automatic cleanup of expired content
- Configurable check intervals
#### Phase 7: Dependency Injection & Integration
- ✅ Service registration (ServiceRegistrator)
- ✅ Jellyfin provider interfaces implementation
- ✅ Plugin initialization and configuration
#### Phase 8: Live Sports Streaming
- ✅ Play v3 API integration for scheduled livestreams
- ✅ Sports events folder in channel
- ✅ Upcoming event detection and display
- ✅ Akamai token authentication for streams
- ✅ Dynamic cache refresh for live events
- ✅ Event scheduling with ValidFrom/ValidTo handling
- ✅ Automatic stream URL resolution when events go live
### ✅ Build Status
**Successfully compiling!** All code analysis warnings resolved.
### 🧪 Testing Status
- [x] Unit tests (xUnit framework)
- StreamUrlResolver tests
- MetadataCache tests
- More to be added
- [x] API spec validation tests (nightly automated runs)
- All business units (SRF, RTS, RSI, RTR, SWI)
- Response schema validation
- Performance monitoring
- [ ] Integration testing with Jellyfin instance
- [ ] End-to-end playback testing for VOD content
- [ ] Live sports streaming validation
- **Unit tests** (xUnit framework) for core services
- **API spec validation tests** for all business units (SRF, RTS, RSI, RTR, SWI)
- **Integration testing** - Tested on multiple Jellyfin instances with VOD and live sports streaming
**Run tests:**
```bash
@@ -132,13 +64,6 @@ dotnet test --collect:"XPlat Code Coverage"
See [Test Documentation](Jellyfin.Plugin.SRFPlay.Tests/README.md) for more details.
### 📝 Next Steps
1. Test live sports streaming when events are scheduled
2. Verify Akamai token authentication
3. Test with different business units (RTS, RSI, RTR)
4. Add more unit tests for remaining services
5. Performance optimization if needed
## API Information
**Base URL:** `https://il.srgssr.ch/integrationlayer/2.0/`
@@ -187,7 +112,7 @@ dotnet build
The compiled plugin will be in `bin/Debug/net8.0/`
## Installation
## Manual Installation
1. Build the plugin (see above)
2. Copy the compiled DLL to your Jellyfin plugins directory
@@ -201,7 +126,7 @@ The compiled plugin will be in `bin/Debug/net8.0/`
## Configuration
- **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)
- **Expiration Check Interval**: How often to check for expired content (1-168 hours)
- **Cache Duration**: How long to cache metadata (5-1440 minutes)
@@ -230,31 +155,59 @@ If you encounter issues with the plugin:
Jellyfin.Plugin.SRFPlay/
├── Api/
│ ├── Models/ # API response models
│ │ ├── MediaComposition.cs
│ │ ├── Chapter.cs
│ │ ├── Resource.cs
│ │ ├── MediaComposition.cs # Root composition (HasChapters helper)
│ │ ├── Chapter.cs # Video/episode chapter with resources
│ │ ├── Resource.cs # Stream URL entry (IsPlayable helper)
│ │ ├── Show.cs
│ │ ├── Episode.cs
│ │ └── PlayV3/ # Play v3 API models
│ │ ├── PlayV3Show.cs
│ │ ├── PlayV3Topic.cs
│ │ ├── PlayV3Video.cs
│ │ ├── PlayV3TvProgram.cs
│ │ ── PlayV3TvProgramGuideResponse.cs
└── SRFApiClient.cs # HTTP client for SRF API
│ │ ── PlayV3Response.cs
│ ├── PlayV3DirectResponse.cs
│ │ └── PlayV3DataContainer.cs
│ ├── SRFApiClient.cs # HTTP client for SRF APIs
│ ├── ISRFApiClientFactory.cs # Factory interface
│ └── SRFApiClientFactory.cs # Factory implementation
├── Channels/
│ └── SRFPlayChannel.cs # Channel implementation
├── Configuration/
│ ├── PluginConfiguration.cs
│ └── configPage.html
├── Constants/
│ └── ApiEndpoints.cs # API base URLs and endpoint constants
├── Controllers/
│ └── StreamProxyController.cs # HLS proxy endpoints (master/variant/segment)
├── Services/
│ ├── StreamUrlResolver.cs # HLS stream resolution & authentication
│ ├── MetadataCache.cs # Caching layer
│ ├── ContentExpirationService.cs # Expiration management
│ ├── ContentRefreshService.cs # Content discovery
└── CategoryService.cs # Topic/category management
│ ├── Interfaces/ # Service contracts
│ ├── IStreamProxyService.cs
│ ├── IStreamUrlResolver.cs
│ ├── IMediaCompositionFetcher.cs
│ ├── 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/
│ ├── SRFSeriesProvider.cs # Series metadata
│ ├── SRFEpisodeProvider.cs # Episode metadata
│ ├── SRFImageProvider.cs # Image fetching
│ └── SRFMediaProvider.cs # Playback URLs
├── Utilities/
│ ├── Extensions.cs # BusinessUnit.ToLowerString() extension
│ ├── MimeTypeHelper.cs # Content-type detection
│ ├── PlaceholderImageGenerator.cs
│ └── UrnHelper.cs # URN parsing utilities
├── ScheduledTasks/
│ ├── ContentRefreshTask.cs # Periodic content refresh
│ └── ExpirationCheckTask.cs # Periodic expiration check
@@ -264,14 +217,15 @@ Jellyfin.Plugin.SRFPlay/
### Key Components
1. **API Client**: Handles all HTTP requests to SRF Integration Layer and Play v3 API
2. **Channel**: SRF Play channel with Latest, Trending, and Live Sports folders
3. **Stream Resolver**: Extracts and selects optimal HLS streams with Akamai authentication
4. **Configuration**: User-configurable settings via Jellyfin dashboard
5. **Metadata Cache**: Thread-safe caching with dynamic TTL for livestreams
6. **Content Providers**: Jellyfin integration for series, episodes, images, and media sources
7. **Scheduled Tasks**: Automatic content refresh and expiration management
8. **Service Layer**: Content discovery, expiration handling, stream resolution, and category management
1. **API Client** (`SRFApiClient`): HTTP requests to SRF Integration Layer and Play v3 API, with proxy support
2. **Channel** (`SRFPlayChannel`): SRF Play channel with Latest, Trending, and Live Sports folders
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. **Stream Resolver** (`StreamUrlResolver`): Selects optimal HLS stream by quality preference, filters DRM content
5. **Metadata Cache** (`MetadataCache`): Thread-safe `ConcurrentDictionary` cache with dynamic TTL for livestreams
6. **Media Composition Fetcher** (`MediaCompositionFetcher`): Cached wrapper around API client for media composition requests
7. **Content Providers** (`SRFSeriesProvider`, `SRFEpisodeProvider`, `SRFImageProvider`, `SRFMediaProvider`): Jellyfin integration for series, episodes, images, and media sources
8. **Scheduled Tasks**: Automatic content refresh and expiration management
9. **Utilities**: Business unit extensions, MIME type helpers, URN parsing, placeholder image generation
## Important Notes
@@ -297,9 +251,7 @@ Currently focused on SRF but easily extensible.
## Development
### Current Status
All core functionality is implemented and compiling successfully! The plugin includes:
The plugin includes:
- Complete API integration with SRF Play (Integration Layer v2.0 and Play v3)
- **Live sports streaming** with scheduled event detection
- Channel with Latest, Trending, and Live Sports folders
@@ -310,25 +262,15 @@ All core functionality is implemented and compiling successfully! The plugin inc
- Scheduled tasks for content refresh
- Smart caching with dynamic TTL for upcoming livestreams
### Testing
### Known Issues
To test the plugin:
1. Build the plugin: `dotnet build`
2. Copy `bin/Debug/net8.0/Jellyfin.Plugin.SRFPlay.dll` to your Jellyfin plugins directory
3. Restart Jellyfin
4. Configure the plugin in Dashboard → Plugins → SRF Play
5. Add a library with the SRF Play content provider
### Known Limitations
- This is a first version that has not been tested with a live Jellyfin instance yet
- Some edge cases may need handling
- Performance optimization may be needed for large content catalogs
- Some clients may experience issues with hardware decoding (appears to be client-specific)
- Some edge cases may need additional handling
- Performance optimization may be needed for very large content catalogs
### Contributing
This plugin is in active development. Contributions welcome!
Contributions welcome!
## License
@@ -336,7 +278,9 @@ See LICENSE file for details.
## 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
Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

+53
View File
@@ -0,0 +1,53 @@
[
{
"guid": "a4b12f86-8c3d-4e9a-b7f2-1d5e6c8a9b4f",
"name": "SRF Play",
"description": "SRF Play plugin for Jellyfin enables streaming of content from SRF (Swiss Radio and Television). Access live TV channels and on-demand content from Switzerland's German-language public broadcaster directly within Jellyfin.",
"overview": "Stream SRF content in Jellyfin",
"owner": "dtourolle",
"category": "Live TV",
"imageUrl": "https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/raw/branch/master/assests/main%20logo.png",
"versions": [
{
"version": "1.0.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",
"changelog": "Release 1.0.14",
"targetAbi": "10.9.0.0",
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/releases/download/v1.0.14/srfplay_1.0.14.0.zip",
"checksum": "49e6afe16f7abf95c099ecc1d016e725",
"timestamp": "2025-12-30T12:38:41Z"
},
{
"version": "1.0.13",
"changelog": "Release 1.0.13",
"targetAbi": "10.9.0.0",
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/releases/download/v1.0.13/srfplay_1.0.13.0.zip",
"checksum": "ac7c1e33c926c21f8e4319da91ef079c",
"timestamp": "2025-12-21T13:02:37Z"
},
{
"version": "1.0.12",
"changelog": "Release 1.0.12",
"targetAbi": "10.9.0.0",
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/releases/download/v1.0.12/srfplay_1.0.12.0.zip",
"checksum": "47baa02ade413253db94fe3ba0763f69",
"timestamp": "2025-12-20T13:35:38Z"
}
]
}
]