Compare commits

...
16 Commits
Author SHA1 Message Date
dtourolle 7c76402a4a Clean up dead code, consolidate duplication, fix redundancies
🏗️ Build Plugin / build (push) Failing after 9s
🧪 Test Plugin / test (push) Successful in 1m28s
🚀 Release Plugin / build-and-release (push) Failing after 5s
Remove 9 dead methods, 6 unused constants, and redundant
ReaderWriterLockSlim from MetadataCache. Consolidate repeated
patterns into HasChapters, IsPlayable, and ToLowerString helpers.
Extract shared API methods in SRFApiClient. Move variant manifest
rewriting from controller to StreamProxyService. Make Auto quality
distinct from HD. Update README architecture section.
2026-02-28 11:34:45 +01:00
Gitea Actions 873e531599 Update manifest.json for version 1.0.16 2026-01-17 20:31:09 +00:00
dtourolle 5c898a98f5 Use place holders for channel titles
🏗️ Build Plugin / build (push) Successful in 2m56s
🧪 Test Plugin / test (push) Successful in 1m25s
🚀 Release Plugin / build-and-release (push) Successful in 2m51s
2026-01-17 21:23:50 +01:00
Gitea Actions c282a98377 Update manifest.json for version 1.0.15 2026-01-17 09:54:39 +00:00
dtourolle 92dc6d8203 fix: change in server side API for live stream
🏗️ Build Plugin / build (push) Successful in 4m12s
🧪 Test Plugin / test (push) Successful in 1m24s
🚀 Release Plugin / build-and-release (push) Successful in 2m49s
2026-01-17 10:46:11 +01:00
Gitea Actions d313b68975 Update manifest.json for version 1.0.14 2025-12-30 12:38:41 +00:00
dtourolle 60434abd01 Use utf-8 decode everywhere
🏗️ Build Plugin / build (push) Successful in 3m4s
🧪 Test Plugin / test (push) Successful in 1m24s
🚀 Release Plugin / build-and-release (push) Successful in 2m52s
2025-12-30 13:31:13 +01:00
Gitea Actions 5ace3f4296 Update manifest.json for version 1.0.13 2025-12-21 13:02:38 +00:00
dtourolle 757aab1943 Break-line for placeholder titles when too long
🏗️ Build Plugin / build (push) Successful in 2m57s
🧪 Test Plugin / test (push) Successful in 1m26s
🚀 Release Plugin / build-and-release (push) Successful in 2m56s
2025-12-21 13:55:11 +01:00
Gitea Actions 23d8da9ae7 Update manifest.json for version 1.0.12 2025-12-20 13:35:38 +00:00
dtourolle 2631c93444 moved repo install guide to top 2025-12-20 14:32:13 +01:00
dtourolle 7f71d3419c Add repo manifest
🏗️ Build Plugin / build (push) Successful in 2m49s
🧪 Test Plugin / test (push) Successful in 1m21s
🚀 Release Plugin / build-and-release (push) Successful in 2m46s
Use title cards when non provided or is livestream
2025-12-20 14:28:39 +01:00
dtourolle dbbdd7eb6d utf-8 decdoding
🏗️ Build Plugin / build (push) Successful in 2m46s
🧪 Test Plugin / test (push) Successful in 1m21s
🚀 Release Plugin / build-and-release (push) Successful in 2m41s
2025-12-14 09:52:22 +01:00
dtourolle 9146830546 remove live-tv as it has issues with URI resolution
🏗️ Build Plugin / build (push) Successful in 2m43s
🧪 Test Plugin / test (push) Successful in 1m16s
2025-12-07 18:18:40 +01:00
dtourolle 0548fe7dec Use TV guide for livestreams
🏗️ Build Plugin / build (push) Successful in 2m53s
🧪 Test Plugin / test (push) Successful in 1m21s
2025-12-07 17:41:48 +01:00
dtourolle 198fc4c58d more consolidation
🚀 Release Plugin / build-and-release (push) Successful in 2m51s
🏗️ Build Plugin / build (push) Successful in 2m50s
🧪 Test Plugin / test (push) Successful in 1m20s
2025-12-07 13:29:13 +01:00
39 changed files with 1081 additions and 1281 deletions
+42
View File
@@ -70,6 +70,13 @@ jobs:
echo "artifact_name=${ARTIFACT_NAME}" >> $GITHUB_OUTPUT echo "artifact_name=${ARTIFACT_NAME}" >> $GITHUB_OUTPUT
echo "Found artifact: ${ARTIFACT}" 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 - name: Create Release
env: env:
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -126,3 +133,38 @@ jobs:
echo "✅ Release created successfully!" echo "✅ Release created successfully!"
echo "View at: ${GITEA_URL}/${REPO_OWNER}/${REPO_NAME}/releases/tag/${{ steps.get_version.outputs.version }}" echo "View at: ${GITEA_URL}/${REPO_OWNER}/${REPO_NAME}/releases/tag/${{ steps.get_version.outputs.version }}"
- name: Update manifest.json
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
+1
View File
@@ -3,3 +3,4 @@ obj/
.vs/ .vs/
.idea/ .idea/
artifacts artifacts
*.log
+10 -1
View File
@@ -12,6 +12,14 @@ using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.SRFPlay.Tests namespace Jellyfin.Plugin.SRFPlay.Tests
{ {
/// <summary>
/// Simple IHttpClientFactory implementation for tests.
/// </summary>
internal sealed class TestHttpClientFactory : IHttpClientFactory
{
public HttpClient CreateClient(string name) => new HttpClient();
}
class Program class Program
{ {
static async Task Main(string[] args) static async Task Main(string[] args)
@@ -33,8 +41,9 @@ namespace Jellyfin.Plugin.SRFPlay.Tests
builder.SetMinimumLevel(LogLevel.Warning); // Only show warnings and errors builder.SetMinimumLevel(LogLevel.Warning); // Only show warnings and errors
}); });
var httpClientFactory = new TestHttpClientFactory();
var apiClient = new SRFApiClient(loggerFactory); var apiClient = new SRFApiClient(loggerFactory);
var streamResolver = new StreamUrlResolver(loggerFactory.CreateLogger<StreamUrlResolver>()); var streamResolver = new StreamUrlResolver(loggerFactory.CreateLogger<StreamUrlResolver>(), httpClientFactory);
var cancellationToken = CancellationToken.None; var cancellationToken = CancellationToken.None;
@@ -115,7 +115,7 @@ namespace Jellyfin.Plugin.SRFPlay.Tests
if (chapter.ResourceList != null && chapter.ResourceList.Any()) if (chapter.ResourceList != null && chapter.ResourceList.Any())
{ {
var hlsResource = chapter.ResourceList.FirstOrDefault(r => var hlsResource = chapter.ResourceList.FirstOrDefault(r =>
r.Protocol == "HLS" && (r.DrmList == null || r.DrmList.ToString() == "[]")); r.Protocol == "HLS" && r.IsPlayable);
if (hlsResource != null) if (hlsResource != null)
{ {
@@ -74,7 +74,9 @@ public class Chapter
/// Gets or sets the list of available resources (streams). /// Gets or sets the list of available resources (streams).
/// </summary> /// </summary>
[JsonPropertyName("resourceList")] [JsonPropertyName("resourceList")]
public IReadOnlyList<Resource> ResourceList { get; set; } = new List<Resource>(); [System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "CA1002:Do not expose generic lists", Justification = "Required for JSON deserialization")]
[System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "CA2227:Collection properties should be read only", Justification = "Required for JSON deserialization")]
public List<Resource> ResourceList { get; set; } = new List<Resource>();
/// <summary> /// <summary>
/// Gets or sets the episode number. /// Gets or sets the episode number.
@@ -1,5 +1,4 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
namespace Jellyfin.Plugin.SRFPlay.Api.Models; namespace Jellyfin.Plugin.SRFPlay.Api.Models;
@@ -15,6 +14,12 @@ public class MediaComposition
[JsonPropertyName("chapterList")] [JsonPropertyName("chapterList")]
public IReadOnlyList<Chapter> ChapterList { get; set; } = new List<Chapter>(); public IReadOnlyList<Chapter> ChapterList { get; set; } = new List<Chapter>();
/// <summary>
/// Gets a value indicating whether this composition has any chapters.
/// </summary>
[JsonIgnore]
public bool HasChapters => ChapterList != null && ChapterList.Count > 0;
/// <summary> /// <summary>
/// Gets or sets the episode information. /// Gets or sets the episode information.
/// </summary> /// </summary>
@@ -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; namespace Jellyfin.Plugin.SRFPlay.Api.Models;
// NOTE: DrmList is typed as object? because the SRF API returns either null or a JSON array.
// IsPlayable checks for both null and empty array ("[]") to determine if content is DRM-free.
/// <summary> /// <summary>
/// Represents a streaming resource (URL) in the SRF API response. /// Represents a streaming resource (URL) in the SRF API response.
/// </summary> /// </summary>
@@ -48,4 +51,10 @@ public class Resource
/// </summary> /// </summary>
[JsonPropertyName("drmList")] [JsonPropertyName("drmList")]
public object? DrmList { get; set; } public object? DrmList { get; set; }
/// <summary>
/// Gets a value indicating whether this resource is playable (not DRM-protected).
/// </summary>
[JsonIgnore]
public bool IsPlayable => DrmList == null || DrmList.ToString() == "[]";
} }
+54 -156
View File
@@ -2,6 +2,8 @@ using System;
using System.Globalization; using System.Globalization;
using System.Net; using System.Net;
using System.Net.Http; using System.Net.Http;
using System.Text;
using System.Text.Encodings.Web;
using System.Text.Json; using System.Text.Json;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
@@ -51,10 +53,23 @@ public class SRFApiClient : IDisposable
_jsonOptions = new JsonSerializerOptions _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> /// <summary>
/// Creates an HttpClient with optional proxy configuration. /// Creates an HttpClient with optional proxy configuration.
/// </summary> /// </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.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.Accept.ParseAdd("*/*");
client.DefaultRequestHeaders.AcceptLanguage.ParseAdd("en-US,en;q=0.9"); client.DefaultRequestHeaders.AcceptLanguage.ParseAdd("en-US,en;q=0.9");
client.DefaultRequestHeaders.Add("Accept-Charset", "utf-8");
_logger.LogInformation( _logger.LogInformation(
"HttpClient created with HTTP/1.1 and headers - User-Agent: {UserAgent}, Accept: {Accept}, Accept-Language: {AcceptLanguage}", "HttpClient created with HTTP/1.1 and headers - User-Agent: {UserAgent}, Accept: {Accept}, Accept-Language: {AcceptLanguage}",
@@ -135,54 +151,11 @@ public class SRFApiClient : IDisposable
{ {
try try
{ {
var url = $"/mediaComposition/byUrn/{urn}.json"; var fullUrl = $"{ApiEndpoints.IntegrationLayerBaseUrl}/mediaComposition/byUrn/{urn}.json";
var fullUrl = $"{ApiEndpoints.IntegrationLayerBaseUrl}{url}"; _logger.LogDebug("Fetching media composition for URN: {Urn} from {Url}", urn, fullUrl);
_logger.LogInformation("Fetching media composition for URN: {Urn} from {Url}", urn, fullUrl);
// HttpClient consistently fails with 404, use curl directly // Use curl - HttpClient returns 404 due to server routing/network configuration
// This is likely due to routing/network configuration on the Jellyfin server
return await FetchWithCurlAsync(fullUrl, cancellationToken).ConfigureAwait(false); return await FetchWithCurlAsync(fullUrl, cancellationToken).ConfigureAwait(false);
/* HttpClient fallback disabled - always returns 404
var response = await _httpClient.GetAsync(url, cancellationToken).ConfigureAwait(false);
// Log response headers to diagnose geo-blocking
var xLocation = response.Headers.Contains("x-location")
? string.Join(", ", response.Headers.GetValues("x-location"))
: "not present";
_logger.LogInformation(
"Media composition response for URN {Urn}: StatusCode={StatusCode}, x-location={XLocation}",
urn,
response.StatusCode,
xLocation);
// If HttpClient fails, try curl as fallback
if (!response.IsSuccessStatusCode)
{
_logger.LogWarning("HttpClient failed with {StatusCode}, trying curl fallback", response.StatusCode);
return await FetchWithCurlAsync(fullUrl, cancellationToken).ConfigureAwait(false);
}
response.EnsureSuccessStatusCode();
var content = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
var result = JsonSerializer.Deserialize<MediaComposition>(content, _jsonOptions);
if (result?.ChapterList != null && result.ChapterList.Count > 0)
{
_logger.LogInformation(
"Successfully fetched media composition for URN: {Urn} - Chapters: {ChapterCount}",
urn,
result.ChapterList.Count);
}
else
{
_logger.LogWarning("Media composition for URN {Urn} has no chapters", urn);
}
return result;
*/
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -227,7 +200,8 @@ public class SRFApiClient : IDisposable
RedirectStandardOutput = true, RedirectStandardOutput = true,
RedirectStandardError = true, RedirectStandardError = true,
UseShellExecute = false, UseShellExecute = false,
CreateNoWindow = true CreateNoWindow = true,
StandardOutputEncoding = Encoding.UTF8
}; };
using var process = new System.Diagnostics.Process { StartInfo = processStartInfo }; using var process = new System.Diagnostics.Process { StartInfo = processStartInfo };
@@ -254,7 +228,7 @@ public class SRFApiClient : IDisposable
var result = JsonSerializer.Deserialize<MediaComposition>(output, _jsonOptions); var result = JsonSerializer.Deserialize<MediaComposition>(output, _jsonOptions);
if (result?.ChapterList != null && result.ChapterList.Count > 0) if (result?.HasChapters == true)
{ {
_logger.LogInformation("Successfully fetched media composition via curl - Chapters: {ChapterCount}", result.ChapterList.Count); _logger.LogInformation("Successfully fetched media composition via curl - Chapters: {ChapterCount}", result.ChapterList.Count);
} }
@@ -274,38 +248,8 @@ public class SRFApiClient : IDisposable
/// <param name="businessUnit">The business unit (e.g., srf, rts).</param> /// <param name="businessUnit">The business unit (e.g., srf, rts).</param>
/// <param name="cancellationToken">The cancellation token.</param> /// <param name="cancellationToken">The cancellation token.</param>
/// <returns>The media composition containing latest videos.</returns> /// <returns>The media composition containing latest videos.</returns>
public async Task<MediaComposition?> GetLatestVideosAsync(string businessUnit, CancellationToken cancellationToken = default) public Task<MediaComposition?> GetLatestVideosAsync(string businessUnit, CancellationToken cancellationToken = default)
{ => GetMediaCompositionListAsync(businessUnit, "latest", cancellationToken);
try
{
var url = $"/video/{businessUnit}/latest.json";
_logger.LogInformation("Fetching latest videos for business unit: {BusinessUnit} from URL: {Url}", businessUnit, url);
var response = await _httpClient.GetAsync(url, cancellationToken).ConfigureAwait(false);
_logger.LogInformation("Latest videos API response: {StatusCode}", response.StatusCode);
if (!response.IsSuccessStatusCode)
{
var errorContent = await 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;
}
}
/// <summary> /// <summary>
/// Gets the trending videos for a business unit. /// Gets the trending videos for a business unit.
@@ -313,60 +257,38 @@ public class SRFApiClient : IDisposable
/// <param name="businessUnit">The business unit (e.g., srf, rts).</param> /// <param name="businessUnit">The business unit (e.g., srf, rts).</param>
/// <param name="cancellationToken">The cancellation token.</param> /// <param name="cancellationToken">The cancellation token.</param>
/// <returns>The media composition containing trending videos.</returns> /// <returns>The media composition containing trending videos.</returns>
public async Task<MediaComposition?> GetTrendingVideosAsync(string businessUnit, CancellationToken cancellationToken = default) public Task<MediaComposition?> GetTrendingVideosAsync(string businessUnit, CancellationToken cancellationToken = default)
=> GetMediaCompositionListAsync(businessUnit, "trending", cancellationToken);
private async Task<MediaComposition?> GetMediaCompositionListAsync(string businessUnit, string endpoint, CancellationToken cancellationToken)
{ {
try try
{ {
var url = $"/video/{businessUnit}/trending.json"; var url = $"/video/{businessUnit}/{endpoint}.json";
_logger.LogInformation("Fetching trending videos for business unit: {BusinessUnit} from URL: {Url}", businessUnit, url); _logger.LogInformation("Fetching {Endpoint} videos for business unit: {BusinessUnit} from URL: {Url}", endpoint, businessUnit, url);
var response = await _httpClient.GetAsync(url, cancellationToken).ConfigureAwait(false); var response = await _httpClient.GetAsync(url, cancellationToken).ConfigureAwait(false);
_logger.LogInformation("Trending videos API response: {StatusCode}", response.StatusCode); _logger.LogInformation("{Endpoint} videos API response: {StatusCode}", endpoint, response.StatusCode);
if (!response.IsSuccessStatusCode) if (!response.IsSuccessStatusCode)
{ {
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); _logger.LogError("API returned error {StatusCode}: {Error}", response.StatusCode, errorContent);
return null; return null;
} }
var content = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); var content = await ReadAsUtf8StringAsync(response.Content, cancellationToken).ConfigureAwait(false);
_logger.LogDebug("Trending videos response length: {Length}", content.Length); _logger.LogDebug("{Endpoint} videos response length: {Length}", endpoint, content.Length);
var result = JsonSerializer.Deserialize<MediaComposition>(content, _jsonOptions); var result = JsonSerializer.Deserialize<MediaComposition>(content, _jsonOptions);
_logger.LogInformation("Successfully fetched trending videos for business unit: {BusinessUnit}", businessUnit); _logger.LogInformation("Successfully fetched {Endpoint} videos for business unit: {BusinessUnit}", endpoint, businessUnit);
return result; return result;
} }
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogError(ex, "Error fetching trending videos for business unit: {BusinessUnit}", businessUnit); _logger.LogError(ex, "Error fetching {Endpoint} videos for business unit: {BusinessUnit}", endpoint, businessUnit);
return null;
}
}
/// <summary>
/// Gets raw JSON response from a URL.
/// </summary>
/// <param name="url">The relative URL.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>The JSON string.</returns>
public async Task<string?> GetJsonAsync(string url, CancellationToken cancellationToken = default)
{
try
{
_logger.LogDebug("Fetching JSON from URL: {Url}", url);
var response = await _httpClient.GetAsync(url, cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
var content = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
return content;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error fetching JSON from URL: {Url}", url);
return null; return null;
} }
} }
@@ -377,35 +299,8 @@ public class SRFApiClient : IDisposable
/// <param name="businessUnit">The business unit (e.g., srf, rts).</param> /// <param name="businessUnit">The business unit (e.g., srf, rts).</param>
/// <param name="cancellationToken">The cancellation token.</param> /// <param name="cancellationToken">The cancellation token.</param>
/// <returns>List of shows.</returns> /// <returns>List of shows.</returns>
public async Task<System.Collections.Generic.List<PlayV3Show>?> GetAllShowsAsync(string businessUnit, CancellationToken cancellationToken = default) public Task<System.Collections.Generic.List<PlayV3Show>?> GetAllShowsAsync(string businessUnit, CancellationToken cancellationToken = default)
{ => GetPlayV3DirectListAsync<PlayV3Show>(businessUnit, "shows", cancellationToken);
try
{
var baseUrl = string.Format(CultureInfo.InvariantCulture, PlayV3UrlFormat, businessUnit);
var url = $"{baseUrl}shows";
_logger.LogInformation("Fetching all shows for business unit: {BusinessUnit} from URL: {Url}", businessUnit, url);
var response = await _playV3HttpClient.GetAsync(url, cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
var errorContent = await 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;
}
}
/// <summary> /// <summary>
/// Gets all topics from the Play v3 API. /// Gets all topics from the Play v3 API.
@@ -413,32 +308,35 @@ public class SRFApiClient : IDisposable
/// <param name="businessUnit">The business unit (e.g., srf, rts).</param> /// <param name="businessUnit">The business unit (e.g., srf, rts).</param>
/// <param name="cancellationToken">The cancellation token.</param> /// <param name="cancellationToken">The cancellation token.</param>
/// <returns>List of topics.</returns> /// <returns>List of topics.</returns>
public async Task<System.Collections.Generic.List<PlayV3Topic>?> GetAllTopicsAsync(string businessUnit, CancellationToken cancellationToken = default) public Task<System.Collections.Generic.List<PlayV3Topic>?> GetAllTopicsAsync(string businessUnit, CancellationToken cancellationToken = default)
=> GetPlayV3DirectListAsync<PlayV3Topic>(businessUnit, "topics", cancellationToken);
private async Task<System.Collections.Generic.List<T>?> GetPlayV3DirectListAsync<T>(string businessUnit, string endpoint, CancellationToken cancellationToken)
{ {
try try
{ {
var baseUrl = string.Format(CultureInfo.InvariantCulture, PlayV3UrlFormat, businessUnit); var baseUrl = string.Format(CultureInfo.InvariantCulture, PlayV3UrlFormat, businessUnit);
var url = $"{baseUrl}topics"; var url = $"{baseUrl}{endpoint}";
_logger.LogInformation("Fetching all topics for business unit: {BusinessUnit} from URL: {Url}", businessUnit, url); _logger.LogInformation("Fetching all {Endpoint} for business unit: {BusinessUnit} from URL: {Url}", endpoint, businessUnit, url);
var response = await _playV3HttpClient.GetAsync(url, cancellationToken).ConfigureAwait(false); var response = await _playV3HttpClient.GetAsync(url, cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode) 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); _logger.LogError("API returned error {StatusCode}: {Error}", response.StatusCode, errorContent);
return null; return null;
} }
var content = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); var content = await ReadAsUtf8StringAsync(response.Content, cancellationToken).ConfigureAwait(false);
var result = JsonSerializer.Deserialize<PlayV3DirectResponse<PlayV3Topic>>(content, _jsonOptions); var result = JsonSerializer.Deserialize<PlayV3DirectResponse<T>>(content, _jsonOptions);
_logger.LogInformation("Successfully fetched {Count} topics for business unit: {BusinessUnit}", result?.Data?.Count ?? 0, businessUnit); _logger.LogInformation("Successfully fetched {Count} {Endpoint} for business unit: {BusinessUnit}", result?.Data?.Count ?? 0, endpoint, businessUnit);
return result?.Data; return result?.Data;
} }
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogError(ex, "Error fetching topics for business unit: {BusinessUnit}", businessUnit); _logger.LogError(ex, "Error fetching {Endpoint} for business unit: {BusinessUnit}", endpoint, businessUnit);
return null; return null;
} }
} }
@@ -462,12 +360,12 @@ public class SRFApiClient : IDisposable
if (!response.IsSuccessStatusCode) 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); _logger.LogError("API returned error {StatusCode}: {Error}", response.StatusCode, errorContent);
return null; 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); var result = JsonSerializer.Deserialize<PlayV3Response<PlayV3Video>>(content, _jsonOptions);
_logger.LogDebug("Successfully fetched {Count} videos for show {ShowId}", result?.Data?.Data?.Count ?? 0, showId); _logger.LogDebug("Successfully fetched {Count} videos for show {ShowId}", result?.Data?.Data?.Count ?? 0, showId);
@@ -502,12 +400,12 @@ public class SRFApiClient : IDisposable
if (!response.IsSuccessStatusCode) 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); _logger.LogError("API returned error {StatusCode}: {Error}", response.StatusCode, errorContent);
return null; 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": [...] } } // The response structure is: { "data": { "scheduledLivestreams": [...] } }
var jsonDoc = JsonSerializer.Deserialize<JsonElement>(content, _jsonOptions); var jsonDoc = JsonSerializer.Deserialize<JsonElement>(content, _jsonOptions);
@@ -2,12 +2,13 @@ using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Globalization; using System.Globalization;
using System.Linq; using System.Linq;
using System.Security.Cryptography;
using System.Text; using System.Text;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using Jellyfin.Plugin.SRFPlay.Api;
using Jellyfin.Plugin.SRFPlay.Constants; using Jellyfin.Plugin.SRFPlay.Constants;
using Jellyfin.Plugin.SRFPlay.Services.Interfaces; using Jellyfin.Plugin.SRFPlay.Services.Interfaces;
using Jellyfin.Plugin.SRFPlay.Utilities;
using MediaBrowser.Controller; using MediaBrowser.Controller;
using MediaBrowser.Controller.Channels; using MediaBrowser.Controller.Channels;
using MediaBrowser.Controller.Providers; using MediaBrowser.Controller.Providers;
@@ -24,11 +25,11 @@ namespace Jellyfin.Plugin.SRFPlay.Channels;
public class SRFPlayChannel : IChannel, IHasCacheKey public class SRFPlayChannel : IChannel, IHasCacheKey
{ {
private readonly ILogger<SRFPlayChannel> _logger; private readonly ILogger<SRFPlayChannel> _logger;
private readonly ILoggerFactory _loggerFactory;
private readonly IContentRefreshService _contentRefreshService; private readonly IContentRefreshService _contentRefreshService;
private readonly IStreamUrlResolver _streamResolver; private readonly IStreamUrlResolver _streamResolver;
private readonly IMediaSourceFactory _mediaSourceFactory; private readonly IMediaSourceFactory _mediaSourceFactory;
private readonly ICategoryService? _categoryService; private readonly ICategoryService? _categoryService;
private readonly ISRFApiClientFactory _apiClientFactory;
/// <summary> /// <summary>
/// Initializes a new instance of the <see cref="SRFPlayChannel"/> class. /// Initializes a new instance of the <see cref="SRFPlayChannel"/> class.
@@ -38,26 +39,28 @@ public class SRFPlayChannel : IChannel, IHasCacheKey
/// <param name="streamResolver">The stream resolver.</param> /// <param name="streamResolver">The stream resolver.</param>
/// <param name="mediaSourceFactory">The media source factory.</param> /// <param name="mediaSourceFactory">The media source factory.</param>
/// <param name="categoryService">The category service (optional).</param> /// <param name="categoryService">The category service (optional).</param>
/// <param name="apiClientFactory">The API client factory.</param>
public SRFPlayChannel( public SRFPlayChannel(
ILoggerFactory loggerFactory, ILoggerFactory loggerFactory,
IContentRefreshService contentRefreshService, IContentRefreshService contentRefreshService,
IStreamUrlResolver streamResolver, IStreamUrlResolver streamResolver,
IMediaSourceFactory mediaSourceFactory, IMediaSourceFactory mediaSourceFactory,
ICategoryService? categoryService = null) ICategoryService? categoryService,
ISRFApiClientFactory apiClientFactory)
{ {
_loggerFactory = loggerFactory;
_logger = loggerFactory.CreateLogger<SRFPlayChannel>(); _logger = loggerFactory.CreateLogger<SRFPlayChannel>();
_contentRefreshService = contentRefreshService; _contentRefreshService = contentRefreshService;
_streamResolver = streamResolver; _streamResolver = streamResolver;
_mediaSourceFactory = mediaSourceFactory; _mediaSourceFactory = mediaSourceFactory;
_categoryService = categoryService; _categoryService = categoryService;
_apiClientFactory = apiClientFactory;
if (_categoryService == null) if (_categoryService == null)
{ {
_logger.LogWarning("CategoryService not available - category folders will be disabled"); _logger.LogWarning("CategoryService not available - category folders will be disabled");
} }
_logger.LogInformation("=== SRFPlayChannel constructor called! Channel is being instantiated ==="); _logger.LogDebug("SRFPlayChannel initialized");
} }
/// <inheritdoc /> /// <inheritdoc />
@@ -78,7 +81,7 @@ public class SRFPlayChannel : IChannel, IHasCacheKey
/// <inheritdoc /> /// <inheritdoc />
public InternalChannelFeatures GetChannelFeatures() public InternalChannelFeatures GetChannelFeatures()
{ {
_logger.LogInformation("=== GetChannelFeatures called for SRF Play channel ==="); _logger.LogDebug("GetChannelFeatures called");
return new InternalChannelFeatures return new InternalChannelFeatures
{ {
@@ -104,10 +107,19 @@ public class SRFPlayChannel : IChannel, IHasCacheKey
/// <inheritdoc /> /// <inheritdoc />
public Task<DynamicImageResponse> GetChannelImage(ImageType type, CancellationToken cancellationToken) 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 return Task.FromResult(new DynamicImageResponse
{ {
HasImage = false HasImage = true,
Stream = resourceStream,
Format = MediaBrowser.Model.Drawing.ImageFormat.Png
}); });
} }
@@ -124,12 +136,12 @@ public class SRFPlayChannel : IChannel, IHasCacheKey
/// <inheritdoc /> /// <inheritdoc />
public async Task<ChannelItemResult> GetChannelItems(InternalChannelItemQuery query, CancellationToken cancellationToken) 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 try
{ {
var items = await GetFolderItemsAsync(query.FolderId, cancellationToken).ConfigureAwait(false); 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 return new ChannelItemResult
{ {
@@ -178,7 +190,7 @@ public class SRFPlayChannel : IChannel, IHasCacheKey
{ {
try try
{ {
var businessUnit = config.BusinessUnit.ToString().ToLowerInvariant(); var businessUnit = config.BusinessUnit.ToLowerString();
var topics = await _categoryService.GetTopicsAsync(businessUnit, cancellationToken).ConfigureAwait(false); var topics = await _categoryService.GetTopicsAsync(businessUnit, cancellationToken).ConfigureAwait(false);
foreach (var topic in topics.Where(t => !string.IsNullOrEmpty(t.Id))) foreach (var topic in topics.Where(t => !string.IsNullOrEmpty(t.Id)))
@@ -188,7 +200,9 @@ public class SRFPlayChannel : IChannel, IHasCacheKey
continue; continue;
} }
items.Add(CreateFolder($"category_{topic.Id}", topic.Title ?? topic.Id!, topic.Lead)); // Generate placeholder image for topic
var placeholderUrl = CreatePlaceholderImageUrl(topic.Title ?? topic.Id!, _mediaSourceFactory.GetServerBaseUrl());
items.Add(CreateFolder($"category_{topic.Id}", topic.Title ?? topic.Id!, topic.Lead, placeholderUrl));
} }
_logger.LogInformation("Added {Count} category folders", topics.Count); _logger.LogInformation("Added {Count} category folders", topics.Count);
@@ -202,7 +216,7 @@ public class SRFPlayChannel : IChannel, IHasCacheKey
return items; return items;
} }
private static ChannelItemInfo CreateFolder(string id, string name, string? overview = null) private static ChannelItemInfo CreateFolder(string id, string name, string? overview = null, string? imageUrl = null)
{ {
return new ChannelItemInfo return new ChannelItemInfo
{ {
@@ -210,7 +224,7 @@ public class SRFPlayChannel : IChannel, IHasCacheKey
Name = name, Name = name,
Type = ChannelItemType.Folder, Type = ChannelItemType.Folder,
FolderType = ChannelFolderType.Container, FolderType = ChannelFolderType.Container,
ImageUrl = null, ImageUrl = imageUrl,
Overview = overview Overview = overview
}; };
} }
@@ -234,9 +248,9 @@ public class SRFPlayChannel : IChannel, IHasCacheKey
try try
{ {
var businessUnit = config?.BusinessUnit.ToString().ToLowerInvariant() ?? "srf"; var businessUnit = config?.BusinessUnit.ToLowerString() ?? "srf";
using var apiClient = new Api.SRFApiClient(_loggerFactory); using var apiClient = _apiClientFactory.CreateClient();
var scheduledLivestreams = await apiClient.GetScheduledLivestreamsAsync(businessUnit, "SPORT", cancellationToken).ConfigureAwait(false); var scheduledLivestreams = await apiClient.GetScheduledLivestreamsAsync(businessUnit, "SPORT", cancellationToken).ConfigureAwait(false);
if (scheduledLivestreams == null) if (scheduledLivestreams == null)
@@ -296,12 +310,12 @@ public class SRFPlayChannel : IChannel, IHasCacheKey
{ {
var config = Plugin.Instance?.Configuration; var config = Plugin.Instance?.Configuration;
var topicId = folderId.Substring("category_".Length); var topicId = folderId.Substring("category_".Length);
var businessUnit = config?.BusinessUnit.ToString().ToLowerInvariant() ?? "srf"; var businessUnit = config?.BusinessUnit.ToLowerString() ?? "srf";
var shows = await _categoryService.GetShowsByTopicAsync(topicId, businessUnit, 20, cancellationToken).ConfigureAwait(false); var shows = await _categoryService.GetShowsByTopicAsync(topicId, businessUnit, 20, cancellationToken).ConfigureAwait(false);
var urns = new List<string>(); var urns = new List<string>();
using var apiClient = new Api.SRFApiClient(_loggerFactory); using var apiClient = _apiClientFactory.CreateClient();
foreach (var show in shows) foreach (var show in shows)
{ {
@@ -390,8 +404,14 @@ public class SRFPlayChannel : IChannel, IHasCacheKey
var enabledTopics = config?.EnabledTopics != null && config.EnabledTopics.Count > 0 var enabledTopics = config?.EnabledTopics != null && config.EnabledTopics.Count > 0
? string.Join(",", config.EnabledTopics) ? string.Join(",", config.EnabledTopics)
: "all"; : "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) private async Task<List<ChannelItemInfo>> ConvertUrnsToChannelItems(List<string> urns, CancellationToken cancellationToken)
@@ -407,7 +427,7 @@ public class SRFPlayChannel : IChannel, IHasCacheKey
_logger.LogInformation("Converting {Count} URNs to channel items", urns.Count); _logger.LogInformation("Converting {Count} URNs to channel items", urns.Count);
using var apiClient = new Api.SRFApiClient(_loggerFactory); using var apiClient = _apiClientFactory.CreateClient();
int successCount = 0; int successCount = 0;
int failedCount = 0; int failedCount = 0;
int expiredCount = 0; int expiredCount = 0;
@@ -420,7 +440,7 @@ public class SRFPlayChannel : IChannel, IHasCacheKey
_logger.LogDebug("Processing URN: {Urn}", urn); _logger.LogDebug("Processing URN: {Urn}", urn);
var mediaComposition = await apiClient.GetMediaCompositionByUrnAsync(urn, cancellationToken).ConfigureAwait(false); var mediaComposition = await apiClient.GetMediaCompositionByUrnAsync(urn, cancellationToken).ConfigureAwait(false);
if (mediaComposition?.ChapterList == null || mediaComposition.ChapterList.Count == 0) if (mediaComposition?.HasChapters != true)
{ {
_logger.LogWarning("URN {Urn}: No media composition or chapters found", urn); _logger.LogWarning("URN {Urn}: No media composition or chapters found", urn);
failedCount++; failedCount++;
@@ -446,7 +466,7 @@ public class SRFPlayChannel : IChannel, IHasCacheKey
} }
// Generate deterministic GUID from URN // 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) // Use factory to create MediaSourceInfo (handles stream URL, auth, proxy registration)
var mediaSource = await _mediaSourceFactory.CreateMediaSourceAsync( var mediaSource = await _mediaSourceFactory.CreateMediaSourceAsync(
@@ -482,21 +502,36 @@ public class SRFPlayChannel : IChannel, IHasCacheKey
// Build overview // Build overview
var overview = chapter.Description ?? chapter.Lead; var overview = chapter.Description ?? chapter.Lead;
// Get image URL - prefer chapter image, fall back to show image if available // Determine image URL based on configuration
var originalImageUrl = chapter.ImageUrl; string? imageUrl;
if (string.IsNullOrEmpty(originalImageUrl) && mediaComposition.Show != null) var generateTitleCards = config.GenerateTitleCards;
{
originalImageUrl = mediaComposition.Show.ImageUrl;
_logger.LogDebug("URN {Urn}: Using show image as fallback", urn);
}
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 if (string.IsNullOrEmpty(originalImageUrl))
var imageUrl = CreateProxiedImageUrl(originalImageUrl, _mediaSourceFactory.GetServerBaseUrl()); {
_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 // 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(); var premiereDate = chapter.Type == "SCHEDULED_LIVESTREAM" ? chapter.ValidFrom?.ToUniversalTime() : chapter.Date?.ToUniversalTime();
@@ -554,25 +589,6 @@ public class SRFPlayChannel : IChannel, IHasCacheKey
return items; 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> /// <summary>
/// Creates a proxy URL for an image to fix Content-Type headers from SRF CDN. /// Creates a proxy URL for an image to fix Content-Type headers from SRF CDN.
/// </summary> /// </summary>
@@ -591,6 +607,18 @@ public class SRFPlayChannel : IChannel, IHasCacheKey
return $"{serverUrl}/Plugins/SRFPlay/Proxy/Image?url={encodedUrl}"; 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 /> /// <inheritdoc />
public bool IsEnabledFor(string userId) public bool IsEnabledFor(string userId)
{ {
@@ -74,6 +74,7 @@ public class PluginConfiguration : BasePluginConfiguration
EnableTrendingContent = true; EnableTrendingContent = true;
EnableCategoryFolders = true; EnableCategoryFolders = true;
EnabledTopics = new System.Collections.Generic.List<string>(); EnabledTopics = new System.Collections.Generic.List<string>();
GenerateTitleCards = true;
} }
/// <summary> /// <summary>
@@ -149,4 +150,10 @@ public class PluginConfiguration : BasePluginConfiguration
/// This is important for Android and other remote clients to access streams. /// This is important for Android and other remote clients to access streams.
/// </summary> /// </summary>
public string PublicServerUrl { get; set; } = string.Empty; 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> </label>
<div class="fieldDescription">Automatically discover and add trending videos</div> <div class="fieldDescription">Automatically discover and add trending videos</div>
</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 /> <br />
<h2>Proxy Settings</h2> <h2>Proxy Settings</h2>
<div class="checkboxContainer checkboxContainer-withDescription"> <div class="checkboxContainer checkboxContainer-withDescription">
@@ -117,6 +124,7 @@
document.querySelector('#CacheDurationMinutes').value = config.CacheDurationMinutes; document.querySelector('#CacheDurationMinutes').value = config.CacheDurationMinutes;
document.querySelector('#EnableLatestContent').checked = config.EnableLatestContent; document.querySelector('#EnableLatestContent').checked = config.EnableLatestContent;
document.querySelector('#EnableTrendingContent').checked = config.EnableTrendingContent; document.querySelector('#EnableTrendingContent').checked = config.EnableTrendingContent;
document.querySelector('#GenerateTitleCards').checked = config.GenerateTitleCards !== false;
document.querySelector('#UseProxy').checked = config.UseProxy || false; document.querySelector('#UseProxy').checked = config.UseProxy || false;
document.querySelector('#ProxyAddress').value = config.ProxyAddress || ''; document.querySelector('#ProxyAddress').value = config.ProxyAddress || '';
document.querySelector('#ProxyUsername').value = config.ProxyUsername || ''; document.querySelector('#ProxyUsername').value = config.ProxyUsername || '';
@@ -137,6 +145,7 @@
config.CacheDurationMinutes = parseInt(document.querySelector('#CacheDurationMinutes').value); config.CacheDurationMinutes = parseInt(document.querySelector('#CacheDurationMinutes').value);
config.EnableLatestContent = document.querySelector('#EnableLatestContent').checked; config.EnableLatestContent = document.querySelector('#EnableLatestContent').checked;
config.EnableTrendingContent = document.querySelector('#EnableTrendingContent').checked; config.EnableTrendingContent = document.querySelector('#EnableTrendingContent').checked;
config.GenerateTitleCards = document.querySelector('#GenerateTitleCards').checked;
config.UseProxy = document.querySelector('#UseProxy').checked; config.UseProxy = document.querySelector('#UseProxy').checked;
config.ProxyAddress = document.querySelector('#ProxyAddress').value; config.ProxyAddress = document.querySelector('#ProxyAddress').value;
config.ProxyUsername = document.querySelector('#ProxyUsername').value; config.ProxyUsername = document.querySelector('#ProxyUsername').value;
@@ -28,12 +28,6 @@ public static class ApiEndpoints
/// </summary> /// </summary>
public const string SrfPlayHomepage = "https://www.srf.ch/play"; public const string SrfPlayHomepage = "https://www.srf.ch/play";
/// <summary>
/// Media composition endpoint template (relative to IntegrationLayerBaseUrl).
/// Format: {0} = URN.
/// </summary>
public const string MediaCompositionByUrnPath = "/mediaComposition/byUrn/{0}.json";
/// <summary> /// <summary>
/// Base proxy route for stream proxying (relative to server root). /// Base proxy route for stream proxying (relative to server root).
/// </summary> /// </summary>
@@ -44,30 +38,4 @@ public static class ApiEndpoints
/// Format: {0} = item ID. /// Format: {0} = item ID.
/// </summary> /// </summary>
public const string ProxyMasterManifestPath = "/Plugins/SRFPlay/Proxy/{0}/master.m3u8"; public const string ProxyMasterManifestPath = "/Plugins/SRFPlay/Proxy/{0}/master.m3u8";
/// <summary>
/// Image proxy route template (relative to server root).
/// Format: {0} = base64-encoded original URL.
/// </summary>
public const string ImageProxyPath = "/Plugins/SRFPlay/Image/{0}";
/// <summary>
/// Play V3 shows endpoint (relative to PlayV3 base URL).
/// </summary>
public const string PlayV3ShowsPath = "shows";
/// <summary>
/// Play V3 topics endpoint (relative to PlayV3 base URL).
/// </summary>
public const string PlayV3TopicsPath = "topics";
/// <summary>
/// Play V3 livestreams endpoint (relative to PlayV3 base URL).
/// </summary>
public const string PlayV3LivestreamsPath = "livestreams";
/// <summary>
/// Play V3 scheduled livestreams endpoint (relative to PlayV3 base URL).
/// </summary>
public const string PlayV3ScheduledLivestreamsPath = "scheduled-livestreams";
} }
@@ -5,6 +5,7 @@ using System.Net.Http.Headers;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using Jellyfin.Plugin.SRFPlay.Services.Interfaces; using Jellyfin.Plugin.SRFPlay.Services.Interfaces;
using Jellyfin.Plugin.SRFPlay.Utilities;
using MediaBrowser.Common.Net; using MediaBrowser.Common.Net;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http;
@@ -52,6 +53,29 @@ public class StreamProxyController : ControllerBase
Response.Headers["Access-Control-Max-Age"] = "86400"; // Cache preflight for 24 hours Response.Headers["Access-Control-Max-Age"] = "86400"; // Cache preflight for 24 hours
} }
/// <summary>
/// Adds Cache-Control headers appropriate for the stream type.
/// Livestreams need frequent manifest refresh, VOD can be cached longer.
/// </summary>
/// <param name="itemId">The item ID to check.</param>
private void AddManifestCacheHeaders(string itemId)
{
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);
}
else
{
// VOD manifests are static, can cache longer
Response.Headers["Cache-Control"] = "max-age=3600";
}
}
/// <summary> /// <summary>
/// Handles CORS preflight OPTIONS requests for all proxy endpoints. /// Handles CORS preflight OPTIONS requests for all proxy endpoints.
/// </summary> /// </summary>
@@ -120,8 +144,11 @@ public class StreamProxyController : ControllerBase
return NotFound(); return NotFound();
} }
// Set cache headers based on stream type (live vs VOD)
AddManifestCacheHeaders(actualItemId);
_logger.LogDebug("Returning master manifest for item {ItemId} ({Length} bytes)", itemId, manifestContent.Length); _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) catch (Exception ex)
{ {
@@ -156,7 +183,8 @@ public class StreamProxyController : ControllerBase
try try
{ {
// Fetch the variant manifest as a segment // 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) if (manifestData == null)
{ {
@@ -168,10 +196,29 @@ public class StreamProxyController : ControllerBase
var manifestContent = System.Text.Encoding.UTF8.GetString(manifestData); var manifestContent = System.Text.Encoding.UTF8.GetString(manifestData);
var scheme = GetProxyScheme(); var scheme = GetProxyScheme();
var baseProxyUrl = $"{scheme}://{Request.Host}/Plugins/SRFPlay/Proxy/{itemId}"; var baseProxyUrl = $"{scheme}://{Request.Host}/Plugins/SRFPlay/Proxy/{itemId}";
var rewrittenContent = RewriteSegmentUrls(manifestContent, baseProxyUrl);
// Extract query parameters from the current request to propagate them
string queryParams;
if (Request.Query.TryGetValue("token", out var tokenVal) && !string.IsNullOrEmpty(tokenVal))
{
queryParams = $"?token={tokenVal}";
}
else if (Request.Query.TryGetValue("itemId", out var itemIdVal) && !string.IsNullOrEmpty(itemIdVal))
{
queryParams = $"?itemId={itemIdVal}";
}
else
{
queryParams = string.Empty;
}
var rewrittenContent = _proxyService.RewriteVariantManifestUrls(manifestContent, baseProxyUrl, queryParams);
// Set cache headers based on stream type (live vs VOD)
AddManifestCacheHeaders(actualItemId);
_logger.LogDebug("Returning variant manifest for item {ItemId} ({Length} bytes)", itemId, rewrittenContent.Length); _logger.LogDebug("Returning variant manifest for item {ItemId} ({Length} bytes)", itemId, rewrittenContent.Length);
return Content(rewrittenContent, "application/vnd.apple.mpegurl"); return Content(rewrittenContent, "application/vnd.apple.mpegurl; charset=utf-8");
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -204,7 +251,9 @@ public class StreamProxyController : ControllerBase
try 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;
var segmentData = await _proxyService.GetSegmentAsync(actualItemId, segmentPath, queryString, cancellationToken).ConfigureAwait(false);
if (segmentData == null) if (segmentData == null)
{ {
@@ -213,7 +262,7 @@ public class StreamProxyController : ControllerBase
} }
// Determine content type based on file extension // Determine content type based on file extension
var contentType = GetContentType(segmentPath); var contentType = MimeTypeHelper.GetSegmentContentType(segmentPath);
_logger.LogDebug("Returning segment {SegmentPath} ({Length} bytes, {ContentType})", segmentPath, segmentData.Length, contentType); _logger.LogDebug("Returning segment {SegmentPath} ({Length} bytes, {ContentType})", segmentPath, segmentData.Length, contentType);
return File(segmentData, contentType); return File(segmentData, contentType);
@@ -278,107 +327,6 @@ public class StreamProxyController : ControllerBase
return pathItemId; return pathItemId;
} }
/// <summary>
/// Rewrites segment URLs in a manifest to point to proxy.
/// </summary>
/// <param name="manifestContent">The manifest content.</param>
/// <param name="baseProxyUrl">The base proxy URL.</param>
/// <returns>The rewritten manifest.</returns>
private string RewriteSegmentUrls(string manifestContent, string baseProxyUrl)
{
// Extract query parameters from the current request to propagate them
string queryParams;
if (Request.Query.TryGetValue("token", out var token) && !string.IsNullOrEmpty(token))
{
queryParams = $"?token={token}";
}
else if (Request.Query.TryGetValue("itemId", out var itemId) && !string.IsNullOrEmpty(itemId))
{
queryParams = $"?itemId={itemId}";
}
else
{
queryParams = string.Empty;
}
// Helper function to rewrite a single URL
string RewriteUrl(string url)
{
if (url.Contains("://", StringComparison.Ordinal))
{
// Absolute URL - extract filename and rewrite
var uri = new Uri(url.Trim());
var segments = uri.AbsolutePath.Split('/');
var fileName = segments[^1];
return $"{baseProxyUrl}/{fileName}{queryParams}";
}
// Relative URL - rewrite to proxy
return $"{baseProxyUrl}/{url.Trim()}{queryParams}";
}
var lines = manifestContent.Split('\n');
var result = new System.Text.StringBuilder();
foreach (var line in lines)
{
if (string.IsNullOrWhiteSpace(line))
{
result.AppendLine(line);
}
else if (line.StartsWith('#'))
{
// HLS tag line - check for URI="..." attributes (e.g., #EXT-X-MAP:URI="init.mp4")
if (line.Contains("URI=\"", StringComparison.Ordinal))
{
var rewrittenLine = System.Text.RegularExpressions.Regex.Replace(
line,
@"URI=""([^""]+)""",
match => $"URI=\"{RewriteUrl(match.Groups[1].Value)}\"");
result.AppendLine(rewrittenLine);
}
else
{
// Keep other metadata lines as-is
result.AppendLine(line);
}
}
else
{
// Non-tag line with URL - rewrite it
result.AppendLine(RewriteUrl(line));
}
}
return result.ToString();
}
/// <summary>
/// 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> /// <summary>
/// Proxies image requests from SRF CDN, fixing Content-Type headers. /// Proxies image requests from SRF CDN, fixing Content-Type headers.
/// </summary> /// </summary>
@@ -442,7 +390,7 @@ public class StreamProxyController : ControllerBase
.ConfigureAwait(false); .ConfigureAwait(false);
// Determine correct content type // Determine correct content type
var contentType = GetImageContentType(decodedUrl); var contentType = MimeTypeHelper.GetImageContentType(decodedUrl);
_logger.LogDebug( _logger.LogDebug(
"Returning proxied image ({Length} bytes, {ContentType})", "Returning proxied image ({Length} bytes, {ContentType})",
@@ -459,36 +407,36 @@ public class StreamProxyController : ControllerBase
} }
/// <summary> /// <summary>
/// Gets the content type for an image based on URL or file extension. /// Generates a placeholder image with the given text centered.
/// </summary> /// </summary>
/// <param name="url">The image URL.</param> /// <param name="text">The text to display (base64 encoded).</param>
/// <returns>The MIME content type.</returns> /// <returns>A PNG image with the text centered.</returns>
private static string GetImageContentType(string url) [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); string decodedText;
var path = uri.AbsolutePath.ToLowerInvariant(); try
if (path.EndsWith(".png", StringComparison.Ordinal))
{ {
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)) _logger.LogDebug("Generating placeholder image for: {Text}", decodedText);
{
return "image/gif";
}
if (path.EndsWith(".webp", StringComparison.Ordinal)) var imageStream = PlaceholderImageGenerator.GeneratePlaceholder(decodedText);
{ return File(imageStream, "image/png");
return "image/webp";
}
// Default to JPEG for SRF images (most common)
return "image/jpeg";
} }
} }
@@ -14,6 +14,7 @@
<PackageReference Include="Jellyfin.Controller" Version="10.9.11" /> <PackageReference Include="Jellyfin.Controller" Version="10.9.11" />
<PackageReference Include="Jellyfin.Model" Version="10.9.11" /> <PackageReference Include="Jellyfin.Model" Version="10.9.11" />
<PackageReference Include="Socks5" Version="1.1.0" /> <PackageReference Include="Socks5" Version="1.1.0" />
<PackageReference Include="SkiaSharp" Version="2.88.8" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
@@ -27,4 +28,9 @@
<EmbeddedResource Include="Configuration\configPage.html" /> <EmbeddedResource Include="Configuration\configPage.html" />
</ItemGroup> </ItemGroup>
<ItemGroup>
<None Remove="..\assests\main logo.png" />
<EmbeddedResource Include="..\assests\main logo.png" LogicalName="Jellyfin.Plugin.SRFPlay.Images.logo.png" />
</ItemGroup>
</Project> </Project>
@@ -1,6 +1,5 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq;
using System.Net.Http; using System.Net.Http;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
@@ -19,22 +18,18 @@ namespace Jellyfin.Plugin.SRFPlay.Providers;
public class SRFEpisodeProvider : IRemoteMetadataProvider<Episode, EpisodeInfo> public class SRFEpisodeProvider : IRemoteMetadataProvider<Episode, EpisodeInfo>
{ {
private readonly ILogger<SRFEpisodeProvider> _logger; private readonly ILogger<SRFEpisodeProvider> _logger;
private readonly IHttpClientFactory _httpClientFactory;
private readonly IMediaCompositionFetcher _compositionFetcher; private readonly IMediaCompositionFetcher _compositionFetcher;
/// <summary> /// <summary>
/// Initializes a new instance of the <see cref="SRFEpisodeProvider"/> class. /// Initializes a new instance of the <see cref="SRFEpisodeProvider"/> class.
/// </summary> /// </summary>
/// <param name="loggerFactory">The logger factory.</param> /// <param name="logger">The logger.</param>
/// <param name="httpClientFactory">The HTTP client factory.</param>
/// <param name="compositionFetcher">The media composition fetcher.</param> /// <param name="compositionFetcher">The media composition fetcher.</param>
public SRFEpisodeProvider( public SRFEpisodeProvider(
ILoggerFactory loggerFactory, ILogger<SRFEpisodeProvider> logger,
IHttpClientFactory httpClientFactory,
IMediaCompositionFetcher compositionFetcher) IMediaCompositionFetcher compositionFetcher)
{ {
_logger = loggerFactory.CreateLogger<SRFEpisodeProvider>(); _logger = logger;
_httpClientFactory = httpClientFactory;
_compositionFetcher = compositionFetcher; _compositionFetcher = compositionFetcher;
} }
@@ -55,7 +50,7 @@ public class SRFEpisodeProvider : IRemoteMetadataProvider<Episode, EpisodeInfo>
var mediaComposition = await _compositionFetcher.GetMediaCompositionAsync(urn, cancellationToken).ConfigureAwait(false); var mediaComposition = await _compositionFetcher.GetMediaCompositionAsync(urn, cancellationToken).ConfigureAwait(false);
if (mediaComposition?.ChapterList != null && mediaComposition.ChapterList.Count > 0) if (mediaComposition?.HasChapters == true)
{ {
var chapter = mediaComposition.ChapterList[0]; var chapter = mediaComposition.ChapterList[0];
results.Add(new RemoteSearchResult results.Add(new RemoteSearchResult
@@ -98,7 +93,7 @@ public class SRFEpisodeProvider : IRemoteMetadataProvider<Episode, EpisodeInfo>
var mediaComposition = await _compositionFetcher.GetMediaCompositionAsync(urn, cancellationToken).ConfigureAwait(false); var mediaComposition = await _compositionFetcher.GetMediaCompositionAsync(urn, cancellationToken).ConfigureAwait(false);
if (mediaComposition?.ChapterList == null || mediaComposition.ChapterList.Count == 0) if (mediaComposition?.HasChapters != true)
{ {
_logger.LogWarning("No chapter information found for URN: {Urn}", urn); _logger.LogWarning("No chapter information found for URN: {Urn}", urn);
return result; return result;
@@ -5,6 +5,7 @@ using System.Net.Http.Headers;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using Jellyfin.Plugin.SRFPlay.Services.Interfaces; using Jellyfin.Plugin.SRFPlay.Services.Interfaces;
using Jellyfin.Plugin.SRFPlay.Utilities;
using MediaBrowser.Common.Net; using MediaBrowser.Common.Net;
using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Providers; using MediaBrowser.Controller.Providers;
@@ -91,7 +92,7 @@ public class SRFImageProvider : IRemoteImageProvider, IHasOrder
} }
// Extract images from chapters // Extract images from chapters
if (mediaComposition.ChapterList != null && mediaComposition.ChapterList.Count > 0) if (mediaComposition.HasChapters)
{ {
var chapter = mediaComposition.ChapterList[0]; var chapter = mediaComposition.ChapterList[0];
if (!string.IsNullOrEmpty(chapter.ImageUrl)) if (!string.IsNullOrEmpty(chapter.ImageUrl))
@@ -186,7 +187,7 @@ public class SRFImageProvider : IRemoteImageProvider, IHasOrder
if (needsContentTypeFix) if (needsContentTypeFix)
{ {
// Determine correct content type from URL extension or default to JPEG // Determine correct content type from URL extension or default to JPEG
var contentType = GetContentTypeFromUrl(url); var contentType = MimeTypeHelper.GetImageContentType(url);
if (!string.IsNullOrEmpty(contentType)) if (!string.IsNullOrEmpty(contentType))
{ {
_logger.LogInformation( _logger.LogInformation(
@@ -201,42 +202,4 @@ public class SRFImageProvider : IRemoteImageProvider, IHasOrder
return response; 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); var mediaComposition = await _compositionFetcher.GetMediaCompositionAsync(urn, cancellationToken, cacheDuration).ConfigureAwait(false);
if (mediaComposition?.ChapterList == null || mediaComposition.ChapterList.Count == 0) if (mediaComposition?.HasChapters != true)
{ {
_logger.LogWarning("No chapters found for URN: {Urn}", urn); _logger.LogWarning("No chapters found for URN: {Urn}", urn);
return sources; return sources;
@@ -116,7 +116,7 @@ public class SRFMediaProvider : IMediaSourceProvider
// Force fresh fetch with short cache duration // Force fresh fetch with short cache duration
var freshMediaComposition = await _compositionFetcher.GetMediaCompositionAsync(urn, cancellationToken, 1).ConfigureAwait(false); var freshMediaComposition = await _compositionFetcher.GetMediaCompositionAsync(urn, cancellationToken, 1).ConfigureAwait(false);
if (freshMediaComposition?.ChapterList != null && freshMediaComposition.ChapterList.Count > 0) if (freshMediaComposition?.HasChapters == true)
{ {
var freshChapter = freshMediaComposition.ChapterList[0]; var freshChapter = freshMediaComposition.ChapterList[0];
mediaSource = await _mediaSourceFactory.CreateMediaSourceAsync( mediaSource = await _mediaSourceFactory.CreateMediaSourceAsync(
@@ -1,13 +1,11 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq;
using System.Net.Http; using System.Net.Http;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using Jellyfin.Plugin.SRFPlay.Services.Interfaces; using Jellyfin.Plugin.SRFPlay.Services.Interfaces;
using MediaBrowser.Controller.Entities.TV; using MediaBrowser.Controller.Entities.TV;
using MediaBrowser.Controller.Providers; using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.Providers; using MediaBrowser.Model.Providers;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
@@ -19,22 +17,18 @@ namespace Jellyfin.Plugin.SRFPlay.Providers;
public class SRFSeriesProvider : IRemoteMetadataProvider<Series, SeriesInfo> public class SRFSeriesProvider : IRemoteMetadataProvider<Series, SeriesInfo>
{ {
private readonly ILogger<SRFSeriesProvider> _logger; private readonly ILogger<SRFSeriesProvider> _logger;
private readonly IHttpClientFactory _httpClientFactory;
private readonly IMediaCompositionFetcher _compositionFetcher; private readonly IMediaCompositionFetcher _compositionFetcher;
/// <summary> /// <summary>
/// Initializes a new instance of the <see cref="SRFSeriesProvider"/> class. /// Initializes a new instance of the <see cref="SRFSeriesProvider"/> class.
/// </summary> /// </summary>
/// <param name="loggerFactory">The logger factory.</param> /// <param name="logger">The logger.</param>
/// <param name="httpClientFactory">The HTTP client factory.</param>
/// <param name="compositionFetcher">The media composition fetcher.</param> /// <param name="compositionFetcher">The media composition fetcher.</param>
public SRFSeriesProvider( public SRFSeriesProvider(
ILoggerFactory loggerFactory, ILogger<SRFSeriesProvider> logger,
IHttpClientFactory httpClientFactory,
IMediaCompositionFetcher compositionFetcher) IMediaCompositionFetcher compositionFetcher)
{ {
_logger = loggerFactory.CreateLogger<SRFSeriesProvider>(); _logger = logger;
_httpClientFactory = httpClientFactory;
_compositionFetcher = compositionFetcher; _compositionFetcher = compositionFetcher;
} }
@@ -15,18 +15,22 @@ public class ExpirationCheckTask : IScheduledTask
{ {
private readonly ILogger<ExpirationCheckTask> _logger; private readonly ILogger<ExpirationCheckTask> _logger;
private readonly IContentExpirationService _expirationService; private readonly IContentExpirationService _expirationService;
private readonly IStreamProxyService _streamProxyService;
/// <summary> /// <summary>
/// Initializes a new instance of the <see cref="ExpirationCheckTask"/> class. /// Initializes a new instance of the <see cref="ExpirationCheckTask"/> class.
/// </summary> /// </summary>
/// <param name="logger">The logger instance.</param> /// <param name="logger">The logger instance.</param>
/// <param name="expirationService">The content expiration service.</param> /// <param name="expirationService">The content expiration service.</param>
/// <param name="streamProxyService">The stream proxy service.</param>
public ExpirationCheckTask( public ExpirationCheckTask(
ILogger<ExpirationCheckTask> logger, ILogger<ExpirationCheckTask> logger,
IContentExpirationService expirationService) IContentExpirationService expirationService,
IStreamProxyService streamProxyService)
{ {
_logger = logger; _logger = logger;
_expirationService = expirationService; _expirationService = expirationService;
_streamProxyService = streamProxyService;
} }
/// <inheritdoc /> /// <inheritdoc />
@@ -56,27 +60,14 @@ public class ExpirationCheckTask : IScheduledTask
return; return;
} }
// Get expiration statistics first // Check and remove expired content
progress?.Report(25); progress?.Report(25);
var (total, expired, expiringSoon) = await _expirationService.GetExpirationStatisticsAsync(cancellationToken).ConfigureAwait(false);
_logger.LogInformation(
"Expiration statistics - Total: {Total}, Expired: {Expired}, Expiring Soon: {ExpiringSoon}",
total,
expired,
expiringSoon);
if (expired == 0)
{
_logger.LogInformation("No expired content found");
progress?.Report(100);
return;
}
// Remove expired content
progress?.Report(50);
var removedCount = await _expirationService.CheckAndRemoveExpiredContentAsync(cancellationToken).ConfigureAwait(false); var removedCount = await _expirationService.CheckAndRemoveExpiredContentAsync(cancellationToken).ConfigureAwait(false);
// Clean up old stream proxy mappings
progress?.Report(75);
_streamProxyService.CleanupOldMappings();
progress?.Report(100); progress?.Report(100);
_logger.LogInformation("SRF Play expiration check task completed. Removed {Count} expired items", removedCount); _logger.LogInformation("SRF Play expiration check task completed. Removed {Count} expired items", removedCount);
} }
@@ -1,6 +1,5 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Linq; using System.Linq;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
@@ -16,8 +15,8 @@ namespace Jellyfin.Plugin.SRFPlay.Services;
/// </summary> /// </summary>
public class CategoryService : ICategoryService public class CategoryService : ICategoryService
{ {
private readonly ILogger _logger; private readonly ILogger<CategoryService> _logger;
private readonly ILoggerFactory _loggerFactory; private readonly ISRFApiClientFactory _apiClientFactory;
private readonly TimeSpan _topicsCacheDuration = TimeSpan.FromHours(24); private readonly TimeSpan _topicsCacheDuration = TimeSpan.FromHours(24);
private Dictionary<string, PlayV3Topic>? _topicsCache; private Dictionary<string, PlayV3Topic>? _topicsCache;
private DateTime _topicsCacheExpiry = DateTime.MinValue; private DateTime _topicsCacheExpiry = DateTime.MinValue;
@@ -25,19 +24,15 @@ public class CategoryService : ICategoryService
/// <summary> /// <summary>
/// Initializes a new instance of the <see cref="CategoryService"/> class. /// Initializes a new instance of the <see cref="CategoryService"/> class.
/// </summary> /// </summary>
/// <param name="loggerFactory">The logger factory.</param> /// <param name="logger">The logger.</param>
public CategoryService(ILoggerFactory loggerFactory) /// <param name="apiClientFactory">The API client factory.</param>
public CategoryService(ILogger<CategoryService> logger, ISRFApiClientFactory apiClientFactory)
{ {
_loggerFactory = loggerFactory; _logger = logger;
_logger = loggerFactory.CreateLogger<CategoryService>(); _apiClientFactory = apiClientFactory;
} }
/// <summary> /// <inheritdoc />
/// 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>
public async Task<List<PlayV3Topic>> GetTopicsAsync(string businessUnit, CancellationToken cancellationToken = default) public async Task<List<PlayV3Topic>> GetTopicsAsync(string businessUnit, CancellationToken cancellationToken = default)
{ {
// Return cached topics if still valid // Return cached topics if still valid
@@ -48,7 +43,7 @@ public class CategoryService : ICategoryService
} }
_logger.LogInformation("Fetching topics for business unit: {BusinessUnit}", businessUnit); _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); var topics = await apiClient.GetAllTopicsAsync(businessUnit, cancellationToken).ConfigureAwait(false);
if (topics != null && topics.Count > 0) if (topics != null && topics.Count > 0)
@@ -65,13 +60,7 @@ public class CategoryService : ICategoryService
return topics ?? new List<PlayV3Topic>(); return topics ?? new List<PlayV3Topic>();
} }
/// <summary> /// <inheritdoc />
/// 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>
public async Task<PlayV3Topic?> GetTopicByIdAsync(string topicId, string businessUnit, CancellationToken cancellationToken = default) public async Task<PlayV3Topic?> GetTopicByIdAsync(string topicId, string businessUnit, CancellationToken cancellationToken = default)
{ {
// Ensure topics are loaded // Ensure topics are loaded
@@ -83,70 +72,14 @@ public class CategoryService : ICategoryService
return _topicsCache?.GetValueOrDefault(topicId); return _topicsCache?.GetValueOrDefault(topicId);
} }
/// <summary> /// <inheritdoc />
/// 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>
public async Task<List<PlayV3Show>> GetShowsByTopicAsync( public async Task<List<PlayV3Show>> GetShowsByTopicAsync(
string topicId, string topicId,
string businessUnit, string businessUnit,
int maxResults = 50, int maxResults = 50,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
{ {
using var apiClient = new SRFApiClient(_loggerFactory); using var apiClient = _apiClientFactory.CreateClient();
var allShows = await apiClient.GetAllShowsAsync(businessUnit, cancellationToken).ConfigureAwait(false); var allShows = await apiClient.GetAllShowsAsync(businessUnit, cancellationToken).ConfigureAwait(false);
if (allShows == null || allShows.Count == 0) if (allShows == null || allShows.Count == 0)
@@ -165,43 +98,29 @@ public class CategoryService : ICategoryService
return filteredShows; return filteredShows;
} }
/// <summary> /// <inheritdoc />
/// 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>
public void ClearCache() public void ClearCache()
{ {
_topicsCache = null; _topicsCache = null;
_topicsCacheExpiry = DateTime.MinValue; _topicsCacheExpiry = DateTime.MinValue;
_logger.LogInformation("Topics cache cleared"); _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); 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 // If we can't fetch the content, consider it expired
_logger.LogWarning("Could not fetch media composition for URN: {Urn}, treating as expired", urn); _logger.LogWarning("Could not fetch media composition for URN: {Urn}, treating as expired", urn);
@@ -135,72 +135,4 @@ public class ContentExpirationService : IContentExpirationService
return isExpired; return isExpired;
} }
/// <summary>
/// Gets statistics about content expiration.
/// </summary>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>Tuple with total count, expired count, and items expiring soon.</returns>
public async Task<(int Total, int Expired, int ExpiringSoon)> GetExpirationStatisticsAsync(CancellationToken cancellationToken)
{
var total = 0;
var expired = 0;
var expiringSoon = 0;
var soonThreshold = DateTime.UtcNow.AddDays(7); // Items expiring within 7 days
try
{
var query = new InternalItemsQuery
{
HasAnyProviderId = new Dictionary<string, string> { { "SRF", string.Empty } },
IsVirtualItem = false
};
var items = _libraryManager.GetItemList(query);
total = items.Count;
foreach (var item in items)
{
if (cancellationToken.IsCancellationRequested)
{
break;
}
try
{
var urn = item.ProviderIds.GetValueOrDefault("SRF");
if (string.IsNullOrEmpty(urn))
{
continue;
}
var mediaComposition = await _compositionFetcher.GetMediaCompositionAsync(urn, cancellationToken).ConfigureAwait(false);
if (mediaComposition?.ChapterList != null && mediaComposition.ChapterList.Count > 0)
{
var chapter = mediaComposition.ChapterList[0];
if (_streamResolver.IsContentExpired(chapter))
{
expired++;
}
else if (chapter.ValidTo.HasValue && chapter.ValidTo.Value.ToUniversalTime() <= soonThreshold)
{
expiringSoon++;
}
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error checking expiration statistics for item: {Name}", item.Name);
}
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error getting expiration statistics");
}
return (total, expired, expiringSoon);
}
} }
@@ -5,6 +5,7 @@ using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using Jellyfin.Plugin.SRFPlay.Api; using Jellyfin.Plugin.SRFPlay.Api;
using Jellyfin.Plugin.SRFPlay.Services.Interfaces; using Jellyfin.Plugin.SRFPlay.Services.Interfaces;
using Jellyfin.Plugin.SRFPlay.Utilities;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.SRFPlay.Services; namespace Jellyfin.Plugin.SRFPlay.Services;
@@ -37,111 +38,20 @@ public class ContentRefreshService : IContentRefreshService
/// <returns>List of URNs for new content.</returns> /// <returns>List of URNs for new content.</returns>
public async Task<List<string>> RefreshLatestContentAsync(CancellationToken cancellationToken) public async Task<List<string>> RefreshLatestContentAsync(CancellationToken cancellationToken)
{ {
var urns = new List<string>(); var config = Plugin.Instance?.Configuration;
if (config == null || !config.EnableLatestContent)
try
{ {
var config = Plugin.Instance?.Configuration; _logger.LogDebug("Latest content refresh is disabled");
if (config == null || !config.EnableLatestContent) return new List<string>();
{
_logger.LogDebug("Latest content refresh is disabled");
return urns;
}
_logger.LogInformation("Refreshing latest content for business unit: {BusinessUnit}", config.BusinessUnit);
using var apiClient = _apiClientFactory.CreateClient();
var businessUnit = config.BusinessUnit.ToString().ToLowerInvariant();
// Get all shows from Play v3 API
var shows = await apiClient.GetAllShowsAsync(businessUnit, cancellationToken).ConfigureAwait(false);
if (shows == null || shows.Count == 0)
{
_logger.LogWarning("No shows found for business unit: {BusinessUnit}", config.BusinessUnit);
return urns;
}
_logger.LogInformation("Found {Count} shows, fetching latest episodes from each", shows.Count);
// Get latest episodes from each show (limit to 20 shows to avoid overwhelming)
var showsToFetch = shows.Where(s => s.NumberOfEpisodes > 0)
.OrderByDescending(s => s.NumberOfEpisodes)
.Take(20)
.ToList();
foreach (var show in showsToFetch)
{
if (show.Id == null)
{
continue;
}
try
{
var videos = await apiClient.GetVideosForShowAsync(businessUnit, show.Id, cancellationToken).ConfigureAwait(false);
if (videos != null && videos.Count > 0)
{
_logger.LogDebug("Show {Show} ({ShowId}): Found {Count} videos", show.Title, show.Id, videos.Count);
// Filter to videos that are actually published (validFrom in the past)
var now = DateTime.UtcNow;
var publishedVideos = videos.Where(v =>
v.ValidFrom == null || v.ValidFrom.Value.ToUniversalTime() <= now).ToList();
_logger.LogDebug("Show {Show}: {PublishedCount} published out of {TotalCount} videos", show.Title, publishedVideos.Count, videos.Count);
if (publishedVideos.Count > 0)
{
// Take only the most recent published video from each show
var latestVideo = publishedVideos.OrderByDescending(v => v.Date).FirstOrDefault();
if (latestVideo?.Urn != null)
{
urns.Add(latestVideo.Urn);
_logger.LogInformation(
"Added latest video from show {Show}: {Title} (URN: {Urn}, Date: {Date}, ValidFrom: {ValidFrom}, ValidTo: {ValidTo})",
show.Title,
latestVideo.Title,
latestVideo.Urn,
latestVideo.Date,
latestVideo.ValidFrom,
latestVideo.ValidTo);
}
else
{
_logger.LogWarning("Show {Show}: Latest video has null URN", show.Title);
}
}
else
{
_logger.LogDebug("Show {Show} has no published videos yet", show.Title);
}
}
else
{
_logger.LogDebug("Show {Show} ({ShowId}): No videos returned from API", show.Title, show.Id);
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Error fetching videos for show {ShowId}", show.Id);
}
// Respect cancellation
if (cancellationToken.IsCancellationRequested)
{
break;
}
}
_logger.LogInformation("Refreshed {Count} latest content items from {ShowCount} shows", urns.Count, showsToFetch.Count);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error refreshing latest content");
} }
return urns; return await FetchVideosFromShowsAsync(
config.BusinessUnit.ToLowerString(),
minEpisodeCount: 0,
maxShows: 20,
videosPerShow: 1,
contentType: "latest",
cancellationToken).ConfigureAwait(false);
} }
/// <summary> /// <summary>
@@ -151,43 +61,62 @@ public class ContentRefreshService : IContentRefreshService
/// <param name="cancellationToken">The cancellation token.</param> /// <param name="cancellationToken">The cancellation token.</param>
/// <returns>List of URNs for trending content.</returns> /// <returns>List of URNs for trending content.</returns>
public async Task<List<string>> RefreshTrendingContentAsync(CancellationToken cancellationToken) public async Task<List<string>> RefreshTrendingContentAsync(CancellationToken cancellationToken)
{
var config = Plugin.Instance?.Configuration;
if (config == null || !config.EnableTrendingContent)
{
_logger.LogDebug("Trending content refresh is disabled");
return new List<string>();
}
return await FetchVideosFromShowsAsync(
config.BusinessUnit.ToLowerString(),
minEpisodeCount: 10,
maxShows: 15,
videosPerShow: 2,
contentType: "trending",
cancellationToken).ConfigureAwait(false);
}
/// <summary>
/// Fetches videos from shows based on filter criteria.
/// </summary>
private async Task<List<string>> FetchVideosFromShowsAsync(
string businessUnit,
int minEpisodeCount,
int maxShows,
int videosPerShow,
string contentType,
CancellationToken cancellationToken)
{ {
var urns = new List<string>(); var urns = new List<string>();
try try
{ {
var config = Plugin.Instance?.Configuration; _logger.LogInformation("Refreshing {ContentType} content for business unit: {BusinessUnit}", contentType, businessUnit);
if (config == null || !config.EnableTrendingContent)
{
_logger.LogDebug("Trending content refresh is disabled");
return urns;
}
_logger.LogInformation("Refreshing trending content for business unit: {BusinessUnit}", config.BusinessUnit);
using var apiClient = _apiClientFactory.CreateClient(); using var apiClient = _apiClientFactory.CreateClient();
var businessUnit = config.BusinessUnit.ToString().ToLowerInvariant();
// Get all shows from Play v3 API
var shows = await apiClient.GetAllShowsAsync(businessUnit, cancellationToken).ConfigureAwait(false); var shows = await apiClient.GetAllShowsAsync(businessUnit, cancellationToken).ConfigureAwait(false);
if (shows == null || shows.Count == 0) if (shows == null || shows.Count == 0)
{ {
_logger.LogWarning("No shows found for business unit: {BusinessUnit}", config.BusinessUnit); _logger.LogWarning("No shows found for business unit: {BusinessUnit}", businessUnit);
return urns; return urns;
} }
_logger.LogInformation("Found {Count} shows, fetching popular content", shows.Count); _logger.LogInformation("Found {Count} shows, fetching {ContentType} content", shows.Count, contentType);
// Get videos from popular shows (those with many episodes) var filteredShows = shows
var popularShows = shows.Where(s => s.NumberOfEpisodes > 10) .Where(s => s.NumberOfEpisodes > minEpisodeCount)
.OrderByDescending(s => s.NumberOfEpisodes) .OrderByDescending(s => s.NumberOfEpisodes)
.Take(15) .Take(maxShows)
.ToList(); .ToList();
foreach (var show in popularShows) var now = DateTime.UtcNow;
foreach (var show in filteredShows)
{ {
if (show.Id == null) if (show.Id == null || cancellationToken.IsCancellationRequested)
{ {
continue; continue;
} }
@@ -195,110 +124,48 @@ public class ContentRefreshService : IContentRefreshService
try try
{ {
var videos = await apiClient.GetVideosForShowAsync(businessUnit, show.Id, cancellationToken).ConfigureAwait(false); var videos = await apiClient.GetVideosForShowAsync(businessUnit, show.Id, cancellationToken).ConfigureAwait(false);
if (videos != null && videos.Count > 0) if (videos == null || videos.Count == 0)
{ {
_logger.LogDebug("Show {Show} ({ShowId}): Found {Count} videos for trending", show.Title, show.Id, videos.Count); _logger.LogDebug("Show {Show} ({ShowId}): No videos returned from API", show.Title, show.Id);
continue;
// Filter to videos that are actually published (validFrom in the past)
var now = DateTime.UtcNow;
var publishedVideos = videos.Where(v =>
v.ValidFrom == null || v.ValidFrom.Value.ToUniversalTime() <= now).ToList();
_logger.LogDebug("Show {Show}: {PublishedCount} published out of {TotalCount} videos for trending", show.Title, publishedVideos.Count, videos.Count);
if (publishedVideos.Count > 0)
{
// Take 2 recent published videos from each popular show
var recentVideos = publishedVideos.OrderByDescending(v => v.Date).Take(2);
foreach (var video in recentVideos)
{
if (video.Urn != null)
{
urns.Add(video.Urn);
_logger.LogInformation(
"Added trending video from show {Show}: {Title} (URN: {Urn}, Date: {Date}, ValidFrom: {ValidFrom}, ValidTo: {ValidTo})",
show.Title,
video.Title,
video.Urn,
video.Date,
video.ValidFrom,
video.ValidTo);
}
else
{
_logger.LogWarning("Show {Show}: Trending video has null URN - {Title}", show.Title, video.Title);
}
}
}
} }
else
_logger.LogDebug("Show {Show} ({ShowId}): Found {Count} videos", show.Title, show.Id, videos.Count);
// Filter to videos that are actually published (validFrom in the past)
var publishedVideos = videos
.Where(v => v.ValidFrom == null || v.ValidFrom.Value.ToUniversalTime() <= now)
.OrderByDescending(v => v.Date)
.Take(videosPerShow)
.ToList();
foreach (var video in publishedVideos)
{ {
_logger.LogDebug("Show {Show} ({ShowId}): No videos returned from API for trending", show.Title, show.Id); if (video.Urn != null)
{
urns.Add(video.Urn);
_logger.LogDebug(
"Added {ContentType} video from show {Show}: {Title} (URN: {Urn})",
contentType,
show.Title,
video.Title,
video.Urn);
}
} }
} }
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogWarning(ex, "Error fetching videos for show {ShowId}", show.Id); _logger.LogWarning(ex, "Error fetching videos for show {ShowId}", show.Id);
} }
// Respect cancellation
if (cancellationToken.IsCancellationRequested)
{
break;
}
} }
_logger.LogInformation("Refreshed {Count} trending content items from {ShowCount} shows", urns.Count, popularShows.Count); _logger.LogInformation("Refreshed {Count} {ContentType} content items from {ShowCount} shows", urns.Count, contentType, filteredShows.Count);
} }
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogError(ex, "Error refreshing trending content"); _logger.LogError(ex, "Error refreshing {ContentType} content", contentType);
} }
return urns; return urns;
} }
/// <summary>
/// Refreshes all content (latest and trending).
/// </summary>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>Tuple with counts of latest and trending items.</returns>
public async Task<(int LatestCount, int TrendingCount)> RefreshAllContentAsync(CancellationToken cancellationToken)
{
_logger.LogInformation("Starting full content refresh");
var latestUrns = await RefreshLatestContentAsync(cancellationToken).ConfigureAwait(false);
var trendingUrns = await RefreshTrendingContentAsync(cancellationToken).ConfigureAwait(false);
var latestCount = latestUrns.Count;
var trendingCount = trendingUrns.Count;
_logger.LogInformation(
"Content refresh completed. Latest: {LatestCount}, Trending: {TrendingCount}",
latestCount,
trendingCount);
return (latestCount, trendingCount);
}
/// <summary>
/// Gets content recommendations (combines latest and trending).
/// </summary>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>List of recommended URNs.</returns>
public async Task<List<string>> GetRecommendedContentAsync(CancellationToken cancellationToken)
{
var recommendations = new HashSet<string>();
var latestUrns = await RefreshLatestContentAsync(cancellationToken).ConfigureAwait(false);
var trendingUrns = await RefreshTrendingContentAsync(cancellationToken).ConfigureAwait(false);
foreach (var urn in latestUrns.Concat(trendingUrns))
{
recommendations.Add(urn);
}
_logger.LogInformation("Generated {Count} content recommendations", recommendations.Count);
return recommendations.ToList();
}
} }
@@ -27,21 +27,6 @@ public interface ICategoryService
/// <returns>The topic, or null if not found.</returns> /// <returns>The topic, or null if not found.</returns>
Task<PlayV3Topic?> GetTopicByIdAsync(string topicId, string businessUnit, CancellationToken cancellationToken = default); 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> /// <summary>
/// Gets shows for a specific topic, sorted by number of episodes. /// Gets shows for a specific topic, sorted by number of episodes.
/// </summary> /// </summary>
@@ -56,13 +41,6 @@ public interface ICategoryService
int maxResults = 50, int maxResults = 50,
CancellationToken cancellationToken = default); 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> /// <summary>
/// Clears the topics cache. /// Clears the topics cache.
/// </summary> /// </summary>
@@ -14,11 +14,4 @@ public interface IContentExpirationService
/// <param name="cancellationToken">The cancellation token.</param> /// <param name="cancellationToken">The cancellation token.</param>
/// <returns>The number of items removed.</returns> /// <returns>The number of items removed.</returns>
Task<int> CheckAndRemoveExpiredContentAsync(CancellationToken cancellationToken); Task<int> CheckAndRemoveExpiredContentAsync(CancellationToken cancellationToken);
/// <summary>
/// Gets statistics about content expiration.
/// </summary>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>Tuple with total count, expired count, and items expiring soon.</returns>
Task<(int Total, int Expired, int ExpiringSoon)> GetExpirationStatisticsAsync(CancellationToken cancellationToken);
} }
@@ -22,18 +22,4 @@ public interface IContentRefreshService
/// <param name="cancellationToken">The cancellation token.</param> /// <param name="cancellationToken">The cancellation token.</param>
/// <returns>List of URNs for trending content.</returns> /// <returns>List of URNs for trending content.</returns>
Task<List<string>> RefreshTrendingContentAsync(CancellationToken cancellationToken); Task<List<string>> RefreshTrendingContentAsync(CancellationToken cancellationToken);
/// <summary>
/// Refreshes all content (latest and trending).
/// </summary>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>Tuple with counts of latest and trending items.</returns>
Task<(int LatestCount, int TrendingCount)> RefreshAllContentAsync(CancellationToken cancellationToken);
/// <summary>
/// Gets content recommendations (combines latest and trending).
/// </summary>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>List of recommended URNs.</returns>
Task<List<string>> GetRecommendedContentAsync(CancellationToken cancellationToken);
} }
@@ -22,20 +22,8 @@ public interface IMetadataCache
/// <param name="mediaComposition">The media composition to cache.</param> /// <param name="mediaComposition">The media composition to cache.</param>
void SetMediaComposition(string urn, MediaComposition mediaComposition); void SetMediaComposition(string urn, MediaComposition mediaComposition);
/// <summary>
/// Removes media composition from cache.
/// </summary>
/// <param name="urn">The URN.</param>
void RemoveMediaComposition(string urn);
/// <summary> /// <summary>
/// Clears all cached data. /// Clears all cached data.
/// </summary> /// </summary>
void Clear(); void Clear();
/// <summary>
/// Gets the cache statistics.
/// </summary>
/// <returns>A tuple with cache count and size estimate.</returns>
(int Count, long SizeEstimate) GetStatistics();
} }
@@ -8,15 +8,6 @@ namespace Jellyfin.Plugin.SRFPlay.Services.Interfaces;
/// </summary> /// </summary>
public interface IStreamProxyService public interface IStreamProxyService
{ {
/// <summary>
/// Registers a stream for proxying with an already-authenticated URL.
/// </summary>
/// <param name="itemId">The item ID.</param>
/// <param name="authenticatedUrl">The authenticated stream URL.</param>
/// <param name="urn">The SRF URN for this content (used for re-fetching fresh URLs).</param>
/// <param name="isLiveStream">Whether this is a livestream (livestreams always fetch fresh URLs).</param>
void RegisterStream(string itemId, string authenticatedUrl, string? urn = null, bool isLiveStream = false);
/// <summary> /// <summary>
/// Registers a stream for deferred authentication (authenticates on first playback request). /// Registers a stream for deferred authentication (authenticates on first playback request).
/// Use this when browsing to avoid wasting 30-second tokens before the user clicks play. /// Use this when browsing to avoid wasting 30-second tokens before the user clicks play.
@@ -38,8 +29,9 @@ public interface IStreamProxyService
/// Gets the authenticated URL for an item. /// Gets the authenticated URL for an item.
/// </summary> /// </summary>
/// <param name="itemId">The item ID.</param> /// <param name="itemId">The item ID.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The authenticated URL, or null if not found or expired.</returns> /// <returns>The authenticated URL, or null if not found or expired.</returns>
string? GetAuthenticatedUrl(string itemId); Task<string?> GetAuthenticatedUrlAsync(string itemId, CancellationToken cancellationToken = default);
/// <summary> /// <summary>
/// Fetches and rewrites an HLS manifest to use proxy URLs. /// Fetches and rewrites an HLS manifest to use proxy URLs.
@@ -55,9 +47,19 @@ public interface IStreamProxyService
/// </summary> /// </summary>
/// <param name="itemId">The item ID.</param> /// <param name="itemId">The item ID.</param>
/// <param name="segmentPath">The segment path.</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> /// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The segment content as bytes.</returns> /// <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>
/// Rewrites URLs in a variant (sub) manifest to point to the proxy.
/// </summary>
/// <param name="manifestContent">The variant manifest content.</param>
/// <param name="baseProxyUrl">The base proxy URL (without query params).</param>
/// <param name="queryParams">Query parameters to append to rewritten URLs (e.g., "?token=abc").</param>
/// <returns>The rewritten manifest content.</returns>
string RewriteVariantManifestUrls(string manifestContent, string baseProxyUrl, string queryParams);
/// <summary> /// <summary>
/// Cleans up old and expired stream mappings. /// Cleans up old and expired stream mappings.
@@ -1,5 +1,6 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using Jellyfin.Plugin.SRFPlay.Api.Models; using Jellyfin.Plugin.SRFPlay.Api.Models;
@@ -78,16 +79,18 @@ public class MediaSourceFactory : IMediaSourceFactory
itemId, itemId,
isLiveStream); isLiveStream);
// Create MediaSourceInfo with minimal settings to let clients determine playback // Create MediaSourceInfo with codec info so clients know they can direct play
// Don't specify Container or MediaStreams - let the .m3u8 path trigger HLS detection // Provide MediaStreams with H.264+AAC so Android TV/ExoPlayer doesn't trigger transcoding
var mediaStreams = CreateMediaStreams(qualityPreference);
var mediaSource = new MediaSourceInfo var mediaSource = new MediaSourceInfo
{ {
Id = itemId, Id = itemId,
Name = chapter.Title, Name = chapter.Title,
Path = proxyUrl, Path = proxyUrl,
Protocol = MediaProtocol.Http, Protocol = MediaProtocol.Http,
// Empty container - let clients detect HLS from .m3u8 extension // Use "hls" to trigger hls.js player in web client
Container = string.Empty, Container = "hls",
SupportsDirectStream = true, SupportsDirectStream = true,
SupportsDirectPlay = true, SupportsDirectPlay = true,
SupportsTranscoding = false, SupportsTranscoding = false,
@@ -96,17 +99,16 @@ public class MediaSourceFactory : IMediaSourceFactory
RunTimeTicks = chapter.Duration > 0 ? TimeSpan.FromMilliseconds(chapter.Duration).Ticks : null, RunTimeTicks = chapter.Duration > 0 ? TimeSpan.FromMilliseconds(chapter.Duration).Ticks : null,
VideoType = VideoType.VideoFile, VideoType = VideoType.VideoFile,
IsInfiniteStream = isLiveStream, IsInfiniteStream = isLiveStream,
// Don't use RequiresOpening - it forces Jellyfin to transcode which breaks token auth
RequiresOpening = false, RequiresOpening = false,
RequiresClosing = false, RequiresClosing = false,
// Disable probing - we provide stream info directly
SupportsProbing = false, SupportsProbing = false,
ReadAtNativeFramerate = isLiveStream, ReadAtNativeFramerate = isLiveStream,
// Don't specify MediaStreams - let client determine codec compatibility // Provide codec info so clients know they can direct play H.264+AAC
MediaStreams = new List<MediaStream>(), MediaStreams = mediaStreams.ToList(),
// Reduce analyze duration for faster startup (3000ms is Jellyfin default, 1000ms for live)
AnalyzeDurationMs = isLiveStream ? 1000 : 3000, AnalyzeDurationMs = isLiveStream ? 1000 : 3000,
// Ignore DTS timestamps for live streams to avoid sync issues
IgnoreDts = isLiveStream, IgnoreDts = isLiveStream,
// Ignore index for live streams
IgnoreIndex = isLiveStream, IgnoreIndex = isLiveStream,
}; };
+12 -132
View File
@@ -1,6 +1,5 @@
using System; using System;
using System.Collections.Concurrent; using System.Collections.Concurrent;
using System.Threading;
using Jellyfin.Plugin.SRFPlay.Api.Models; using Jellyfin.Plugin.SRFPlay.Api.Models;
using Jellyfin.Plugin.SRFPlay.Services.Interfaces; using Jellyfin.Plugin.SRFPlay.Services.Interfaces;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
@@ -10,12 +9,10 @@ namespace Jellyfin.Plugin.SRFPlay.Services;
/// <summary> /// <summary>
/// Service for caching metadata from SRF API. /// Service for caching metadata from SRF API.
/// </summary> /// </summary>
public sealed class MetadataCache : IMetadataCache, IDisposable public sealed class MetadataCache : IMetadataCache
{ {
private readonly ILogger<MetadataCache> _logger; private readonly ILogger<MetadataCache> _logger;
private readonly ConcurrentDictionary<string, CacheEntry<MediaComposition>> _mediaCompositionCache; private readonly ConcurrentDictionary<string, CacheEntry<MediaComposition>> _mediaCompositionCache;
private readonly ReaderWriterLockSlim _lock;
private bool _disposed;
/// <summary> /// <summary>
/// Initializes a new instance of the <see cref="MetadataCache"/> class. /// Initializes a new instance of the <see cref="MetadataCache"/> class.
@@ -25,19 +22,6 @@ public sealed class MetadataCache : IMetadataCache, IDisposable
{ {
_logger = logger; _logger = logger;
_mediaCompositionCache = new ConcurrentDictionary<string, CacheEntry<MediaComposition>>(); _mediaCompositionCache = new ConcurrentDictionary<string, CacheEntry<MediaComposition>>();
_lock = new ReaderWriterLockSlim();
}
/// <summary>
/// Disposes resources.
/// </summary>
public void Dispose()
{
if (!_disposed)
{
_lock?.Dispose();
_disposed = true;
}
} }
/// <summary> /// <summary>
@@ -53,30 +37,15 @@ public sealed class MetadataCache : IMetadataCache, IDisposable
return null; return null;
} }
try if (_mediaCompositionCache.TryGetValue(urn, out var entry))
{ {
_lock.EnterReadLock(); if (entry.IsValid(cacheDurationMinutes))
try
{ {
if (_mediaCompositionCache.TryGetValue(urn, out var entry)) _logger.LogDebug("Cache hit for URN: {Urn}", urn);
{ return entry.Value;
if (entry.IsValid(cacheDurationMinutes)) }
{
_logger.LogDebug("Cache hit for URN: {Urn}", urn);
return entry.Value;
}
_logger.LogDebug("Cache entry expired for URN: {Urn}", urn); _logger.LogDebug("Cache entry expired for URN: {Urn}", urn);
}
}
finally
{
_lock.ExitReadLock();
}
}
catch (ObjectDisposedException)
{
return null;
} }
return null; return null;
@@ -94,56 +63,9 @@ public sealed class MetadataCache : IMetadataCache, IDisposable
return; return;
} }
try var entry = new CacheEntry<MediaComposition>(mediaComposition);
{ _mediaCompositionCache.AddOrUpdate(urn, entry, (key, oldValue) => entry);
_lock.EnterWriteLock(); _logger.LogDebug("Cached media composition for URN: {Urn}", urn);
try
{
var entry = new CacheEntry<MediaComposition>(mediaComposition);
_mediaCompositionCache.AddOrUpdate(urn, entry, (key, oldValue) => entry);
_logger.LogDebug("Cached media composition for URN: {Urn}", urn);
}
finally
{
_lock.ExitWriteLock();
}
}
catch (ObjectDisposedException)
{
// Cache is disposed, ignore
}
}
/// <summary>
/// Removes media composition from cache.
/// </summary>
/// <param name="urn">The URN.</param>
public void RemoveMediaComposition(string urn)
{
if (string.IsNullOrEmpty(urn))
{
return;
}
try
{
_lock.EnterWriteLock();
try
{
if (_mediaCompositionCache.TryRemove(urn, out _))
{
_logger.LogDebug("Removed cached media composition for URN: {Urn}", urn);
}
}
finally
{
_lock.ExitWriteLock();
}
}
catch (ObjectDisposedException)
{
// Cache is disposed, ignore
}
} }
/// <summary> /// <summary>
@@ -151,50 +73,8 @@ public sealed class MetadataCache : IMetadataCache, IDisposable
/// </summary> /// </summary>
public void Clear() public void Clear()
{ {
try _mediaCompositionCache.Clear();
{ _logger.LogInformation("Cleared metadata cache");
_lock.EnterWriteLock();
try
{
_mediaCompositionCache.Clear();
_logger.LogInformation("Cleared metadata cache");
}
finally
{
_lock.ExitWriteLock();
}
}
catch (ObjectDisposedException)
{
// Cache is disposed, ignore
}
}
/// <summary>
/// Gets the cache statistics.
/// </summary>
/// <returns>A tuple with cache count and size estimate.</returns>
public (int Count, long SizeEstimate) GetStatistics()
{
try
{
_lock.EnterReadLock();
try
{
var count = _mediaCompositionCache.Count;
// Rough estimate: average 50KB per entry
var sizeEstimate = count * 50L * 1024;
return (count, sizeEstimate);
}
finally
{
_lock.ExitReadLock();
}
}
catch (ObjectDisposedException)
{
return (0, 0);
}
} }
/// <summary> /// <summary>
@@ -10,6 +10,7 @@ using System.Web;
using Jellyfin.Plugin.SRFPlay.Api; using Jellyfin.Plugin.SRFPlay.Api;
using Jellyfin.Plugin.SRFPlay.Configuration; using Jellyfin.Plugin.SRFPlay.Configuration;
using Jellyfin.Plugin.SRFPlay.Services.Interfaces; using Jellyfin.Plugin.SRFPlay.Services.Interfaces;
using MediaBrowser.Common.Net;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.SRFPlay.Services; namespace Jellyfin.Plugin.SRFPlay.Services;
@@ -17,14 +18,13 @@ namespace Jellyfin.Plugin.SRFPlay.Services;
/// <summary> /// <summary>
/// Service for proxying SRF Play streams and managing authentication. /// Service for proxying SRF Play streams and managing authentication.
/// </summary> /// </summary>
public class StreamProxyService : IStreamProxyService, IDisposable public class StreamProxyService : IStreamProxyService
{ {
private readonly ILogger<StreamProxyService> _logger; private readonly ILogger<StreamProxyService> _logger;
private readonly IStreamUrlResolver _streamResolver; private readonly IStreamUrlResolver _streamResolver;
private readonly IMediaCompositionFetcher _compositionFetcher; private readonly IMediaCompositionFetcher _compositionFetcher;
private readonly HttpClient _httpClient; private readonly IHttpClientFactory _httpClientFactory;
private readonly ConcurrentDictionary<string, StreamInfo> _streamMappings; private readonly ConcurrentDictionary<string, StreamInfo> _streamMappings;
private bool _disposed;
/// <summary> /// <summary>
/// Initializes a new instance of the <see cref="StreamProxyService"/> class. /// Initializes a new instance of the <see cref="StreamProxyService"/> class.
@@ -32,82 +32,20 @@ public class StreamProxyService : IStreamProxyService, IDisposable
/// <param name="logger">The logger.</param> /// <param name="logger">The logger.</param>
/// <param name="streamResolver">The stream URL resolver.</param> /// <param name="streamResolver">The stream URL resolver.</param>
/// <param name="compositionFetcher">The media composition fetcher.</param> /// <param name="compositionFetcher">The media composition fetcher.</param>
/// <param name="httpClientFactory">The HTTP client factory.</param>
public StreamProxyService( public StreamProxyService(
ILogger<StreamProxyService> logger, ILogger<StreamProxyService> logger,
IStreamUrlResolver streamResolver, IStreamUrlResolver streamResolver,
IMediaCompositionFetcher compositionFetcher) IMediaCompositionFetcher compositionFetcher,
IHttpClientFactory httpClientFactory)
{ {
_logger = logger; _logger = logger;
_streamResolver = streamResolver; _streamResolver = streamResolver;
_compositionFetcher = compositionFetcher; _compositionFetcher = compositionFetcher;
_httpClient = new HttpClient _httpClientFactory = httpClientFactory;
{
Timeout = TimeSpan.FromSeconds(30)
};
_streamMappings = new ConcurrentDictionary<string, StreamInfo>(); _streamMappings = new ConcurrentDictionary<string, StreamInfo>();
} }
/// <summary>
/// Registers a stream for proxying.
/// </summary>
/// <param name="itemId">The item ID.</param>
/// <param name="authenticatedUrl">The authenticated stream URL.</param>
/// <param name="urn">The SRF URN for this content (used for re-fetching fresh URLs).</param>
/// <param name="isLiveStream">Whether this is a livestream (livestreams always fetch fresh URLs).</param>
public void RegisterStream(string itemId, string authenticatedUrl, string? urn = null, bool isLiveStream = false)
{
var tokenExpiry = ExtractTokenExpiry(authenticatedUrl);
var unauthenticatedUrl = StripAuthenticationFromUrl(authenticatedUrl);
var streamInfo = new StreamInfo
{
AuthenticatedUrl = authenticatedUrl,
UnauthenticatedUrl = unauthenticatedUrl,
RegisteredAt = DateTime.UtcNow,
TokenExpiresAt = tokenExpiry,
Urn = urn,
IsLiveStream = isLiveStream,
LastLivestreamFetchAt = isLiveStream ? DateTime.UtcNow : null
};
// Register with the provided item ID
_streamMappings.AddOrUpdate(itemId, streamInfo, (key, old) => streamInfo);
// Also register with alternative GUID formats to handle Jellyfin's ID transformations
if (Guid.TryParse(itemId, out var guid))
{
var formats = new[]
{
guid.ToString("N"), // Without dashes: 00000000000000000000000000000000
guid.ToString("D"), // With dashes: 00000000-0000-0000-0000-000000000000
guid.ToString("B"), // With braces: {00000000-0000-0000-0000-000000000000}
};
foreach (var format in formats)
{
if (format != itemId) // Don't duplicate the original
{
_streamMappings.AddOrUpdate(format, streamInfo, (key, old) => streamInfo);
}
}
_logger.LogDebug("Registered stream with {Count} GUID format variations", formats.Length);
}
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> /// <summary>
/// Registers a stream for deferred authentication (authenticates on first playback request). /// Registers a stream for deferred authentication (authenticates on first playback request).
/// </summary> /// </summary>
@@ -129,27 +67,7 @@ public class StreamProxyService : IStreamProxyService, IDisposable
NeedsAuthentication = true NeedsAuthentication = true
}; };
// Register with the provided item ID RegisterWithGuidFormats(itemId, streamInfo);
_streamMappings.AddOrUpdate(itemId, streamInfo, (key, old) => streamInfo);
// Also register with alternative GUID formats
if (Guid.TryParse(itemId, out var guid))
{
var formats = new[]
{
guid.ToString("N"),
guid.ToString("D"),
guid.ToString("B"),
};
foreach (var format in formats)
{
if (format != itemId)
{
_streamMappings.AddOrUpdate(format, streamInfo, (key, old) => streamInfo);
}
}
}
_logger.LogDebug( _logger.LogDebug(
"Registered deferred stream for item {ItemId} (URN: {Urn}, will authenticate on first access)", "Registered deferred stream for item {ItemId} (URN: {Urn}, will authenticate on first access)",
@@ -191,10 +109,11 @@ public class StreamProxyService : IStreamProxyService, IDisposable
/// Gets the authenticated URL for an item. /// Gets the authenticated URL for an item.
/// </summary> /// </summary>
/// <param name="itemId">The item ID.</param> /// <param name="itemId">The item ID.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The authenticated URL, or null if not found or expired.</returns> /// <returns>The authenticated URL, or null if not found or expired.</returns>
public string? GetAuthenticatedUrl(string itemId) public async Task<string?> GetAuthenticatedUrlAsync(string itemId, CancellationToken cancellationToken = default)
{ {
_logger.LogInformation("GetAuthenticatedUrl called for itemId: {ItemId}", itemId); _logger.LogInformation("GetAuthenticatedUrlAsync called for itemId: {ItemId}", itemId);
// Try direct lookup first // Try direct lookup first
if (_streamMappings.TryGetValue(itemId, out var streamInfo)) if (_streamMappings.TryGetValue(itemId, out var streamInfo))
@@ -204,7 +123,7 @@ public class StreamProxyService : IStreamProxyService, IDisposable
? (streamInfo.TokenExpiresAt.Value - DateTime.UtcNow).TotalSeconds ? (streamInfo.TokenExpiresAt.Value - DateTime.UtcNow).TotalSeconds
: -1; : -1;
_logger.LogInformation( _logger.LogInformation(
"Found stream by direct lookup for itemId: {ItemId} - NeedsAuth={NeedsAuth}, IsLive={IsLive}, Urn={Urn}, TokenLeft={TokenLeft:F0}s, AuthUrl={HasAuth}", "Found stream by direct lookup for itemId: {ItemId} - NeedsAuth={NeedsAuth}, IsLive={IsLive}, Urn={Urn}, TokenLeft={TokenLeft:F0}s, AuthUrl={HasAuth}",
itemId, itemId,
streamInfo.NeedsAuthentication, streamInfo.NeedsAuthentication,
streamInfo.IsLiveStream, streamInfo.IsLiveStream,
@@ -225,14 +144,14 @@ public class StreamProxyService : IStreamProxyService, IDisposable
itemId, itemId,
freshStream.Value.Key); freshStream.Value.Key);
_streamMappings.AddOrUpdate(itemId, freshStream.Value.Value, (key, old) => freshStream.Value.Value); _streamMappings.AddOrUpdate(itemId, freshStream.Value.Value, (key, old) => freshStream.Value.Value);
return ValidateAndReturnStream(itemId, freshStream.Value.Value); return await ValidateAndReturnStreamAsync(itemId, freshStream.Value.Value, cancellationToken).ConfigureAwait(false);
} }
} }
return ValidateAndReturnStream(itemId, streamInfo); return await ValidateAndReturnStreamAsync(itemId, streamInfo, cancellationToken).ConfigureAwait(false);
} }
_logger.LogWarning("No direct match for itemId: {ItemId}, trying fallbacks... (Registered streams: {Count})", itemId, _streamMappings.Count); _logger.LogWarning("No direct match for itemId: {ItemId}, trying fallbacks... (Registered streams: {Count})", itemId, _streamMappings.Count);
// Fallback: Try to find by GUID variations (with/without dashes) // Fallback: Try to find by GUID variations (with/without dashes)
// This handles cases where Jellyfin uses different GUID formats // This handles cases where Jellyfin uses different GUID formats
@@ -248,7 +167,7 @@ public class StreamProxyService : IStreamProxyService, IDisposable
"Found stream by GUID normalization - Requested: {RequestedId}, Registered: {RegisteredId}", "Found stream by GUID normalization - Requested: {RequestedId}, Registered: {RegisteredId}",
itemId, itemId,
kvp.Key); kvp.Key);
var url = ValidateAndReturnStream(kvp.Key, kvp.Value); var url = await ValidateAndReturnStreamAsync(kvp.Key, kvp.Value, cancellationToken).ConfigureAwait(false);
if (url != null) if (url != null)
{ {
return url; // Found valid stream return url; // Found valid stream
@@ -261,6 +180,50 @@ public class StreamProxyService : IStreamProxyService, IDisposable
} }
} }
// 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) // Last resort: Use active stream fallbacks (helps when Jellyfin creates random transcoding session IDs)
var activeStreams = _streamMappings.Where(kvp => var activeStreams = _streamMappings.Where(kvp =>
{ {
@@ -282,7 +245,7 @@ public class StreamProxyService : IStreamProxyService, IDisposable
// Register the transcoding session ID as an alias (update if stale alias exists) // Register the transcoding session ID as an alias (update if stale alias exists)
_streamMappings.AddOrUpdate(itemId, activeStreams[0].Value, (key, old) => activeStreams[0].Value); _streamMappings.AddOrUpdate(itemId, activeStreams[0].Value, (key, old) => activeStreams[0].Value);
return ValidateAndReturnStream(activeStreams[0].Key, activeStreams[0].Value); return await ValidateAndReturnStreamAsync(activeStreams[0].Key, activeStreams[0].Value, cancellationToken).ConfigureAwait(false);
} }
// If multiple active streams, use the most recently registered one (likely the one being transcoded) // If multiple active streams, use the most recently registered one (likely the one being transcoded)
@@ -305,7 +268,7 @@ public class StreamProxyService : IStreamProxyService, IDisposable
// Register the transcoding session ID as an alias (update if stale alias exists) // Register the transcoding session ID as an alias (update if stale alias exists)
_streamMappings.AddOrUpdate(itemId, mostRecent.Value, (key, old) => mostRecent.Value); _streamMappings.AddOrUpdate(itemId, mostRecent.Value, (key, old) => mostRecent.Value);
return ValidateAndReturnStream(mostRecent.Key, mostRecent.Value); return await ValidateAndReturnStreamAsync(mostRecent.Key, mostRecent.Value, cancellationToken).ConfigureAwait(false);
} }
} }
@@ -320,7 +283,7 @@ public class StreamProxyService : IStreamProxyService, IDisposable
/// <summary> /// <summary>
/// Validates a stream and returns its URL if valid. /// Validates a stream and returns its URL if valid.
/// </summary> /// </summary>
private string? ValidateAndReturnStream(string itemId, StreamInfo streamInfo) private async Task<string?> ValidateAndReturnStreamAsync(string itemId, StreamInfo streamInfo, CancellationToken cancellationToken)
{ {
// Handle deferred authentication (first playback after browsing) // Handle deferred authentication (first playback after browsing)
if (streamInfo.NeedsAuthentication) if (streamInfo.NeedsAuthentication)
@@ -329,7 +292,7 @@ public class StreamProxyService : IStreamProxyService, IDisposable
"First playback for item {ItemId} - authenticating stream on-demand", "First playback for item {ItemId} - authenticating stream on-demand",
itemId); itemId);
var authenticatedUrl = AuthenticateOnDemand(itemId, streamInfo); var authenticatedUrl = await AuthenticateOnDemandAsync(itemId, streamInfo, cancellationToken).ConfigureAwait(false);
if (authenticatedUrl != null) if (authenticatedUrl != null)
{ {
return authenticatedUrl; return authenticatedUrl;
@@ -369,7 +332,7 @@ public class StreamProxyService : IStreamProxyService, IDisposable
tokenTimeLeft, tokenTimeLeft,
timeSinceLastFetch); timeSinceLastFetch);
var freshUrl = FetchFreshStreamUrl(itemId, streamInfo); var freshUrl = await FetchFreshStreamUrlAsync(itemId, streamInfo, cancellationToken).ConfigureAwait(false);
if (freshUrl != null) if (freshUrl != null)
{ {
return freshUrl; return freshUrl;
@@ -403,7 +366,7 @@ public class StreamProxyService : IStreamProxyService, IDisposable
now); now);
// Try to refresh the token // Try to refresh the token
var refreshedUrl = RefreshToken(itemId, streamInfo); var refreshedUrl = await RefreshTokenAsync(itemId, streamInfo, cancellationToken).ConfigureAwait(false);
if (refreshedUrl != null) if (refreshedUrl != null)
{ {
_logger.LogInformation("Successfully refreshed token for item {ItemId}", itemId); _logger.LogInformation("Successfully refreshed token for item {ItemId}", itemId);
@@ -428,7 +391,7 @@ public class StreamProxyService : IStreamProxyService, IDisposable
/// <summary> /// <summary>
/// Fetches a fresh stream URL from the SRF API for livestreams. /// Fetches a fresh stream URL from the SRF API for livestreams.
/// </summary> /// </summary>
private string? FetchFreshStreamUrl(string itemId, StreamInfo streamInfo) private async Task<string?> FetchFreshStreamUrlAsync(string itemId, StreamInfo streamInfo, CancellationToken cancellationToken)
{ {
if (string.IsNullOrEmpty(streamInfo.Urn)) if (string.IsNullOrEmpty(streamInfo.Urn))
{ {
@@ -438,10 +401,9 @@ public class StreamProxyService : IStreamProxyService, IDisposable
try try
{ {
// Use short cache duration (5 min) for livestreams // Use short cache duration (5 min) for livestreams
var mediaComposition = _compositionFetcher.GetMediaCompositionAsync(streamInfo.Urn, CancellationToken.None, 5) var mediaComposition = await _compositionFetcher.GetMediaCompositionAsync(streamInfo.Urn, cancellationToken, 5).ConfigureAwait(false);
.GetAwaiter().GetResult();
if (mediaComposition?.ChapterList == null || mediaComposition.ChapterList.Count == 0) if (mediaComposition?.HasChapters != true)
{ {
_logger.LogWarning("No chapters found when refreshing livestream URL for URN: {Urn}", streamInfo.Urn); _logger.LogWarning("No chapters found when refreshing livestream URL for URN: {Urn}", streamInfo.Urn);
return null; return null;
@@ -458,8 +420,7 @@ public class StreamProxyService : IStreamProxyService, IDisposable
} }
// Authenticate the fresh URL // Authenticate the fresh URL
var authenticatedUrl = _streamResolver.GetAuthenticatedStreamUrlAsync(streamUrl, CancellationToken.None) var authenticatedUrl = await _streamResolver.GetAuthenticatedStreamUrlAsync(streamUrl, cancellationToken).ConfigureAwait(false);
.GetAwaiter().GetResult();
// Update the stored stream info with the fresh data // Update the stored stream info with the fresh data
var newTokenExpiry = ExtractTokenExpiry(authenticatedUrl); var newTokenExpiry = ExtractTokenExpiry(authenticatedUrl);
@@ -486,7 +447,7 @@ public class StreamProxyService : IStreamProxyService, IDisposable
/// <summary> /// <summary>
/// Attempts to refresh an expired token. /// Attempts to refresh an expired token.
/// </summary> /// </summary>
private string? RefreshToken(string itemId, StreamInfo streamInfo) private async Task<string?> RefreshTokenAsync(string itemId, StreamInfo streamInfo, CancellationToken cancellationToken)
{ {
if (string.IsNullOrEmpty(streamInfo.UnauthenticatedUrl)) if (string.IsNullOrEmpty(streamInfo.UnauthenticatedUrl))
{ {
@@ -496,10 +457,10 @@ public class StreamProxyService : IStreamProxyService, IDisposable
try try
{ {
// Re-authenticate the stream URL synchronously (blocking call) // Re-authenticate the stream URL
var newAuthenticatedUrl = _streamResolver.GetAuthenticatedStreamUrlAsync( var newAuthenticatedUrl = await _streamResolver.GetAuthenticatedStreamUrlAsync(
streamInfo.UnauthenticatedUrl, streamInfo.UnauthenticatedUrl,
CancellationToken.None).GetAwaiter().GetResult(); cancellationToken).ConfigureAwait(false);
if (string.IsNullOrEmpty(newAuthenticatedUrl)) if (string.IsNullOrEmpty(newAuthenticatedUrl))
{ {
@@ -528,7 +489,7 @@ public class StreamProxyService : IStreamProxyService, IDisposable
/// <summary> /// <summary>
/// Authenticates a stream on-demand (first playback after browsing). /// Authenticates a stream on-demand (first playback after browsing).
/// </summary> /// </summary>
private string? AuthenticateOnDemand(string itemId, StreamInfo streamInfo) private async Task<string?> AuthenticateOnDemandAsync(string itemId, StreamInfo streamInfo, CancellationToken cancellationToken)
{ {
if (string.IsNullOrEmpty(streamInfo.UnauthenticatedUrl)) if (string.IsNullOrEmpty(streamInfo.UnauthenticatedUrl))
{ {
@@ -539,9 +500,9 @@ public class StreamProxyService : IStreamProxyService, IDisposable
try try
{ {
// Authenticate the stream URL // Authenticate the stream URL
var authenticatedUrl = _streamResolver.GetAuthenticatedStreamUrlAsync( var authenticatedUrl = await _streamResolver.GetAuthenticatedStreamUrlAsync(
streamInfo.UnauthenticatedUrl, streamInfo.UnauthenticatedUrl,
CancellationToken.None).GetAwaiter().GetResult(); cancellationToken).ConfigureAwait(false);
if (string.IsNullOrEmpty(authenticatedUrl)) if (string.IsNullOrEmpty(authenticatedUrl))
{ {
@@ -669,7 +630,7 @@ public class StreamProxyService : IStreamProxyService, IDisposable
string baseProxyUrl, string baseProxyUrl,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
{ {
var authenticatedUrl = GetAuthenticatedUrl(itemId); var authenticatedUrl = await GetAuthenticatedUrlAsync(itemId, cancellationToken).ConfigureAwait(false);
if (authenticatedUrl == null) if (authenticatedUrl == null)
{ {
return null; return null;
@@ -678,14 +639,15 @@ public class StreamProxyService : IStreamProxyService, IDisposable
try try
{ {
_logger.LogInformation("Fetching manifest from: {Url}", authenticatedUrl); _logger.LogInformation("Fetching manifest from: {Url}", authenticatedUrl);
var manifestContent = await _httpClient.GetStringAsync(authenticatedUrl, cancellationToken).ConfigureAwait(false); using var httpClient = _httpClientFactory.CreateClient(NamedClient.Default);
var manifestContent = await httpClient.GetStringAsync(authenticatedUrl, cancellationToken).ConfigureAwait(false);
_logger.LogInformation("Original manifest ({Length} bytes):\n{Content}", manifestContent.Length, manifestContent); _logger.LogDebug("Original manifest ({Length} bytes):\n{Content}", manifestContent.Length, manifestContent);
// Rewrite the manifest to replace Akamai URLs with proxy URLs // Rewrite the manifest to replace Akamai URLs with proxy URLs
var rewrittenContent = RewriteManifestUrls(manifestContent, authenticatedUrl, baseProxyUrl); var rewrittenContent = RewriteManifestUrls(manifestContent, authenticatedUrl, baseProxyUrl);
_logger.LogInformation("Rewritten manifest for item {ItemId} ({Length} bytes):\n{Content}", itemId, rewrittenContent.Length, rewrittenContent); _logger.LogDebug("Rewritten manifest for item {ItemId} ({Length} bytes):\n{Content}", itemId, rewrittenContent.Length, rewrittenContent);
return rewrittenContent; return rewrittenContent;
} }
catch (Exception ex) catch (Exception ex)
@@ -700,14 +662,16 @@ public class StreamProxyService : IStreamProxyService, IDisposable
/// </summary> /// </summary>
/// <param name="itemId">The item ID.</param> /// <param name="itemId">The item ID.</param>
/// <param name="segmentPath">The segment path.</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> /// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The segment content as bytes.</returns> /// <returns>The segment content as bytes.</returns>
public async Task<byte[]?> GetSegmentAsync( public async Task<byte[]?> GetSegmentAsync(
string itemId, string itemId,
string segmentPath, string segmentPath,
string? queryString = null,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
{ {
var authenticatedUrl = GetAuthenticatedUrl(itemId); var authenticatedUrl = await GetAuthenticatedUrlAsync(itemId, cancellationToken).ConfigureAwait(false);
if (authenticatedUrl == null) if (authenticatedUrl == null)
{ {
return null; return null;
@@ -719,19 +683,32 @@ public class StreamProxyService : IStreamProxyService, IDisposable
var baseUri = new Uri(authenticatedUrl); var baseUri = new Uri(authenticatedUrl);
var baseUrl = $"{baseUri.Scheme}://{baseUri.Host}{string.Join('/', baseUri.AbsolutePath.Split('/')[..^1])}"; var baseUrl = $"{baseUri.Scheme}://{baseUri.Host}{string.Join('/', baseUri.AbsolutePath.Split('/')[..^1])}";
// Extract query parameters (auth tokens) from authenticated URL // Use the original query string from the request (preserves segment-specific params like ?m=timestamp)
var queryParams = baseUri.Query; // 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 // Build full segment URL
var segmentUrl = $"{baseUrl}/{segmentPath}{queryParams}"; var segmentUrl = $"{baseUrl}/{segmentPath}{queryParams}";
_logger.LogInformation( _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, authenticatedUrl,
baseUrl, baseUrl,
segmentPath, segmentPath,
queryString ?? "(none)",
segmentUrl); segmentUrl);
var segmentData = await _httpClient.GetByteArrayAsync(segmentUrl, cancellationToken).ConfigureAwait(false); using var httpClient = _httpClientFactory.CreateClient(NamedClient.Default);
var segmentData = await httpClient.GetByteArrayAsync(segmentUrl, cancellationToken).ConfigureAwait(false);
_logger.LogDebug("Successfully fetched segment {SegmentPath} ({Size} bytes)", segmentPath, segmentData.Length); _logger.LogDebug("Successfully fetched segment {SegmentPath} ({Size} bytes)", segmentPath, segmentData.Length);
return segmentData; return segmentData;
@@ -847,6 +824,65 @@ public class StreamProxyService : IStreamProxyService, IDisposable
} }
} }
/// <summary>
/// Rewrites URLs in a variant (sub) manifest to point to the proxy.
/// </summary>
/// <param name="manifestContent">The variant manifest content.</param>
/// <param name="baseProxyUrl">The base proxy URL (without query params).</param>
/// <param name="queryParams">Query parameters to append to rewritten URLs (e.g., "?token=abc").</param>
/// <returns>The rewritten manifest content.</returns>
public string RewriteVariantManifestUrls(string manifestContent, string baseProxyUrl, string queryParams)
{
string RewriteUrl(string url)
{
if (url.Contains("://", StringComparison.Ordinal))
{
// Absolute URL - extract filename and rewrite
var uri = new Uri(url.Trim());
var segments = uri.AbsolutePath.Split('/');
var fileName = segments[^1];
return $"{baseProxyUrl}/{fileName}{queryParams}";
}
// Relative URL - rewrite to proxy
return $"{baseProxyUrl}/{url.Trim()}{queryParams}";
}
var lines = manifestContent.Split('\n');
var result = new System.Text.StringBuilder();
foreach (var line in lines)
{
if (string.IsNullOrWhiteSpace(line))
{
result.AppendLine(line);
}
else if (line.StartsWith('#'))
{
// HLS tag line - check for URI="..." attributes (e.g., #EXT-X-MAP:URI="init.mp4")
if (line.Contains("URI=\"", StringComparison.Ordinal))
{
var rewrittenLine = Regex.Replace(
line,
@"URI=""([^""]+)""",
match => $"URI=\"{RewriteUrl(match.Groups[1].Value)}\"");
result.AppendLine(rewrittenLine);
}
else
{
result.AppendLine(line);
}
}
else
{
// Non-tag line with URL - rewrite it
result.AppendLine(RewriteUrl(line));
}
}
return result.ToString();
}
/// <summary> /// <summary>
/// Cleans up old and expired stream mappings. /// Cleans up old and expired stream mappings.
/// </summary> /// </summary>
@@ -892,31 +928,33 @@ public class StreamProxyService : IStreamProxyService, IDisposable
} }
/// <summary> /// <summary>
/// Disposes the service. /// Registers a stream with multiple GUID format variations to handle Jellyfin's ID transformations.
/// </summary> /// </summary>
public void Dispose() /// <param name="itemId">The item ID.</param>
/// <param name="streamInfo">The stream information to register.</param>
private void RegisterWithGuidFormats(string itemId, StreamInfo streamInfo)
{ {
Dispose(true); _streamMappings.AddOrUpdate(itemId, streamInfo, (key, old) => streamInfo);
GC.SuppressFinalize(this);
}
/// <summary> if (Guid.TryParse(itemId, out var guid))
/// Disposes the service.
/// </summary>
/// <param name="disposing">True if disposing.</param>
protected virtual void Dispose(bool disposing)
{
if (_disposed)
{ {
return; var formats = new[]
} {
guid.ToString("N"), // Without dashes: 00000000000000000000000000000000
guid.ToString("D"), // With dashes: 00000000-0000-0000-0000-000000000000
guid.ToString("B"), // With braces: {00000000-0000-0000-0000-000000000000}
};
if (disposing) foreach (var format in formats)
{ {
_httpClient?.Dispose(); if (format != itemId) // Don't duplicate the original
} {
_streamMappings.AddOrUpdate(format, streamInfo, (key, old) => streamInfo);
}
}
_disposed = true; _logger.LogDebug("Registered stream with {Count} GUID format variations", formats.Length);
}
} }
/// <summary> /// <summary>
@@ -8,6 +8,7 @@ using Jellyfin.Plugin.SRFPlay.Api.Models;
using Jellyfin.Plugin.SRFPlay.Configuration; using Jellyfin.Plugin.SRFPlay.Configuration;
using Jellyfin.Plugin.SRFPlay.Constants; using Jellyfin.Plugin.SRFPlay.Constants;
using Jellyfin.Plugin.SRFPlay.Services.Interfaces; using Jellyfin.Plugin.SRFPlay.Services.Interfaces;
using MediaBrowser.Common.Net;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.SRFPlay.Services; namespace Jellyfin.Plugin.SRFPlay.Services;
@@ -15,20 +16,20 @@ namespace Jellyfin.Plugin.SRFPlay.Services;
/// <summary> /// <summary>
/// Service for resolving stream URLs from media composition resources. /// Service for resolving stream URLs from media composition resources.
/// </summary> /// </summary>
public class StreamUrlResolver : IStreamUrlResolver, IDisposable public class StreamUrlResolver : IStreamUrlResolver
{ {
private readonly ILogger<StreamUrlResolver> _logger; private readonly ILogger<StreamUrlResolver> _logger;
private readonly HttpClient _httpClient; private readonly IHttpClientFactory _httpClientFactory;
private bool _disposed;
/// <summary> /// <summary>
/// Initializes a new instance of the <see cref="StreamUrlResolver"/> class. /// Initializes a new instance of the <see cref="StreamUrlResolver"/> class.
/// </summary> /// </summary>
/// <param name="logger">The logger instance.</param> /// <param name="logger">The logger instance.</param>
public StreamUrlResolver(ILogger<StreamUrlResolver> logger) /// <param name="httpClientFactory">The HTTP client factory.</param>
public StreamUrlResolver(ILogger<StreamUrlResolver> logger, IHttpClientFactory httpClientFactory)
{ {
_logger = logger; _logger = logger;
_httpClient = new HttpClient(); _httpClientFactory = httpClientFactory;
} }
/// <summary> /// <summary>
@@ -52,7 +53,7 @@ public class StreamUrlResolver : IStreamUrlResolver, IDisposable
// Filter out DRM-protected content // Filter out DRM-protected content
var nonDrmResources = chapter.ResourceList var nonDrmResources = chapter.ResourceList
.Where(r => r.DrmList == null || r.DrmList.ToString() == "[]") .Where(r => r.IsPlayable)
.ToList(); .ToList();
_logger.LogInformation( _logger.LogInformation(
@@ -135,14 +136,14 @@ public class StreamUrlResolver : IStreamUrlResolver, IDisposable
{ {
QualityPreference.HD => SelectHDResource(hlsResources) ?? SelectBestAvailableResource(hlsResources), QualityPreference.HD => SelectHDResource(hlsResources) ?? SelectBestAvailableResource(hlsResources),
QualityPreference.SD => SelectSDResource(hlsResources) ?? SelectBestAvailableResource(hlsResources), QualityPreference.SD => SelectSDResource(hlsResources) ?? SelectBestAvailableResource(hlsResources),
QualityPreference.Auto => SelectBestAvailableResource(hlsResources), QualityPreference.Auto => hlsResources.FirstOrDefault(),
_ => SelectBestAvailableResource(hlsResources) _ => hlsResources.FirstOrDefault()
}; };
if (selectedResource != null) if (selectedResource != null)
{ {
_logger.LogInformation( _logger.LogDebug(
"Selected stream for chapter {ChapterId}: Quality={Quality}, Protocol={Protocol}, URL={Url}", "Selected stream for chapter {ChapterId}: Quality={Quality}, Protocol={Protocol}, URL={Url}",
chapter.Id, chapter.Id,
selectedResource.Quality ?? "NULL", selectedResource.Quality ?? "NULL",
selectedResource.Protocol, selectedResource.Protocol,
@@ -163,10 +164,33 @@ public class StreamUrlResolver : IStreamUrlResolver, IDisposable
{ {
if (chapter?.ResourceList == null || chapter.ResourceList.Count == 0) if (chapter?.ResourceList == null || chapter.ResourceList.Count == 0)
{ {
_logger.LogWarning(
"Chapter {ChapterId}: ResourceList is null or empty. ResourceList count: {Count}, ResourceList type: {Type}",
chapter?.Id ?? "null",
chapter?.ResourceList?.Count ?? -1,
chapter?.ResourceList?.GetType().Name ?? "null");
return false; return false;
} }
return chapter.ResourceList.Any(r => r.DrmList == null || r.DrmList.ToString() == "[]"); _logger.LogInformation(
"Chapter {ChapterId}: Found {ResourceCount} resources",
chapter.Id,
chapter.ResourceList.Count);
foreach (var resource in chapter.ResourceList)
{
var urlPreview = resource.Url == null ? "null" : resource.Url.AsSpan(0, Math.Min(60, resource.Url.Length)).ToString() + "...";
_logger.LogDebug(
"Resource - URL: {Url}, Quality: {Quality}, DrmList: {DrmList}, DrmList type: {DrmListType}",
urlPreview,
resource.Quality,
resource.DrmList?.ToString() ?? "null",
resource.DrmList?.GetType().Name ?? "null");
}
var hasPlayable = chapter.ResourceList.Any(r => r.IsPlayable);
_logger.LogInformation("Chapter {ChapterId}: Has playable content: {HasPlayable}", chapter.Id, hasPlayable);
return hasPlayable;
} }
/// <summary> /// <summary>
@@ -247,7 +271,8 @@ public class StreamUrlResolver : IStreamUrlResolver, IDisposable
_logger.LogDebug("Fetching auth token from: {TokenUrl}", tokenUrl); _logger.LogDebug("Fetching auth token from: {TokenUrl}", tokenUrl);
var response = await _httpClient.GetAsync(tokenUrl, cancellationToken).ConfigureAwait(false); using var httpClient = _httpClientFactory.CreateClient(NamedClient.Default);
var response = await httpClient.GetAsync(tokenUrl, cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode(); response.EnsureSuccessStatusCode();
var jsonContent = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); var jsonContent = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
@@ -278,28 +303,4 @@ public class StreamUrlResolver : IStreamUrlResolver, IDisposable
return streamUrl; // Return original URL as fallback return streamUrl; // Return original URL as fallback
} }
} }
/// <inheritdoc/>
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}
/// <summary>
/// Releases the unmanaged resources and optionally releases the managed resources.
/// </summary>
/// <param name="disposing">True to release both managed and unmanaged resources; false to release only unmanaged resources.</param>
protected virtual void Dispose(bool disposing)
{
if (!_disposed)
{
if (disposing)
{
_httpClient?.Dispose();
}
_disposed = true;
}
}
} }
@@ -0,0 +1,19 @@
using Jellyfin.Plugin.SRFPlay.Configuration;
namespace Jellyfin.Plugin.SRFPlay.Utilities;
/// <summary>
/// Extension methods for common plugin operations.
/// </summary>
public static class Extensions
{
/// <summary>
/// Converts the BusinessUnit enum to its lowercase string representation.
/// </summary>
/// <param name="businessUnit">The business unit.</param>
/// <returns>The lowercase string representation.</returns>
public static string ToLowerString(this BusinessUnit businessUnit)
{
return businessUnit.ToString().ToLowerInvariant();
}
}
@@ -0,0 +1,74 @@
using System;
namespace Jellyfin.Plugin.SRFPlay.Utilities;
/// <summary>
/// Helper class for determining MIME content types.
/// </summary>
public static class MimeTypeHelper
{
/// <summary>
/// Gets the MIME content type for an image based on URL or file extension.
/// </summary>
/// <param name="url">The image URL.</param>
/// <returns>The MIME content type, defaulting to "image/jpeg" for SRF images.</returns>
public static string GetImageContentType(string? url)
{
if (string.IsNullOrEmpty(url))
{
return "image/jpeg";
}
var uri = new Uri(url);
var path = uri.AbsolutePath.ToLowerInvariant();
if (path.EndsWith(".png", StringComparison.Ordinal))
{
return "image/png";
}
if (path.EndsWith(".gif", StringComparison.Ordinal))
{
return "image/gif";
}
if (path.EndsWith(".webp", StringComparison.Ordinal))
{
return "image/webp";
}
if (path.EndsWith(".jpg", StringComparison.Ordinal) || path.EndsWith(".jpeg", StringComparison.Ordinal))
{
return "image/jpeg";
}
// Default to JPEG for SRF images (most common)
return "image/jpeg";
}
/// <summary>
/// Gets the MIME content type for a media segment based on file extension.
/// </summary>
/// <param name="path">The segment path or filename.</param>
/// <returns>The MIME content type.</returns>
public static string GetSegmentContentType(string path)
{
if (path.EndsWith(".ts", StringComparison.OrdinalIgnoreCase))
{
return "video/MP2T";
}
if (path.EndsWith(".mp4", StringComparison.OrdinalIgnoreCase) ||
path.EndsWith(".m4s", StringComparison.OrdinalIgnoreCase))
{
return "video/mp4";
}
if (path.EndsWith(".aac", StringComparison.OrdinalIgnoreCase))
{
return "audio/aac";
}
return "application/octet-stream";
}
}
@@ -0,0 +1,141 @@
using System.IO;
using SkiaSharp;
namespace Jellyfin.Plugin.SRFPlay.Utilities;
/// <summary>
/// Generates placeholder images for content without thumbnails.
/// </summary>
public static class PlaceholderImageGenerator
{
private const int Width = 640;
private const int Height = 360; // 16:9 aspect ratio
/// <summary>
/// Generates a placeholder image with the given text centered.
/// </summary>
/// <param name="text">The text to display (typically channel/show name).</param>
/// <returns>A memory stream containing the PNG image.</returns>
public static MemoryStream GeneratePlaceholder(string text)
{
using var surface = SKSurface.Create(new SKImageInfo(Width, Height));
var canvas = surface.Canvas;
// Dark gradient background
using var backgroundPaint = new SKPaint();
using var shader = SKShader.CreateLinearGradient(
new SKPoint(0, 0),
new SKPoint(Width, Height),
new[] { new SKColor(45, 45, 48), new SKColor(28, 28, 30) },
null,
SKShaderTileMode.Clamp);
backgroundPaint.Shader = shader;
canvas.DrawRect(0, 0, Width, Height, backgroundPaint);
// Text
using var textPaint = new SKPaint
{
Color = SKColors.White,
IsAntialias = true,
TextAlign = SKTextAlign.Center,
Typeface = SKTypeface.FromFamilyName("Arial", SKFontStyle.Bold)
};
// Calculate font size and wrap text if needed
float fontSize = 48;
textPaint.TextSize = fontSize;
var maxWidth = Width * 0.85f;
var maxHeight = Height * 0.8f;
var lines = WrapText(text, textPaint, maxWidth, maxHeight, ref fontSize);
// Draw each line centered
var lineHeight = fontSize * 1.2f;
var totalHeight = lines.Count * lineHeight;
var startY = ((Height - totalHeight) / 2) + fontSize;
foreach (var line in lines)
{
canvas.DrawText(line, Width / 2, startY, textPaint);
startY += lineHeight;
}
// Encode to PNG
using var image = surface.Snapshot();
using var data = image.Encode(SKEncodedImageFormat.Png, 100);
var stream = new MemoryStream();
data.SaveTo(stream);
stream.Position = 0;
return stream;
}
/// <summary>
/// Wraps text to fit within the specified width and height constraints.
/// </summary>
/// <param name="text">The text to wrap.</param>
/// <param name="paint">The paint to use for measuring.</param>
/// <param name="maxWidth">Maximum width for each line.</param>
/// <param name="maxHeight">Maximum total height.</param>
/// <param name="fontSize">Font size (will be adjusted if needed).</param>
/// <returns>List of text lines.</returns>
private static System.Collections.Generic.List<string> WrapText(
string text,
SKPaint paint,
float maxWidth,
float maxHeight,
ref float fontSize)
{
const float minFontSize = 20;
var words = text.Split(' ');
var lines = new System.Collections.Generic.List<string>();
while (fontSize >= minFontSize)
{
paint.TextSize = fontSize;
lines.Clear();
var currentLine = string.Empty;
foreach (var word in words)
{
var testLine = string.IsNullOrEmpty(currentLine) ? word : $"{currentLine} {word}";
var testWidth = paint.MeasureText(testLine);
if (testWidth > maxWidth && !string.IsNullOrEmpty(currentLine))
{
lines.Add(currentLine);
currentLine = word;
}
else
{
currentLine = testLine;
}
}
if (!string.IsNullOrEmpty(currentLine))
{
lines.Add(currentLine);
}
// Check if total height fits
var lineHeight = fontSize * 1.2f;
var totalHeight = lines.Count * lineHeight;
if (totalHeight <= maxHeight)
{
break;
}
// Reduce font size and try again
fontSize -= 2;
}
// If still doesn't fit, just return what we have
if (lines.Count == 0)
{
lines.Add(text);
}
return lines;
}
}
@@ -0,0 +1,27 @@
using System;
using System.Security.Cryptography;
using System.Text;
namespace Jellyfin.Plugin.SRFPlay.Utilities;
/// <summary>
/// Helper class for URN-related operations.
/// </summary>
public static class UrnHelper
{
/// <summary>
/// Generates a deterministic GUID from a URN.
/// This ensures the same URN always produces the same GUID.
/// MD5 is used for non-cryptographic purposes only (generating stable IDs).
/// </summary>
/// <param name="urn">The URN to convert.</param>
/// <returns>A deterministic GUID string.</returns>
#pragma warning disable CA5351 // MD5 is used for non-cryptographic purposes (ID generation)
public static string ToGuid(string urn)
{
var hash = MD5.HashData(Encoding.UTF8.GetBytes(urn));
var guid = new Guid(hash);
return guid.ToString();
}
#pragma warning restore CA5351
}
+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. **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 ## Features
- Access to SRF Play VOD content (video-on-demand, no DRM-protected content) - 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) - Support for all Swiss broadcasting units (SRF, RTS, RSI, RTR, SWI)
- Automatic content expiration handling - Automatic content expiration handling
- Latest and trending content discovery - Latest and trending content discovery
- Quality selection (Auto, SD, HD) - Quality selection (Auto lets CDN decide, SD prefers 480p/360p, HD prefers 1080p/720p)
- HLS streaming support with Akamai token authentication - HLS streaming support with Akamai token authentication
- Proxy support for routing traffic through alternate gateways - Proxy support for routing traffic through alternate gateways
- Smart caching with reduced TTL for upcoming livestreams - Smart caching with reduced TTL for upcoming livestreams
@@ -102,7 +112,7 @@ dotnet build
The compiled plugin will be in `bin/Debug/net8.0/` The compiled plugin will be in `bin/Debug/net8.0/`
## Installation ## Manual Installation
1. Build the plugin (see above) 1. Build the plugin (see above)
2. Copy the compiled DLL to your Jellyfin plugins directory 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 ## Configuration
- **Business Unit**: Select the Swiss broadcasting unit (default: SRF) - **Business Unit**: Select the Swiss broadcasting unit (default: SRF)
- **Quality Preference**: Choose video quality (Auto/SD/HD) - **Quality Preference**: Choose video quality Auto (first available, CDN decides), SD, or HD
- **Content Refresh Interval**: How often to check for new content (1-168 hours) - **Content Refresh Interval**: How often to check for new content (1-168 hours)
- **Expiration Check Interval**: How often to check for expired content (1-168 hours) - **Expiration Check Interval**: How often to check for expired content (1-168 hours)
- **Cache Duration**: How long to cache metadata (5-1440 minutes) - **Cache Duration**: How long to cache metadata (5-1440 minutes)
@@ -144,49 +154,78 @@ If you encounter issues with the plugin:
``` ```
Jellyfin.Plugin.SRFPlay/ Jellyfin.Plugin.SRFPlay/
├── Api/ ├── Api/
│ ├── Models/ # API response models │ ├── Models/ # API response models
│ │ ├── MediaComposition.cs │ │ ├── MediaComposition.cs # Root composition (HasChapters helper)
│ │ ├── Chapter.cs │ │ ├── Chapter.cs # Video/episode chapter with resources
│ │ ├── Resource.cs │ │ ├── Resource.cs # Stream URL entry (IsPlayable helper)
│ │ ├── Show.cs │ │ ├── Show.cs
│ │ ├── Episode.cs │ │ ├── Episode.cs
│ │ └── PlayV3/ # Play v3 API models │ │ └── PlayV3/ # Play v3 API models
│ │ ├── PlayV3Show.cs
│ │ ├── PlayV3Topic.cs
│ │ ├── PlayV3Video.cs
│ │ ├── PlayV3TvProgram.cs │ │ ├── PlayV3TvProgram.cs
│ │ ── PlayV3TvProgramGuideResponse.cs │ │ ── PlayV3Response.cs
└── SRFApiClient.cs # HTTP client for SRF API │ ├── PlayV3DirectResponse.cs
│ │ └── PlayV3DataContainer.cs
│ ├── SRFApiClient.cs # HTTP client for SRF APIs
│ ├── ISRFApiClientFactory.cs # Factory interface
│ └── SRFApiClientFactory.cs # Factory implementation
├── Channels/ ├── Channels/
│ └── SRFPlayChannel.cs # Channel implementation │ └── SRFPlayChannel.cs # Channel implementation
├── Configuration/ ├── Configuration/
│ ├── PluginConfiguration.cs │ ├── PluginConfiguration.cs
│ └── configPage.html │ └── configPage.html
├── Constants/
│ └── ApiEndpoints.cs # API base URLs and endpoint constants
├── Controllers/
│ └── StreamProxyController.cs # HLS proxy endpoints (master/variant/segment)
├── Services/ ├── Services/
│ ├── StreamUrlResolver.cs # HLS stream resolution & authentication │ ├── Interfaces/ # Service contracts
│ ├── MetadataCache.cs # Caching layer │ ├── IStreamProxyService.cs
│ ├── ContentExpirationService.cs # Expiration management │ ├── IStreamUrlResolver.cs
│ ├── ContentRefreshService.cs # Content discovery │ ├── IMediaCompositionFetcher.cs
└── CategoryService.cs # Topic/category management │ ├── IMediaSourceFactory.cs
│ │ ├── IMetadataCache.cs
│ │ ├── IContentRefreshService.cs
│ │ ├── IContentExpirationService.cs
│ │ └── ICategoryService.cs
│ ├── StreamProxyService.cs # HLS proxy: auth, manifest rewriting, segments
│ ├── StreamUrlResolver.cs # Stream selection & Akamai authentication
│ ├── MediaCompositionFetcher.cs # Cached API fetcher
│ ├── MediaSourceFactory.cs # Jellyfin MediaSourceInfo builder
│ ├── MetadataCache.cs # Thread-safe ConcurrentDictionary cache
│ ├── ContentExpirationService.cs
│ ├── ContentRefreshService.cs
│ └── CategoryService.cs
├── Providers/ ├── Providers/
│ ├── SRFSeriesProvider.cs # Series metadata │ ├── SRFSeriesProvider.cs # Series metadata
│ ├── SRFEpisodeProvider.cs # Episode metadata │ ├── SRFEpisodeProvider.cs # Episode metadata
│ ├── SRFImageProvider.cs # Image fetching │ ├── SRFImageProvider.cs # Image fetching
│ └── SRFMediaProvider.cs # Playback URLs │ └── SRFMediaProvider.cs # Playback URLs
├── Utilities/
│ ├── Extensions.cs # BusinessUnit.ToLowerString() extension
│ ├── MimeTypeHelper.cs # Content-type detection
│ ├── PlaceholderImageGenerator.cs
│ └── UrnHelper.cs # URN parsing utilities
├── ScheduledTasks/ ├── ScheduledTasks/
│ ├── ContentRefreshTask.cs # Periodic content refresh │ ├── ContentRefreshTask.cs # Periodic content refresh
│ └── ExpirationCheckTask.cs # Periodic expiration check │ └── ExpirationCheckTask.cs # Periodic expiration check
├── ServiceRegistrator.cs # DI registration ├── ServiceRegistrator.cs # DI registration
└── Plugin.cs # Main plugin entry point └── Plugin.cs # Main plugin entry point
``` ```
### Key Components ### Key Components
1. **API Client**: Handles all HTTP requests to SRF Integration Layer and Play v3 API 1. **API Client** (`SRFApiClient`): HTTP requests to SRF Integration Layer and Play v3 API, with proxy support
2. **Channel**: SRF Play channel with Latest, Trending, and Live Sports folders 2. **Channel** (`SRFPlayChannel`): SRF Play channel with Latest, Trending, and Live Sports folders
3. **Stream Resolver**: Extracts and selects optimal HLS streams with Akamai authentication 3. **Stream Proxy** (`StreamProxyService` + `StreamProxyController`): HLS proxy that handles Akamai token auth, manifest URL rewriting, deferred authentication, and token refresh for both VOD and livestreams
4. **Configuration**: User-configurable settings via Jellyfin dashboard 4. **Stream Resolver** (`StreamUrlResolver`): Selects optimal HLS stream by quality preference, filters DRM content
5. **Metadata Cache**: Thread-safe caching with dynamic TTL for livestreams 5. **Metadata Cache** (`MetadataCache`): Thread-safe `ConcurrentDictionary` cache with dynamic TTL for livestreams
6. **Content Providers**: Jellyfin integration for series, episodes, images, and media sources 6. **Media Composition Fetcher** (`MediaCompositionFetcher`): Cached wrapper around API client for media composition requests
7. **Scheduled Tasks**: Automatic content refresh and expiration management 7. **Content Providers** (`SRFSeriesProvider`, `SRFEpisodeProvider`, `SRFImageProvider`, `SRFMediaProvider`): Jellyfin integration for series, episodes, images, and media sources
8. **Service Layer**: Content discovery, expiration handling, stream resolution, and category management 8. **Scheduled Tasks**: Automatic content refresh and expiration management
9. **Utilities**: Business unit extensions, MIME type helpers, URN parsing, placeholder image generation
## Important Notes ## Important Notes
@@ -239,7 +278,9 @@ See LICENSE file for details.
## Acknowledgments ## Acknowledgments
This plugin was developed with inspiration from the excellent [Kodi SRG SSR addon](https://github.com/goggle/script.module.srgssr) by [@goggle](https://github.com/goggle). The Kodi addon served as a fantastic reference for understanding the SRG SSR API structure, authentication mechanisms, and handling of scheduled livestreams. This plugin was developed partly using [Claude Code](https://docs.anthropic.com/en/docs/claude-code) by Anthropic.
Inspired by the excellent [Kodi SRG SSR addon](https://github.com/goggle/script.module.srgssr) by [@goggle](https://github.com/goggle), which served as a fantastic reference for understanding the SRG SSR API structure, authentication mechanisms, and handling of scheduled livestreams.
## References ## References
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"
}
]
}
]