Compare commits

...

11 Commits

Author SHA1 Message Date
Gitea Actions
d313b68975 Update manifest.json for version 1.0.14 2025-12-30 12:38:41 +00:00
60434abd01 Use utf-8 decode everywhere
All checks were successful
🏗️ 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
757aab1943 Break-line for placeholder titles when too long
All checks were successful
🏗️ 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
2631c93444 moved repo install guide to top 2025-12-20 14:32:13 +01:00
7f71d3419c Add repo manifest
All checks were successful
🏗️ 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
dbbdd7eb6d utf-8 decdoding
All checks were successful
🏗️ 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
9146830546 remove live-tv as it has issues with URI resolution
All checks were successful
🏗️ Build Plugin / build (push) Successful in 2m43s
🧪 Test Plugin / test (push) Successful in 1m16s
2025-12-07 18:18:40 +01:00
0548fe7dec Use TV guide for livestreams
All checks were successful
🏗️ Build Plugin / build (push) Successful in 2m53s
🧪 Test Plugin / test (push) Successful in 1m21s
2025-12-07 17:41:48 +01:00
198fc4c58d more consolidation
All checks were successful
🚀 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
28 changed files with 823 additions and 679 deletions

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
.gitignore vendored
View File

@ -3,3 +3,4 @@ obj/
.vs/ .vs/
.idea/ .idea/
artifacts artifacts
*.log

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;

View File

@ -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; }
}

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 };
@ -287,12 +261,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);
_logger.LogDebug("Latest videos response length: {Length}", content.Length); _logger.LogDebug("Latest videos response length: {Length}", content.Length);
var result = JsonSerializer.Deserialize<MediaComposition>(content, _jsonOptions); var result = JsonSerializer.Deserialize<MediaComposition>(content, _jsonOptions);
@ -326,12 +300,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);
_logger.LogDebug("Trending videos response length: {Length}", content.Length); _logger.LogDebug("Trending videos response length: {Length}", content.Length);
var result = JsonSerializer.Deserialize<MediaComposition>(content, _jsonOptions); var result = JsonSerializer.Deserialize<MediaComposition>(content, _jsonOptions);
@ -361,7 +335,7 @@ public class SRFApiClient : IDisposable
var response = await _httpClient.GetAsync(url, cancellationToken).ConfigureAwait(false); var response = await _httpClient.GetAsync(url, cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode(); response.EnsureSuccessStatusCode();
var content = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); var content = await ReadAsUtf8StringAsync(response.Content, cancellationToken).ConfigureAwait(false);
return content; return content;
} }
catch (Exception ex) catch (Exception ex)
@ -389,12 +363,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<PlayV3DirectResponse<PlayV3Show>>(content, _jsonOptions); var result = JsonSerializer.Deserialize<PlayV3DirectResponse<PlayV3Show>>(content, _jsonOptions);
_logger.LogInformation("Successfully fetched {Count} shows for business unit: {BusinessUnit}", result?.Data?.Count ?? 0, businessUnit); _logger.LogInformation("Successfully fetched {Count} shows for business unit: {BusinessUnit}", result?.Data?.Count ?? 0, businessUnit);
@ -425,12 +399,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<PlayV3DirectResponse<PlayV3Topic>>(content, _jsonOptions); var result = JsonSerializer.Deserialize<PlayV3DirectResponse<PlayV3Topic>>(content, _jsonOptions);
_logger.LogInformation("Successfully fetched {Count} topics for business unit: {BusinessUnit}", result?.Data?.Count ?? 0, businessUnit); _logger.LogInformation("Successfully fetched {Count} topics for business unit: {BusinessUnit}", result?.Data?.Count ?? 0, businessUnit);
@ -462,12 +436,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 +476,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);

View File

@ -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
{ {
@ -236,7 +248,7 @@ public class SRFPlayChannel : IChannel, IHasCacheKey
{ {
var businessUnit = config?.BusinessUnit.ToString().ToLowerInvariant() ?? "srf"; var businessUnit = config?.BusinessUnit.ToString().ToLowerInvariant() ?? "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)
@ -301,7 +313,7 @@ public class SRFPlayChannel : IChannel, IHasCacheKey
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 +402,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 +425,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;
@ -446,7 +464,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 +500,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 +587,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 +605,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)
{ {

View File

@ -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; }
} }

View File

@ -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;

View File

@ -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)
{ {
@ -170,8 +197,11 @@ public class StreamProxyController : ControllerBase
var baseProxyUrl = $"{scheme}://{Request.Host}/Plugins/SRFPlay/Proxy/{itemId}"; var baseProxyUrl = $"{scheme}://{Request.Host}/Plugins/SRFPlay/Proxy/{itemId}";
var rewrittenContent = RewriteSegmentUrls(manifestContent, baseProxyUrl); var rewrittenContent = RewriteSegmentUrls(manifestContent, baseProxyUrl);
// 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)
{ {
@ -213,7 +243,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);
@ -353,32 +383,6 @@ public class StreamProxyController : ControllerBase
return result.ToString(); 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 +446,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 +463,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";
} }
} }

View File

@ -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>

View File

@ -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;
} }

View File

@ -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;
@ -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";
}
} }

View File

@ -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;
} }

View File

@ -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 />
@ -77,6 +81,10 @@ public class ExpirationCheckTask : IScheduledTask
progress?.Report(50); 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);
} }

View File

@ -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();
}
} }

View File

@ -37,111 +37,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.ToString().ToLowerInvariant(),
minEpisodeCount: 0,
maxShows: 20,
videosPerShow: 1,
contentType: "latest",
cancellationToken).ConfigureAwait(false);
} }
/// <summary> /// <summary>
@ -151,43 +60,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.ToString().ToLowerInvariant(),
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,64 +123,46 @@ 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;

View File

@ -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>

View File

@ -38,8 +38,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.

View File

@ -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,
}; };

View File

@ -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,18 +32,17 @@ 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>();
} }
@ -70,33 +69,11 @@ public class StreamProxyService : IStreamProxyService, IDisposable
LastLivestreamFetchAt = isLiveStream ? DateTime.UtcNow : null LastLivestreamFetchAt = isLiveStream ? DateTime.UtcNow : null
}; };
// Register with the provided item ID RegisterWithGuidFormats(itemId, streamInfo);
_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) if (tokenExpiry.HasValue)
{ {
_logger.LogInformation( _logger.LogDebug(
"Registered stream for item {ItemId} (token expires at {ExpiresAt} UTC): {Url}", "Registered stream for item {ItemId} (token expires at {ExpiresAt} UTC): {Url}",
itemId, itemId,
tokenExpiry.Value, tokenExpiry.Value,
@ -104,7 +81,7 @@ public class StreamProxyService : IStreamProxyService, IDisposable
} }
else else
{ {
_logger.LogInformation("Registered stream for item {ItemId}: {Url}", itemId, authenticatedUrl); _logger.LogDebug("Registered stream for item {ItemId}: {Url}", itemId, authenticatedUrl);
} }
} }
@ -129,27 +106,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 +148,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 +162,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 +183,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 +206,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 +219,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 +284,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 +307,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 +322,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 +331,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 +371,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 +405,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 +430,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,8 +440,7 @@ 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?.ChapterList == null || mediaComposition.ChapterList.Count == 0)
{ {
@ -458,8 +459,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 +486,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 +496,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 +528,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 +539,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 +669,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 +678,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)
@ -707,7 +708,7 @@ public class StreamProxyService : IStreamProxyService, IDisposable
string segmentPath, string segmentPath,
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;
@ -725,13 +726,14 @@ public class StreamProxyService : IStreamProxyService, IDisposable
// 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}, FullUrl: {FullUrl}",
authenticatedUrl, authenticatedUrl,
baseUrl, baseUrl,
segmentPath, segmentPath,
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;
@ -892,31 +894,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>

View File

@ -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>
@ -141,8 +142,8 @@ public class StreamUrlResolver : IStreamUrlResolver, IDisposable
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,
@ -247,7 +248,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 +280,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;
}
}
} }

View File

@ -0,0 +1,41 @@
using Jellyfin.Plugin.SRFPlay.Configuration;
using MediaBrowser.Controller.Entities;
namespace Jellyfin.Plugin.SRFPlay.Utilities;
/// <summary>
/// Extension methods for common plugin operations.
/// </summary>
public static class Extensions
{
/// <summary>
/// Gets the SRF URN from the item's provider IDs.
/// </summary>
/// <param name="item">The base item.</param>
/// <returns>The SRF URN, or null if not found or empty.</returns>
public static string? GetSrfUrn(this BaseItem item)
{
return item.ProviderIds.TryGetValue("SRF", out var urn) && !string.IsNullOrEmpty(urn)
? urn
: null;
}
/// <summary>
/// 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();
}
/// <summary>
/// Gets the plugin configuration safely, returning null if not available.
/// </summary>
/// <returns>The plugin configuration, or null if not available.</returns>
public static PluginConfiguration? GetPluginConfig()
{
return Plugin.Instance?.Configuration;
}
}

View File

@ -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";
}
}

View File

@ -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;
}
}

View File

@ -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
}

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)
@ -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

BIN
assests/main logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

37
manifest.json Normal file
View File

@ -0,0 +1,37 @@
[
{
"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.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"
}
]
}
]