Compare commits

...
17 Commits
Author SHA1 Message Date
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
41 changed files with 1025 additions and 965 deletions
+4 -15
View File
@@ -15,15 +15,14 @@ on:
jobs:
build:
runs-on: ubuntu-latest
runs-on: ubuntu-22.04
container:
image: gitea.tourolle.paris/dtourolle/srfplay-builder:latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Verify .NET installation
run: dotnet --version
- name: Restore dependencies
run: dotnet restore Jellyfin.Plugin.SRFPlay.sln
@@ -33,21 +32,11 @@ jobs:
- name: Run tests
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
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
jprm --verbosity=debug plugin build .
ARTIFACT=$(find . -name "*.zip" -type f -print -quit)
echo "artifact=${ARTIFACT}" >> $GITHUB_OUTPUT
echo "Found artifact: ${ARTIFACT}"
+46 -15
View File
@@ -13,15 +13,14 @@ on:
jobs:
build-and-release:
runs-on: ubuntu-latest
runs-on: ubuntu-22.04
container:
image: gitea.tourolle.paris/dtourolle/srfplay-builder:latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Verify .NET installation
run: dotnet --version
- name: Get version
id: get_version
run: |
@@ -49,27 +48,24 @@ jobs:
- name: Run tests
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
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
jprm --verbosity=debug plugin build ./
ARTIFACT=$(find . -name "*.zip" -type f -print -quit)
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
run: |
CHECKSUM=$(md5sum "${{ steps.jprm.outputs.artifact }}" | awk '{print $1}')
echo "checksum=${CHECKSUM}" >> $GITHUB_OUTPUT
echo "Checksum: ${CHECKSUM}"
- name: Create Release
env:
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -126,3 +122,38 @@ 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
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
+3 -4
View File
@@ -17,15 +17,14 @@ on:
jobs:
test:
runs-on: ubuntu-latest
runs-on: ubuntu-22.04
container:
image: gitea.tourolle.paris/dtourolle/srfplay-builder:latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Verify .NET installation
run: dotnet --version
- name: Restore dependencies
run: dotnet restore Jellyfin.Plugin.SRFPlay.sln
+1
View File
@@ -3,3 +3,4 @@ obj/
.vs/
.idea/
artifacts
*.log
+17
View File
@@ -0,0 +1,17 @@
# 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 \
&& rm -rf /var/lib/apt/lists/*
RUN pip install --break-system-packages jprm
WORKDIR /src
@@ -115,7 +115,7 @@ namespace Jellyfin.Plugin.SRFPlay.Tests
if (chapter.ResourceList != null && chapter.ResourceList.Any())
{
var hlsResource = chapter.ResourceList.FirstOrDefault(r =>
r.Protocol == "HLS" && (r.DrmList == null || r.DrmList.ToString() == "[]"));
r.Protocol == "HLS" && r.IsPlayable);
if (hlsResource != null)
{
@@ -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() == "[]";
}
+51 -110
View File
@@ -2,6 +2,8 @@ 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;
@@ -51,10 +53,23 @@ public class SRFApiClient : IDisposable
_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>
@@ -110,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}",
@@ -184,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 };
@@ -211,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);
}
@@ -231,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.
@@ -270,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;
}
}
@@ -334,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.
@@ -370,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;
}
}
@@ -419,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);
@@ -459,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);
@@ -2,13 +2,13 @@ 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.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;
@@ -81,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
{
@@ -107,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
});
}
@@ -127,12 +136,12 @@ public class SRFPlayChannel : IChannel, IHasCacheKey
/// <inheritdoc />
public async Task<ChannelItemResult> GetChannelItems(InternalChannelItemQuery query, CancellationToken cancellationToken)
{
_logger.LogInformation("=== GetChannelItems called! FolderId: {FolderId} ===", query.FolderId);
_logger.LogDebug("GetChannelItems called for folder {FolderId}", query.FolderId);
try
{
var items = await GetFolderItemsAsync(query.FolderId, cancellationToken).ConfigureAwait(false);
_logger.LogInformation("Returning {Count} channel items for folder {FolderId}", items.Count, query.FolderId);
_logger.LogDebug("Returning {Count} channel items for folder {FolderId}", items.Count, query.FolderId);
return new ChannelItemResult
{
@@ -181,7 +190,7 @@ public class SRFPlayChannel : IChannel, IHasCacheKey
{
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)))
@@ -191,7 +200,9 @@ public class SRFPlayChannel : IChannel, IHasCacheKey
continue;
}
items.Add(CreateFolder($"category_{topic.Id}", topic.Title ?? topic.Id!, topic.Lead));
// Generate placeholder image for topic
var placeholderUrl = CreatePlaceholderImageUrl(topic.Title ?? topic.Id!, _mediaSourceFactory.GetServerBaseUrl());
items.Add(CreateFolder($"category_{topic.Id}", topic.Title ?? topic.Id!, topic.Lead, placeholderUrl));
}
_logger.LogInformation("Added {Count} category folders", topics.Count);
@@ -205,7 +216,7 @@ public class SRFPlayChannel : IChannel, IHasCacheKey
return items;
}
private static ChannelItemInfo CreateFolder(string id, string name, string? overview = null)
private static ChannelItemInfo CreateFolder(string id, string name, string? overview = null, string? imageUrl = null)
{
return new ChannelItemInfo
{
@@ -213,7 +224,7 @@ public class SRFPlayChannel : IChannel, IHasCacheKey
Name = name,
Type = ChannelItemType.Folder,
FolderType = ChannelFolderType.Container,
ImageUrl = null,
ImageUrl = imageUrl,
Overview = overview
};
}
@@ -237,7 +248,7 @@ public class SRFPlayChannel : IChannel, IHasCacheKey
try
{
var businessUnit = config?.BusinessUnit.ToString().ToLowerInvariant() ?? "srf";
var businessUnit = config?.BusinessUnit.ToLowerString() ?? "srf";
using var apiClient = _apiClientFactory.CreateClient();
var scheduledLivestreams = await apiClient.GetScheduledLivestreamsAsync(businessUnit, "SPORT", cancellationToken).ConfigureAwait(false);
@@ -299,7 +310,7 @@ public class SRFPlayChannel : IChannel, IHasCacheKey
{
var config = Plugin.Instance?.Configuration;
var topicId = folderId.Substring("category_".Length);
var businessUnit = config?.BusinessUnit.ToString().ToLowerInvariant() ?? "srf";
var businessUnit = config?.BusinessUnit.ToLowerString() ?? "srf";
var shows = await _categoryService.GetShowsByTopicAsync(topicId, businessUnit, 20, cancellationToken).ConfigureAwait(false);
var urns = new List<string>();
@@ -393,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)
@@ -423,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++;
@@ -449,7 +466,7 @@ public class SRFPlayChannel : IChannel, IHasCacheKey
}
// Generate deterministic GUID from URN
var itemId = UrnToGuid(urn);
var itemId = UrnHelper.ToGuid(urn);
// Use factory to create MediaSourceInfo (handles stream URL, auth, proxy registration)
var mediaSource = await _mediaSourceFactory.CreateMediaSourceAsync(
@@ -485,21 +502,36 @@ public class SRFPlayChannel : IChannel, IHasCacheKey
// Build overview
var overview = chapter.Description ?? chapter.Lead;
// Get image URL - prefer chapter image, fall back to show image if available
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);
}
// Determine image URL based on configuration
string? imageUrl;
var generateTitleCards = config.GenerateTitleCards;
if (string.IsNullOrEmpty(originalImageUrl))
if (generateTitleCards)
{
_logger.LogWarning("URN {Urn}: No image URL available for '{Title}'", urn, chapter.Title);
// 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);
}
// Proxy image URL to fix Content-Type headers from SRF CDN
var imageUrl = CreateProxiedImageUrl(originalImageUrl, _mediaSourceFactory.GetServerBaseUrl());
if (string.IsNullOrEmpty(originalImageUrl))
{
_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();
@@ -557,25 +589,6 @@ public class SRFPlayChannel : IChannel, IHasCacheKey
return items;
}
/// <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).
/// </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)
{
// 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();
}
#pragma warning restore CA5351
/// <summary>
/// Creates a proxy URL for an image to fix Content-Type headers from SRF CDN.
/// </summary>
@@ -594,6 +607,18 @@ public class SRFPlayChannel : IChannel, IHasCacheKey
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}";
}
/// <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>
@@ -149,4 +150,10 @@ public class PluginConfiguration : BasePluginConfiguration
/// 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">
@@ -117,6 +124,7 @@
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 || '';
@@ -137,6 +145,7 @@
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;
@@ -28,12 +28,6 @@ public static class ApiEndpoints
/// </summary>
public const string SrfPlayHomepage = "https://www.srf.ch/play";
/// <summary>
/// Media composition endpoint template (relative to IntegrationLayerBaseUrl).
/// Format: {0} = URN.
/// </summary>
public const string MediaCompositionByUrnPath = "/mediaComposition/byUrn/{0}.json";
/// <summary>
/// Base proxy route for stream proxying (relative to server root).
/// </summary>
@@ -44,30 +38,4 @@ public static class ApiEndpoints
/// Format: {0} = item ID.
/// </summary>
public const string ProxyMasterManifestPath = "/Plugins/SRFPlay/Proxy/{0}/master.m3u8";
/// <summary>
/// Image proxy route template (relative to server root).
/// Format: {0} = base64-encoded original URL.
/// </summary>
public const string ImageProxyPath = "/Plugins/SRFPlay/Image/{0}";
/// <summary>
/// Play V3 shows endpoint (relative to PlayV3 base URL).
/// </summary>
public const string PlayV3ShowsPath = "shows";
/// <summary>
/// Play V3 topics endpoint (relative to PlayV3 base URL).
/// </summary>
public const string PlayV3TopicsPath = "topics";
/// <summary>
/// Play V3 livestreams endpoint (relative to PlayV3 base URL).
/// </summary>
public const string PlayV3LivestreamsPath = "livestreams";
/// <summary>
/// Play V3 scheduled livestreams endpoint (relative to PlayV3 base URL).
/// </summary>
public const string PlayV3ScheduledLivestreamsPath = "scheduled-livestreams";
}
@@ -5,6 +5,7 @@ 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;
@@ -57,16 +58,28 @@ public class StreamProxyController : ControllerBase
/// Livestreams need frequent manifest refresh, VOD can be cached longer.
/// </summary>
/// <param name="itemId">The item ID to check.</param>
private void AddManifestCacheHeaders(string itemId)
/// <param name="isVariantManifest">Whether this is a variant (sub) manifest with segment lists.</param>
private void AddManifestCacheHeaders(string itemId, bool isVariantManifest = false)
{
var metadata = _proxyService.GetStreamMetadata(itemId);
var isLiveStream = metadata?.IsLiveStream ?? false;
if (isLiveStream)
{
// Livestreams need frequent manifest refresh (segments rotate every ~6-10s)
Response.Headers["Cache-Control"] = "max-age=2, must-revalidate";
_logger.LogDebug("Setting livestream cache headers for {ItemId}", itemId);
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
{
@@ -147,7 +160,7 @@ public class StreamProxyController : ControllerBase
AddManifestCacheHeaders(actualItemId);
_logger.LogDebug("Returning master manifest for item {ItemId} ({Length} bytes)", itemId, manifestContent.Length);
return Content(manifestContent, "application/vnd.apple.mpegurl");
return Content(manifestContent, "application/vnd.apple.mpegurl; charset=utf-8");
}
catch (Exception ex)
{
@@ -182,7 +195,8 @@ public class StreamProxyController : ControllerBase
try
{
// Fetch the variant manifest as a segment
var manifestData = await _proxyService.GetSegmentAsync(actualItemId, fullPath, cancellationToken).ConfigureAwait(false);
var queryString = Request.QueryString.HasValue ? Request.QueryString.Value : null;
var manifestData = await _proxyService.GetSegmentAsync(actualItemId, fullPath, queryString, cancellationToken).ConfigureAwait(false);
if (manifestData == null)
{
@@ -194,13 +208,30 @@ public class StreamProxyController : ControllerBase
var manifestContent = System.Text.Encoding.UTF8.GetString(manifestData);
var scheme = GetProxyScheme();
var baseProxyUrl = $"{scheme}://{Request.Host}/Plugins/SRFPlay/Proxy/{itemId}";
var rewrittenContent = RewriteSegmentUrls(manifestContent, baseProxyUrl);
// Extract query parameters from the current request to propagate them
string queryParams;
if (Request.Query.TryGetValue("token", out var tokenVal) && !string.IsNullOrEmpty(tokenVal))
{
queryParams = $"?token={tokenVal}";
}
else if (Request.Query.TryGetValue("itemId", out var itemIdVal) && !string.IsNullOrEmpty(itemIdVal))
{
queryParams = $"?itemId={itemIdVal}";
}
else
{
queryParams = string.Empty;
}
var rewrittenContent = _proxyService.RewriteVariantManifestUrls(manifestContent, baseProxyUrl, queryParams);
// Set cache headers based on stream type (live vs VOD)
AddManifestCacheHeaders(actualItemId);
// Variant manifests use stricter no-cache for live streams
AddManifestCacheHeaders(actualItemId, isVariantManifest: true);
_logger.LogDebug("Returning variant manifest for item {ItemId} ({Length} bytes)", itemId, rewrittenContent.Length);
return Content(rewrittenContent, "application/vnd.apple.mpegurl");
return Content(rewrittenContent, "application/vnd.apple.mpegurl; charset=utf-8");
}
catch (Exception ex)
{
@@ -233,19 +264,33 @@ public class StreamProxyController : ControllerBase
try
{
var segmentData = await _proxyService.GetSegmentAsync(actualItemId, segmentPath, cancellationToken).ConfigureAwait(false);
// Pass the original query string to preserve segment-specific parameters (e.g., ?m=timestamp)
var queryString = Request.QueryString.HasValue ? Request.QueryString.Value : null;
if (segmentData == null)
// Use streaming proxy: starts forwarding data to the client before the full segment
// is downloaded from the CDN, reducing time-to-first-byte for live streams
using var upstreamResponse = await _proxyService.GetSegmentStreamAsync(actualItemId, segmentPath, queryString, cancellationToken).ConfigureAwait(false);
if (upstreamResponse == null)
{
_logger.LogWarning("Segment not found - ItemId: {ItemId}, Path: {SegmentPath}", itemId, segmentPath);
return NotFound();
}
// Determine content type based on file extension
var contentType = GetContentType(segmentPath);
var contentType = MimeTypeHelper.GetSegmentContentType(segmentPath);
Response.ContentType = contentType;
_logger.LogDebug("Returning segment {SegmentPath} ({Length} bytes, {ContentType})", segmentPath, segmentData.Length, contentType);
return File(segmentData, 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)
{
@@ -307,107 +352,6 @@ public class StreamProxyController : ControllerBase
return pathItemId;
}
/// <summary>
/// Rewrites segment URLs in a manifest to point to proxy.
/// </summary>
/// <param name="manifestContent">The manifest content.</param>
/// <param name="baseProxyUrl">The base proxy URL.</param>
/// <returns>The rewritten manifest.</returns>
private string RewriteSegmentUrls(string manifestContent, string baseProxyUrl)
{
// Extract query parameters from the current request to propagate them
string queryParams;
if (Request.Query.TryGetValue("token", out var token) && !string.IsNullOrEmpty(token))
{
queryParams = $"?token={token}";
}
else if (Request.Query.TryGetValue("itemId", out var itemId) && !string.IsNullOrEmpty(itemId))
{
queryParams = $"?itemId={itemId}";
}
else
{
queryParams = string.Empty;
}
// Helper function to rewrite a single URL
string RewriteUrl(string url)
{
if (url.Contains("://", StringComparison.Ordinal))
{
// Absolute URL - extract filename and rewrite
var uri = new Uri(url.Trim());
var segments = uri.AbsolutePath.Split('/');
var fileName = segments[^1];
return $"{baseProxyUrl}/{fileName}{queryParams}";
}
// Relative URL - rewrite to proxy
return $"{baseProxyUrl}/{url.Trim()}{queryParams}";
}
var lines = manifestContent.Split('\n');
var result = new System.Text.StringBuilder();
foreach (var line in lines)
{
if (string.IsNullOrWhiteSpace(line))
{
result.AppendLine(line);
}
else if (line.StartsWith('#'))
{
// HLS tag line - check for URI="..." attributes (e.g., #EXT-X-MAP:URI="init.mp4")
if (line.Contains("URI=\"", StringComparison.Ordinal))
{
var rewrittenLine = System.Text.RegularExpressions.Regex.Replace(
line,
@"URI=""([^""]+)""",
match => $"URI=\"{RewriteUrl(match.Groups[1].Value)}\"");
result.AppendLine(rewrittenLine);
}
else
{
// Keep other metadata lines as-is
result.AppendLine(line);
}
}
else
{
// Non-tag line with URL - rewrite it
result.AppendLine(RewriteUrl(line));
}
}
return result.ToString();
}
/// <summary>
/// Gets the content type for a segment based on file extension.
/// </summary>
/// <param name="path">The segment path.</param>
/// <returns>The MIME content type.</returns>
private static string GetContentType(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";
}
/// <summary>
/// Proxies image requests from SRF CDN, fixing Content-Type headers.
/// </summary>
@@ -471,7 +415,7 @@ public class StreamProxyController : ControllerBase
.ConfigureAwait(false);
// Determine correct content type
var contentType = GetImageContentType(decodedUrl);
var contentType = MimeTypeHelper.GetImageContentType(decodedUrl);
_logger.LogDebug(
"Returning proxied image ({Length} bytes, {ContentType})",
@@ -488,36 +432,36 @@ public class StreamProxyController : ControllerBase
}
/// <summary>
/// Gets the content type for an image based on URL or file extension.
/// Generates a placeholder image with the given text centered.
/// </summary>
/// <param name="url">The image URL.</param>
/// <returns>The MIME content type.</returns>
private static string GetImageContentType(string url)
/// <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(url))
if (string.IsNullOrEmpty(text))
{
return "image/jpeg";
return BadRequest("Text parameter is required");
}
var uri = new Uri(url);
var path = uri.AbsolutePath.ToLowerInvariant();
if (path.EndsWith(".png", StringComparison.Ordinal))
string decodedText;
try
{
return "image/png";
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");
}
if (path.EndsWith(".gif", StringComparison.Ordinal))
{
return "image/gif";
}
_logger.LogDebug("Generating placeholder image for: {Text}", decodedText);
if (path.EndsWith(".webp", StringComparison.Ordinal))
{
return "image/webp";
}
// Default to JPEG for SRF images (most common)
return "image/jpeg";
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,6 +1,5 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
@@ -19,22 +18,18 @@ namespace Jellyfin.Plugin.SRFPlay.Providers;
public class SRFEpisodeProvider : IRemoteMetadataProvider<Episode, EpisodeInfo>
{
private readonly ILogger<SRFEpisodeProvider> _logger;
private readonly IHttpClientFactory _httpClientFactory;
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="logger">The logger.</param>
/// <param name="compositionFetcher">The media composition fetcher.</param>
public SRFEpisodeProvider(
ILoggerFactory loggerFactory,
IHttpClientFactory httpClientFactory,
ILogger<SRFEpisodeProvider> logger,
IMediaCompositionFetcher compositionFetcher)
{
_logger = loggerFactory.CreateLogger<SRFEpisodeProvider>();
_httpClientFactory = httpClientFactory;
_logger = logger;
_compositionFetcher = compositionFetcher;
}
@@ -55,7 +50,7 @@ public class SRFEpisodeProvider : IRemoteMetadataProvider<Episode, EpisodeInfo>
var mediaComposition = await _compositionFetcher.GetMediaCompositionAsync(urn, cancellationToken).ConfigureAwait(false);
if (mediaComposition?.ChapterList != null && mediaComposition.ChapterList.Count > 0)
if (mediaComposition?.HasChapters == true)
{
var chapter = mediaComposition.ChapterList[0];
results.Add(new RemoteSearchResult
@@ -98,7 +93,7 @@ public class SRFEpisodeProvider : IRemoteMetadataProvider<Episode, EpisodeInfo>
var mediaComposition = await _compositionFetcher.GetMediaCompositionAsync(urn, cancellationToken).ConfigureAwait(false);
if (mediaComposition?.ChapterList == null || mediaComposition.ChapterList.Count == 0)
if (mediaComposition?.HasChapters != true)
{
_logger.LogWarning("No chapter information found for URN: {Urn}", urn);
return result;
@@ -5,6 +5,7 @@ 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 MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Providers;
@@ -91,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))
@@ -186,7 +187,7 @@ public class SRFImageProvider : IRemoteImageProvider, IHasOrder
if (needsContentTypeFix)
{
// Determine correct content type from URL extension or default to JPEG
var contentType = GetContentTypeFromUrl(url);
var contentType = MimeTypeHelper.GetImageContentType(url);
if (!string.IsNullOrEmpty(contentType))
{
_logger.LogInformation(
@@ -201,42 +202,4 @@ public class SRFImageProvider : IRemoteImageProvider, IHasOrder
return response;
}
/// <summary>
/// Determines the correct content type based on URL file extension.
/// </summary>
private static string? GetContentTypeFromUrl(string url)
{
if (string.IsNullOrEmpty(url))
{
return null;
}
// Get the file extension from the URL (ignore query string)
var uri = new Uri(url);
var path = uri.AbsolutePath.ToLowerInvariant();
if (path.EndsWith(".jpg", StringComparison.Ordinal) || path.EndsWith(".jpeg", StringComparison.Ordinal))
{
return "image/jpeg";
}
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";
}
// Default to JPEG for SRF images (most common)
return "image/jpeg";
}
}
@@ -70,7 +70,7 @@ public class SRFMediaProvider : IMediaSourceProvider
var mediaComposition = await _compositionFetcher.GetMediaCompositionAsync(urn, cancellationToken, cacheDuration).ConfigureAwait(false);
if (mediaComposition?.ChapterList == null || mediaComposition.ChapterList.Count == 0)
if (mediaComposition?.HasChapters != true)
{
_logger.LogWarning("No chapters found for URN: {Urn}", urn);
return sources;
@@ -116,7 +116,7 @@ public class SRFMediaProvider : IMediaSourceProvider
// 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];
mediaSource = await _mediaSourceFactory.CreateMediaSourceAsync(
@@ -1,13 +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.Services.Interfaces;
using MediaBrowser.Controller.Entities.TV;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.Providers;
using Microsoft.Extensions.Logging;
@@ -19,22 +17,18 @@ namespace Jellyfin.Plugin.SRFPlay.Providers;
public class SRFSeriesProvider : IRemoteMetadataProvider<Series, SeriesInfo>
{
private readonly ILogger<SRFSeriesProvider> _logger;
private readonly IHttpClientFactory _httpClientFactory;
private readonly IMediaCompositionFetcher _compositionFetcher;
/// <summary>
/// Initializes a new instance of the <see cref="SRFSeriesProvider"/> class.
/// </summary>
/// <param name="loggerFactory">The logger factory.</param>
/// <param name="httpClientFactory">The HTTP client factory.</param>
/// <param name="logger">The logger.</param>
/// <param name="compositionFetcher">The media composition fetcher.</param>
public SRFSeriesProvider(
ILoggerFactory loggerFactory,
IHttpClientFactory httpClientFactory,
ILogger<SRFSeriesProvider> logger,
IMediaCompositionFetcher compositionFetcher)
{
_logger = loggerFactory.CreateLogger<SRFSeriesProvider>();
_httpClientFactory = httpClientFactory;
_logger = logger;
_compositionFetcher = compositionFetcher;
}
@@ -15,18 +15,22 @@ public class ExpirationCheckTask : IScheduledTask
{
private readonly ILogger<ExpirationCheckTask> _logger;
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,
IContentExpirationService 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);
}
@@ -1,6 +1,5 @@
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
@@ -16,8 +15,8 @@ namespace Jellyfin.Plugin.SRFPlay.Services;
/// </summary>
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;
@@ -25,19 +24,15 @@ public class CategoryService : ICategoryService
/// <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
@@ -48,7 +43,7 @@ public class CategoryService : ICategoryService
}
_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)
@@ -65,13 +60,7 @@ public class CategoryService : ICategoryService
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
@@ -83,70 +72,14 @@ public class CategoryService : ICategoryService
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)
@@ -165,43 +98,29 @@ public class CategoryService : ICategoryService
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();
}
}
@@ -118,7 +118,7 @@ public class ContentExpirationService : IContentExpirationService
var mediaComposition = await _compositionFetcher.GetMediaCompositionAsync(urn, cancellationToken).ConfigureAwait(false);
if (mediaComposition?.ChapterList == null || mediaComposition.ChapterList.Count == 0)
if (mediaComposition?.HasChapters != true)
{
// If we can't fetch the content, consider it expired
_logger.LogWarning("Could not fetch media composition for URN: {Urn}, treating as expired", urn);
@@ -135,72 +135,4 @@ public class ContentExpirationService : IContentExpirationService
return isExpired;
}
/// <summary>
/// Gets statistics about content expiration.
/// </summary>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>Tuple with total count, expired count, and items expiring soon.</returns>
public async Task<(int Total, int Expired, int ExpiringSoon)> GetExpirationStatisticsAsync(CancellationToken cancellationToken)
{
var total = 0;
var expired = 0;
var expiringSoon = 0;
var soonThreshold = DateTime.UtcNow.AddDays(7); // Items expiring within 7 days
try
{
var query = new InternalItemsQuery
{
HasAnyProviderId = new Dictionary<string, string> { { "SRF", string.Empty } },
IsVirtualItem = false
};
var items = _libraryManager.GetItemList(query);
total = items.Count;
foreach (var item in items)
{
if (cancellationToken.IsCancellationRequested)
{
break;
}
try
{
var urn = item.ProviderIds.GetValueOrDefault("SRF");
if (string.IsNullOrEmpty(urn))
{
continue;
}
var mediaComposition = await _compositionFetcher.GetMediaCompositionAsync(urn, cancellationToken).ConfigureAwait(false);
if (mediaComposition?.ChapterList != null && mediaComposition.ChapterList.Count > 0)
{
var chapter = mediaComposition.ChapterList[0];
if (_streamResolver.IsContentExpired(chapter))
{
expired++;
}
else if (chapter.ValidTo.HasValue && chapter.ValidTo.Value.ToUniversalTime() <= soonThreshold)
{
expiringSoon++;
}
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error checking expiration statistics for item: {Name}", item.Name);
}
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error getting expiration statistics");
}
return (total, expired, expiringSoon);
}
}
@@ -5,6 +5,7 @@ using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Plugin.SRFPlay.Api;
using Jellyfin.Plugin.SRFPlay.Services.Interfaces;
using Jellyfin.Plugin.SRFPlay.Utilities;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.SRFPlay.Services;
@@ -45,7 +46,7 @@ public class ContentRefreshService : IContentRefreshService
}
return await FetchVideosFromShowsAsync(
config.BusinessUnit.ToString().ToLowerInvariant(),
config.BusinessUnit.ToLowerString(),
minEpisodeCount: 0,
maxShows: 20,
videosPerShow: 1,
@@ -69,7 +70,7 @@ public class ContentRefreshService : IContentRefreshService
}
return await FetchVideosFromShowsAsync(
config.BusinessUnit.ToString().ToLowerInvariant(),
config.BusinessUnit.ToLowerString(),
minEpisodeCount: 10,
maxShows: 15,
videosPerShow: 2,
@@ -167,48 +168,4 @@ public class ContentRefreshService : IContentRefreshService
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();
}
}
@@ -27,21 +27,6 @@ public interface ICategoryService
/// <returns>The topic, or null if not found.</returns>
Task<PlayV3Topic?> GetTopicByIdAsync(string topicId, string businessUnit, CancellationToken cancellationToken = default);
/// <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>
IReadOnlyList<PlayV3Show> FilterShowsByTopic(IReadOnlyList<PlayV3Show> shows, string topicId);
/// <summary>
/// Groups shows by their topics.
/// </summary>
/// <param name="shows">The shows to group.</param>
/// <returns>Dictionary mapping topic IDs to shows.</returns>
IReadOnlyDictionary<string, List<PlayV3Show>> GroupShowsByTopics(IReadOnlyList<PlayV3Show> shows);
/// <summary>
/// Gets shows for a specific topic, sorted by number of episodes.
/// </summary>
@@ -56,13 +41,6 @@ public interface ICategoryService
int maxResults = 50,
CancellationToken cancellationToken = default);
/// <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>
IReadOnlyDictionary<string, int> GetVideoCountByTopic(IReadOnlyList<PlayV3Show> shows);
/// <summary>
/// Clears the topics cache.
/// </summary>
@@ -14,11 +14,4 @@ public interface IContentExpirationService
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>The number of items removed.</returns>
Task<int> CheckAndRemoveExpiredContentAsync(CancellationToken cancellationToken);
/// <summary>
/// Gets statistics about content expiration.
/// </summary>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>Tuple with total count, expired count, and items expiring soon.</returns>
Task<(int Total, int Expired, int ExpiringSoon)> GetExpirationStatisticsAsync(CancellationToken cancellationToken);
}
@@ -22,18 +22,4 @@ public interface IContentRefreshService
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>List of URNs for trending content.</returns>
Task<List<string>> RefreshTrendingContentAsync(CancellationToken cancellationToken);
/// <summary>
/// Refreshes all content (latest and trending).
/// </summary>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>Tuple with counts of latest and trending items.</returns>
Task<(int LatestCount, int TrendingCount)> RefreshAllContentAsync(CancellationToken cancellationToken);
/// <summary>
/// Gets content recommendations (combines latest and trending).
/// </summary>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>List of recommended URNs.</returns>
Task<List<string>> GetRecommendedContentAsync(CancellationToken cancellationToken);
}
@@ -22,20 +22,8 @@ public interface IMetadataCache
/// <param name="mediaComposition">The media composition to cache.</param>
void SetMediaComposition(string urn, MediaComposition mediaComposition);
/// <summary>
/// Removes media composition from cache.
/// </summary>
/// <param name="urn">The URN.</param>
void RemoveMediaComposition(string urn);
/// <summary>
/// Clears all cached data.
/// </summary>
void Clear();
/// <summary>
/// Gets the cache statistics.
/// </summary>
/// <returns>A tuple with cache count and size estimate.</returns>
(int Count, long SizeEstimate) GetStatistics();
}
@@ -1,3 +1,4 @@
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
@@ -8,15 +9,6 @@ namespace Jellyfin.Plugin.SRFPlay.Services.Interfaces;
/// </summary>
public interface IStreamProxyService
{
/// <summary>
/// Registers a stream for proxying with an already-authenticated URL.
/// </summary>
/// <param name="itemId">The item ID.</param>
/// <param name="authenticatedUrl">The authenticated stream URL.</param>
/// <param name="urn">The SRF URN for this content (used for re-fetching fresh URLs).</param>
/// <param name="isLiveStream">Whether this is a livestream (livestreams always fetch fresh URLs).</param>
void RegisterStream(string itemId, string authenticatedUrl, string? urn = null, bool isLiveStream = false);
/// <summary>
/// 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.
@@ -56,9 +48,31 @@ public interface IStreamProxyService
/// </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, CancellationToken cancellationToken = default);
Task<byte[]?> GetSegmentAsync(string itemId, string segmentPath, string? queryString = null, CancellationToken cancellationToken = default);
/// <summary>
/// Fetches a segment from the original source as a streaming response.
/// Returns the HttpResponseMessage for streaming directly to the client, reducing TTFB.
/// The caller is responsible for disposing the response.
/// </summary>
/// <param name="itemId">The item ID.</param>
/// <param name="segmentPath">The segment path.</param>
/// <param name="queryString">The original query string from the request.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The HTTP response for streaming, or null if not found.</returns>
Task<HttpResponseMessage?> GetSegmentStreamAsync(string itemId, string segmentPath, string? queryString = null, CancellationToken cancellationToken = default);
/// <summary>
/// Rewrites URLs in a variant (sub) manifest to point to the proxy.
/// </summary>
/// <param name="manifestContent">The variant manifest content.</param>
/// <param name="baseProxyUrl">The base proxy URL (without query params).</param>
/// <param name="queryParams">Query parameters to append to rewritten URLs (e.g., "?token=abc").</param>
/// <returns>The rewritten manifest content.</returns>
string RewriteVariantManifestUrls(string manifestContent, string baseProxyUrl, string queryParams);
/// <summary>
/// Cleans up old and expired stream mappings.
@@ -99,6 +99,7 @@ public class MediaSourceFactory : IMediaSourceFactory
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
+12 -132
View File
@@ -1,6 +1,5 @@
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;
@@ -10,12 +9,10 @@ namespace Jellyfin.Plugin.SRFPlay.Services;
/// <summary>
/// Service for caching metadata from SRF API.
/// </summary>
public sealed class MetadataCache : IMetadataCache, 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.
@@ -25,19 +22,6 @@ public sealed class MetadataCache : IMetadataCache, 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>
@@ -53,30 +37,15 @@ public sealed class MetadataCache : IMetadataCache, IDisposable
return null;
}
try
if (_mediaCompositionCache.TryGetValue(urn, out var entry))
{
_lock.EnterReadLock();
try
if (entry.IsValid(cacheDurationMinutes))
{
if (_mediaCompositionCache.TryGetValue(urn, out var entry))
{
if (entry.IsValid(cacheDurationMinutes))
{
_logger.LogDebug("Cache hit for URN: {Urn}", urn);
return entry.Value;
}
_logger.LogDebug("Cache hit for URN: {Urn}", urn);
return entry.Value;
}
_logger.LogDebug("Cache entry expired for URN: {Urn}", urn);
}
}
finally
{
_lock.ExitReadLock();
}
}
catch (ObjectDisposedException)
{
return null;
_logger.LogDebug("Cache entry expired for URN: {Urn}", urn);
}
return null;
@@ -94,56 +63,9 @@ public sealed class MetadataCache : IMetadataCache, 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
}
var entry = new CacheEntry<MediaComposition>(mediaComposition);
_mediaCompositionCache.AddOrUpdate(urn, entry, (key, oldValue) => entry);
_logger.LogDebug("Cached media composition for URN: {Urn}", urn);
}
/// <summary>
@@ -151,50 +73,8 @@ public sealed class MetadataCache : IMetadataCache, IDisposable
/// </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);
}
_mediaCompositionCache.Clear();
_logger.LogInformation("Cleared metadata cache");
}
/// <summary>
@@ -46,45 +46,6 @@ public class StreamProxyService : IStreamProxyService
_streamMappings = new ConcurrentDictionary<string, StreamInfo>();
}
/// <summary>
/// Registers a stream for proxying.
/// </summary>
/// <param name="itemId">The item ID.</param>
/// <param name="authenticatedUrl">The authenticated stream URL.</param>
/// <param name="urn">The SRF URN for this content (used for re-fetching fresh URLs).</param>
/// <param name="isLiveStream">Whether this is a livestream (livestreams always fetch fresh URLs).</param>
public void RegisterStream(string itemId, string authenticatedUrl, string? urn = null, bool isLiveStream = false)
{
var tokenExpiry = ExtractTokenExpiry(authenticatedUrl);
var unauthenticatedUrl = StripAuthenticationFromUrl(authenticatedUrl);
var streamInfo = new StreamInfo
{
AuthenticatedUrl = authenticatedUrl,
UnauthenticatedUrl = unauthenticatedUrl,
RegisteredAt = DateTime.UtcNow,
TokenExpiresAt = tokenExpiry,
Urn = urn,
IsLiveStream = isLiveStream,
LastLivestreamFetchAt = isLiveStream ? DateTime.UtcNow : null
};
RegisterWithGuidFormats(itemId, streamInfo);
if (tokenExpiry.HasValue)
{
_logger.LogInformation(
"Registered stream for item {ItemId} (token expires at {ExpiresAt} UTC): {Url}",
itemId,
tokenExpiry.Value,
authenticatedUrl);
}
else
{
_logger.LogInformation("Registered stream for item {ItemId}: {Url}", itemId, authenticatedUrl);
}
}
/// <summary>
/// Registers a stream for deferred authentication (authenticates on first playback request).
/// </summary>
@@ -219,6 +180,50 @@ public class StreamProxyService : IStreamProxyService
}
}
// Fallback: Live TV channelId_guid format lookup
// For Live TV streams, itemId format is "channelId_urnGuid"
// Try matching by suffix (urnGuid part) or prefix (channelId part)
if (itemId.Contains('_', StringComparison.Ordinal))
{
var parts = itemId.Split('_', 2);
var prefix = parts[0]; // channelId part
var suffix = parts.Length > 1 ? parts[1] : null; // urnGuid part
foreach (var kvp in _streamMappings)
{
// Check if registered key contains the same suffix (urnGuid)
if (suffix != null && kvp.Key.Contains(suffix, StringComparison.OrdinalIgnoreCase))
{
_logger.LogInformation(
"Found stream by Live TV suffix match - Requested: {RequestedId}, Registered: {RegisteredId}",
itemId,
kvp.Key);
var url = await ValidateAndReturnStreamAsync(kvp.Key, kvp.Value, cancellationToken).ConfigureAwait(false);
if (url != null)
{
// Also register with the requested itemId for future lookups
_streamMappings.AddOrUpdate(itemId, kvp.Value, (key, old) => kvp.Value);
return url;
}
}
// Check if registered key starts with the same prefix (channelId)
if (kvp.Key.StartsWith(prefix + "_", StringComparison.OrdinalIgnoreCase))
{
_logger.LogInformation(
"Found stream by Live TV prefix match - Requested: {RequestedId}, Registered: {RegisteredId}",
itemId,
kvp.Key);
var url = await ValidateAndReturnStreamAsync(kvp.Key, kvp.Value, cancellationToken).ConfigureAwait(false);
if (url != null)
{
_streamMappings.AddOrUpdate(itemId, kvp.Value, (key, old) => kvp.Value);
return url;
}
}
}
}
// Last resort: Use active stream fallbacks (helps when Jellyfin creates random transcoding session IDs)
var activeStreams = _streamMappings.Where(kvp =>
{
@@ -398,7 +403,7 @@ public class StreamProxyService : IStreamProxyService
// Use short cache duration (5 min) for livestreams
var mediaComposition = await _compositionFetcher.GetMediaCompositionAsync(streamInfo.Urn, cancellationToken, 5).ConfigureAwait(false);
if (mediaComposition?.ChapterList == null || mediaComposition.ChapterList.Count == 0)
if (mediaComposition?.HasChapters != true)
{
_logger.LogWarning("No chapters found when refreshing livestream URL for URN: {Urn}", streamInfo.Urn);
return null;
@@ -642,6 +647,21 @@ public class StreamProxyService : IStreamProxyService
// Rewrite the manifest to replace Akamai URLs with proxy URLs
var rewrittenContent = RewriteManifestUrls(manifestContent, authenticatedUrl, baseProxyUrl);
// For live streams, inject #EXT-X-START to tell the player to start near the live edge
// Without this, players may start at the beginning of the sliding window and stutter
// as old segments get rotated out by the CDN
if (_streamMappings.TryGetValue(itemId, out var streamInfoForManifest) && streamInfoForManifest.IsLiveStream)
{
if (!rewrittenContent.Contains("#EXT-X-START", StringComparison.Ordinal))
{
rewrittenContent = rewrittenContent.Replace(
"#EXTM3U",
"#EXTM3U\n#EXT-X-START:TIME-OFFSET=-6,PRECISE=NO",
StringComparison.Ordinal);
_logger.LogDebug("Injected #EXT-X-START tag for live stream {ItemId}", itemId);
}
}
_logger.LogDebug("Rewritten manifest for item {ItemId} ({Length} bytes):\n{Content}", itemId, rewrittenContent.Length, rewrittenContent);
return rewrittenContent;
}
@@ -657,11 +677,13 @@ public class StreamProxyService : IStreamProxyService
/// </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>
public async Task<byte[]?> GetSegmentAsync(
string itemId,
string segmentPath,
string? queryString = null,
CancellationToken cancellationToken = default)
{
var authenticatedUrl = await GetAuthenticatedUrlAsync(itemId, cancellationToken).ConfigureAwait(false);
@@ -676,17 +698,29 @@ public class StreamProxyService : IStreamProxyService
var baseUri = new Uri(authenticatedUrl);
var baseUrl = $"{baseUri.Scheme}://{baseUri.Host}{string.Join('/', baseUri.AbsolutePath.Split('/')[..^1])}";
// Extract query parameters (auth tokens) from authenticated URL
var queryParams = baseUri.Query;
// Use the original query string from the request (preserves segment-specific params like ?m=timestamp)
// If no query string is provided, check if we need to add auth params from the master manifest
var queryParams = string.Empty;
if (!string.IsNullOrEmpty(queryString))
{
// Use the original query string from the segment request
queryParams = queryString.StartsWith('?') ? queryString : $"?{queryString}";
}
else if (!segmentPath.Contains("hdntl=", StringComparison.OrdinalIgnoreCase))
{
// Only append master manifest query params if segment doesn't have path-based auth
queryParams = baseUri.Query;
}
// Build full segment URL
var segmentUrl = $"{baseUrl}/{segmentPath}{queryParams}";
_logger.LogDebug(
"Fetching segment - BaseUri: {BaseUri}, BaseUrl: {BaseUrl}, SegmentPath: {SegmentPath}, FullUrl: {FullUrl}",
"Fetching segment - BaseUri: {BaseUri}, BaseUrl: {BaseUrl}, SegmentPath: {SegmentPath}, QueryString: {QueryString}, FullUrl: {FullUrl}",
authenticatedUrl,
baseUrl,
segmentPath,
queryString ?? "(none)",
segmentUrl);
using var httpClient = _httpClientFactory.CreateClient(NamedClient.Default);
var segmentData = await httpClient.GetByteArrayAsync(segmentUrl, cancellationToken).ConfigureAwait(false);
@@ -701,6 +735,67 @@ public class StreamProxyService : IStreamProxyService
}
}
/// <summary>
/// Fetches a segment as a streaming response for direct forwarding to the client.
/// Uses HttpCompletionOption.ResponseHeadersRead to start streaming before the full
/// segment is downloaded, reducing time-to-first-byte for live streams.
/// </summary>
/// <param name="itemId">The item ID.</param>
/// <param name="segmentPath">The segment path.</param>
/// <param name="queryString">The original query string from the request.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The HTTP response for streaming, or null if not found.</returns>
public async Task<HttpResponseMessage?> GetSegmentStreamAsync(
string itemId,
string segmentPath,
string? queryString = null,
CancellationToken cancellationToken = default)
{
var authenticatedUrl = await GetAuthenticatedUrlAsync(itemId, cancellationToken).ConfigureAwait(false);
if (authenticatedUrl == null)
{
return null;
}
try
{
var baseUri = new Uri(authenticatedUrl);
var baseUrl = $"{baseUri.Scheme}://{baseUri.Host}{string.Join('/', baseUri.AbsolutePath.Split('/')[..^1])}";
var queryParams = string.Empty;
if (!string.IsNullOrEmpty(queryString))
{
queryParams = queryString.StartsWith('?') ? queryString : $"?{queryString}";
}
else if (!segmentPath.Contains("hdntl=", StringComparison.OrdinalIgnoreCase))
{
queryParams = baseUri.Query;
}
var segmentUrl = $"{baseUrl}/{segmentPath}{queryParams}";
_logger.LogDebug("Streaming segment - SegmentPath: {SegmentPath}, FullUrl: {FullUrl}", segmentPath, segmentUrl);
var httpClient = _httpClientFactory.CreateClient(NamedClient.Default);
var request = new HttpRequestMessage(HttpMethod.Get, segmentUrl);
var response = await httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
_logger.LogWarning("Segment stream request failed with {StatusCode} for {SegmentPath}", response.StatusCode, segmentPath);
response.Dispose();
return null;
}
return response;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to stream segment {SegmentPath} for item {ItemId}", segmentPath, itemId);
return null;
}
}
/// <summary>
/// Rewrites URLs in HLS manifest to point to proxy.
/// </summary>
@@ -805,6 +900,65 @@ public class StreamProxyService : IStreamProxyService
}
}
/// <summary>
/// Rewrites URLs in a variant (sub) manifest to point to the proxy.
/// </summary>
/// <param name="manifestContent">The variant manifest content.</param>
/// <param name="baseProxyUrl">The base proxy URL (without query params).</param>
/// <param name="queryParams">Query parameters to append to rewritten URLs (e.g., "?token=abc").</param>
/// <returns>The rewritten manifest content.</returns>
public string RewriteVariantManifestUrls(string manifestContent, string baseProxyUrl, string queryParams)
{
string RewriteUrl(string url)
{
if (url.Contains("://", StringComparison.Ordinal))
{
// Absolute URL - extract filename and rewrite
var uri = new Uri(url.Trim());
var segments = uri.AbsolutePath.Split('/');
var fileName = segments[^1];
return $"{baseProxyUrl}/{fileName}{queryParams}";
}
// Relative URL - rewrite to proxy
return $"{baseProxyUrl}/{url.Trim()}{queryParams}";
}
var lines = manifestContent.Split('\n');
var result = new System.Text.StringBuilder();
foreach (var line in lines)
{
if (string.IsNullOrWhiteSpace(line))
{
result.AppendLine(line);
}
else if (line.StartsWith('#'))
{
// HLS tag line - check for URI="..." attributes (e.g., #EXT-X-MAP:URI="init.mp4")
if (line.Contains("URI=\"", StringComparison.Ordinal))
{
var rewrittenLine = Regex.Replace(
line,
@"URI=""([^""]+)""",
match => $"URI=\"{RewriteUrl(match.Groups[1].Value)}\"");
result.AppendLine(rewrittenLine);
}
else
{
result.AppendLine(line);
}
}
else
{
// Non-tag line with URL - rewrite it
result.AppendLine(RewriteUrl(line));
}
}
return result.ToString();
}
/// <summary>
/// Cleans up old and expired stream mappings.
/// </summary>
@@ -53,7 +53,7 @@ public class StreamUrlResolver : IStreamUrlResolver
// Filter out DRM-protected content
var nonDrmResources = chapter.ResourceList
.Where(r => r.DrmList == null || r.DrmList.ToString() == "[]")
.Where(r => r.IsPlayable)
.ToList();
_logger.LogInformation(
@@ -136,14 +136,14 @@ public class StreamUrlResolver : IStreamUrlResolver
{
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)
{
_logger.LogInformation(
"Selected stream for chapter {ChapterId}: Quality={Quality}, Protocol={Protocol}, URL={Url}",
_logger.LogDebug(
"Selected stream for chapter {ChapterId}: Quality={Quality}, Protocol={Protocol}, URL={Url}",
chapter.Id,
selectedResource.Quality ?? "NULL",
selectedResource.Protocol,
@@ -164,10 +164,33 @@ public class StreamUrlResolver : IStreamUrlResolver
{
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>
@@ -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
}
+74 -33
View File
@@ -6,6 +6,16 @@ A Jellyfin plugin for accessing SRF Play (Swiss Radio and Television) video-on-d
**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
- Access to SRF Play VOD content (video-on-demand, no DRM-protected content)
@@ -13,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
@@ -102,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
@@ -116,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)
@@ -144,49 +154,78 @@ If you encounter issues with the plugin:
```
Jellyfin.Plugin.SRFPlay/
├── Api/
│ ├── Models/ # API response models
│ │ ├── MediaComposition.cs
│ │ ├── Chapter.cs
│ │ ├── Resource.cs
│ ├── Models/ # API response models
│ │ ├── 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
│ │ └── 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
│ └── 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
│ ├── 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
├── ServiceRegistrator.cs # DI registration
└── Plugin.cs # Main plugin entry point
│ ├── ContentRefreshTask.cs # Periodic content refresh
│ └── ExpirationCheckTask.cs # Periodic expiration check
├── ServiceRegistrator.cs # DI registration
└── Plugin.cs # Main plugin entry point
```
### 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
@@ -239,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"
}
]
}
]