Compare commits

...

10 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
25 changed files with 614 additions and 318 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

@ -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}",
@ -184,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 };
@ -244,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);
@ -283,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);
@ -318,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)
@ -346,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);
@ -382,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);
@ -419,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);
@ -459,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,13 +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.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;
@ -81,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
{ {
@ -107,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
}); });
} }
@ -127,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
{ {
@ -393,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)
@ -449,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(
@ -485,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();
@ -557,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>
@ -594,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;
@ -147,7 +148,7 @@ public class StreamProxyController : ControllerBase
AddManifestCacheHeaders(actualItemId); 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)
{ {
@ -200,7 +201,7 @@ public class StreamProxyController : ControllerBase
AddManifestCacheHeaders(actualItemId); 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)
{ {
@ -242,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);
@ -382,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>
@ -471,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})",
@ -488,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

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

@ -99,6 +99,7 @@ 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 // Disable probing - we provide stream info directly

View File

@ -73,7 +73,7 @@ public class StreamProxyService : IStreamProxyService
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,
@ -81,7 +81,7 @@ public class StreamProxyService : IStreamProxyService
} }
else else
{ {
_logger.LogInformation("Registered stream for item {ItemId}: {Url}", itemId, authenticatedUrl); _logger.LogDebug("Registered stream for item {ItemId}: {Url}", itemId, authenticatedUrl);
} }
} }
@ -219,6 +219,50 @@ public class StreamProxyService : IStreamProxyService
} }
} }
// Fallback: Live TV channelId_guid format lookup
// For Live TV streams, itemId format is "channelId_urnGuid"
// Try matching by suffix (urnGuid part) or prefix (channelId part)
if (itemId.Contains('_', StringComparison.Ordinal))
{
var parts = itemId.Split('_', 2);
var prefix = parts[0]; // channelId part
var suffix = parts.Length > 1 ? parts[1] : null; // urnGuid part
foreach (var kvp in _streamMappings)
{
// Check if registered key contains the same suffix (urnGuid)
if (suffix != null && kvp.Key.Contains(suffix, StringComparison.OrdinalIgnoreCase))
{
_logger.LogInformation(
"Found stream by Live TV suffix match - Requested: {RequestedId}, Registered: {RegisteredId}",
itemId,
kvp.Key);
var url = await ValidateAndReturnStreamAsync(kvp.Key, kvp.Value, cancellationToken).ConfigureAwait(false);
if (url != null)
{
// Also register with the requested itemId for future lookups
_streamMappings.AddOrUpdate(itemId, kvp.Value, (key, old) => kvp.Value);
return url;
}
}
// Check if registered key starts with the same prefix (channelId)
if (kvp.Key.StartsWith(prefix + "_", StringComparison.OrdinalIgnoreCase))
{
_logger.LogInformation(
"Found stream by Live TV prefix match - Requested: {RequestedId}, Registered: {RegisteredId}",
itemId,
kvp.Key);
var url = await ValidateAndReturnStreamAsync(kvp.Key, kvp.Value, cancellationToken).ConfigureAwait(false);
if (url != null)
{
_streamMappings.AddOrUpdate(itemId, kvp.Value, (key, old) => kvp.Value);
return url;
}
}
}
}
// Last resort: Use active stream fallbacks (helps when Jellyfin creates random transcoding session IDs) // Last resort: Use active stream fallbacks (helps when Jellyfin creates random transcoding session IDs)
var activeStreams = _streamMappings.Where(kvp => var activeStreams = _streamMappings.Where(kvp =>
{ {

View File

@ -142,8 +142,8 @@ public class StreamUrlResolver : IStreamUrlResolver
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,

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"
}
]
}
]