Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5c898a98f5 | ||
|
|
c282a98377 | ||
|
|
92dc6d8203 | ||
|
|
d313b68975 | ||
|
|
60434abd01 | ||
|
|
5ace3f4296 | ||
|
|
757aab1943 | ||
|
|
23d8da9ae7 | ||
|
|
2631c93444 |
@@ -2,6 +2,7 @@ using System;
|
||||
using System.Globalization;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Text;
|
||||
using System.Text.Encodings.Web;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
@@ -57,6 +58,18 @@ public class SRFApiClient : IDisposable
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads HTTP response content as UTF-8 string.
|
||||
/// </summary>
|
||||
/// <param name="content">The HTTP content to read.</param>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <returns>The content as a UTF-8 decoded string.</returns>
|
||||
private async Task<string> ReadAsUtf8StringAsync(HttpContent content, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var bytes = await content.ReadAsByteArrayAsync(cancellationToken).ConfigureAwait(false);
|
||||
return Encoding.UTF8.GetString(bytes);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates an HttpClient with optional proxy configuration.
|
||||
/// </summary>
|
||||
@@ -187,7 +200,8 @@ public class SRFApiClient : IDisposable
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true
|
||||
CreateNoWindow = true,
|
||||
StandardOutputEncoding = Encoding.UTF8
|
||||
};
|
||||
|
||||
using var process = new System.Diagnostics.Process { StartInfo = processStartInfo };
|
||||
@@ -247,12 +261,12 @@ public class SRFApiClient : IDisposable
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var errorContent = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
var errorContent = await ReadAsUtf8StringAsync(response.Content, cancellationToken).ConfigureAwait(false);
|
||||
_logger.LogError("API returned error {StatusCode}: {Error}", response.StatusCode, errorContent);
|
||||
return null;
|
||||
}
|
||||
|
||||
var content = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
var content = await ReadAsUtf8StringAsync(response.Content, cancellationToken).ConfigureAwait(false);
|
||||
_logger.LogDebug("Latest videos response length: {Length}", content.Length);
|
||||
|
||||
var result = JsonSerializer.Deserialize<MediaComposition>(content, _jsonOptions);
|
||||
@@ -286,12 +300,12 @@ public class SRFApiClient : IDisposable
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var errorContent = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
var errorContent = await ReadAsUtf8StringAsync(response.Content, cancellationToken).ConfigureAwait(false);
|
||||
_logger.LogError("API returned error {StatusCode}: {Error}", response.StatusCode, errorContent);
|
||||
return null;
|
||||
}
|
||||
|
||||
var content = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
var content = await ReadAsUtf8StringAsync(response.Content, cancellationToken).ConfigureAwait(false);
|
||||
_logger.LogDebug("Trending videos response length: {Length}", content.Length);
|
||||
|
||||
var result = JsonSerializer.Deserialize<MediaComposition>(content, _jsonOptions);
|
||||
@@ -321,7 +335,7 @@ public class SRFApiClient : IDisposable
|
||||
var response = await _httpClient.GetAsync(url, cancellationToken).ConfigureAwait(false);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var content = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
var content = await ReadAsUtf8StringAsync(response.Content, cancellationToken).ConfigureAwait(false);
|
||||
return content;
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -349,12 +363,12 @@ public class SRFApiClient : IDisposable
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var errorContent = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
var errorContent = await ReadAsUtf8StringAsync(response.Content, cancellationToken).ConfigureAwait(false);
|
||||
_logger.LogError("API returned error {StatusCode}: {Error}", response.StatusCode, errorContent);
|
||||
return null;
|
||||
}
|
||||
|
||||
var content = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
var content = await ReadAsUtf8StringAsync(response.Content, cancellationToken).ConfigureAwait(false);
|
||||
var result = JsonSerializer.Deserialize<PlayV3DirectResponse<PlayV3Show>>(content, _jsonOptions);
|
||||
|
||||
_logger.LogInformation("Successfully fetched {Count} shows for business unit: {BusinessUnit}", result?.Data?.Count ?? 0, businessUnit);
|
||||
@@ -385,12 +399,12 @@ public class SRFApiClient : IDisposable
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var errorContent = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
var errorContent = await ReadAsUtf8StringAsync(response.Content, cancellationToken).ConfigureAwait(false);
|
||||
_logger.LogError("API returned error {StatusCode}: {Error}", response.StatusCode, errorContent);
|
||||
return null;
|
||||
}
|
||||
|
||||
var content = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
var content = await ReadAsUtf8StringAsync(response.Content, cancellationToken).ConfigureAwait(false);
|
||||
var result = JsonSerializer.Deserialize<PlayV3DirectResponse<PlayV3Topic>>(content, _jsonOptions);
|
||||
|
||||
_logger.LogInformation("Successfully fetched {Count} topics for business unit: {BusinessUnit}", result?.Data?.Count ?? 0, businessUnit);
|
||||
@@ -422,12 +436,12 @@ public class SRFApiClient : IDisposable
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var errorContent = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
var errorContent = await ReadAsUtf8StringAsync(response.Content, cancellationToken).ConfigureAwait(false);
|
||||
_logger.LogError("API returned error {StatusCode}: {Error}", response.StatusCode, errorContent);
|
||||
return null;
|
||||
}
|
||||
|
||||
var content = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
var content = await ReadAsUtf8StringAsync(response.Content, cancellationToken).ConfigureAwait(false);
|
||||
var result = JsonSerializer.Deserialize<PlayV3Response<PlayV3Video>>(content, _jsonOptions);
|
||||
|
||||
_logger.LogDebug("Successfully fetched {Count} videos for show {ShowId}", result?.Data?.Data?.Count ?? 0, showId);
|
||||
@@ -462,12 +476,12 @@ public class SRFApiClient : IDisposable
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var errorContent = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
var errorContent = await ReadAsUtf8StringAsync(response.Content, cancellationToken).ConfigureAwait(false);
|
||||
_logger.LogError("API returned error {StatusCode}: {Error}", response.StatusCode, errorContent);
|
||||
return null;
|
||||
}
|
||||
|
||||
var content = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
var content = await ReadAsUtf8StringAsync(response.Content, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// The response structure is: { "data": { "scheduledLivestreams": [...] } }
|
||||
var jsonDoc = JsonSerializer.Deserialize<JsonElement>(content, _jsonOptions);
|
||||
|
||||
@@ -200,7 +200,9 @@ public class SRFPlayChannel : IChannel, IHasCacheKey
|
||||
continue;
|
||||
}
|
||||
|
||||
items.Add(CreateFolder($"category_{topic.Id}", topic.Title ?? topic.Id!, topic.Lead));
|
||||
// Generate placeholder image for topic
|
||||
var placeholderUrl = CreatePlaceholderImageUrl(topic.Title ?? topic.Id!, _mediaSourceFactory.GetServerBaseUrl());
|
||||
items.Add(CreateFolder($"category_{topic.Id}", topic.Title ?? topic.Id!, topic.Lead, placeholderUrl));
|
||||
}
|
||||
|
||||
_logger.LogInformation("Added {Count} category folders", topics.Count);
|
||||
@@ -214,7 +216,7 @@ public class SRFPlayChannel : IChannel, IHasCacheKey
|
||||
return items;
|
||||
}
|
||||
|
||||
private static ChannelItemInfo CreateFolder(string id, string name, string? overview = null)
|
||||
private static ChannelItemInfo CreateFolder(string id, string name, string? overview = null, string? imageUrl = null)
|
||||
{
|
||||
return new ChannelItemInfo
|
||||
{
|
||||
@@ -222,7 +224,7 @@ public class SRFPlayChannel : IChannel, IHasCacheKey
|
||||
Name = name,
|
||||
Type = ChannelItemType.Folder,
|
||||
FolderType = ChannelFolderType.Container,
|
||||
ImageUrl = null,
|
||||
ImageUrl = imageUrl,
|
||||
Overview = overview
|
||||
};
|
||||
}
|
||||
|
||||
@@ -183,7 +183,8 @@ public class StreamProxyController : ControllerBase
|
||||
try
|
||||
{
|
||||
// Fetch the variant manifest as a segment
|
||||
var manifestData = await _proxyService.GetSegmentAsync(actualItemId, fullPath, cancellationToken).ConfigureAwait(false);
|
||||
var queryString = Request.QueryString.HasValue ? Request.QueryString.Value : null;
|
||||
var manifestData = await _proxyService.GetSegmentAsync(actualItemId, fullPath, queryString, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (manifestData == null)
|
||||
{
|
||||
@@ -234,7 +235,9 @@ public class StreamProxyController : ControllerBase
|
||||
|
||||
try
|
||||
{
|
||||
var segmentData = await _proxyService.GetSegmentAsync(actualItemId, segmentPath, cancellationToken).ConfigureAwait(false);
|
||||
// Pass the original query string to preserve segment-specific parameters (e.g., ?m=timestamp)
|
||||
var queryString = Request.QueryString.HasValue ? Request.QueryString.Value : null;
|
||||
var segmentData = await _proxyService.GetSegmentAsync(actualItemId, segmentPath, queryString, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (segmentData == null)
|
||||
{
|
||||
|
||||
@@ -56,9 +56,10 @@ public interface IStreamProxyService
|
||||
/// </summary>
|
||||
/// <param name="itemId">The item ID.</param>
|
||||
/// <param name="segmentPath">The segment path.</param>
|
||||
/// <param name="queryString">The original query string from the request (preserves segment-specific parameters like timestamps).</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The segment content as bytes.</returns>
|
||||
Task<byte[]?> GetSegmentAsync(string itemId, string segmentPath, CancellationToken cancellationToken = default);
|
||||
Task<byte[]?> GetSegmentAsync(string itemId, string segmentPath, string? queryString = null, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Cleans up old and expired stream mappings.
|
||||
|
||||
@@ -701,11 +701,13 @@ public class StreamProxyService : IStreamProxyService
|
||||
/// </summary>
|
||||
/// <param name="itemId">The item ID.</param>
|
||||
/// <param name="segmentPath">The segment path.</param>
|
||||
/// <param name="queryString">The original query string from the request (preserves segment-specific parameters like timestamps).</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The segment content as bytes.</returns>
|
||||
public async Task<byte[]?> GetSegmentAsync(
|
||||
string itemId,
|
||||
string segmentPath,
|
||||
string? queryString = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var authenticatedUrl = await GetAuthenticatedUrlAsync(itemId, cancellationToken).ConfigureAwait(false);
|
||||
@@ -720,17 +722,29 @@ public class StreamProxyService : IStreamProxyService
|
||||
var baseUri = new Uri(authenticatedUrl);
|
||||
var baseUrl = $"{baseUri.Scheme}://{baseUri.Host}{string.Join('/', baseUri.AbsolutePath.Split('/')[..^1])}";
|
||||
|
||||
// Extract query parameters (auth tokens) from authenticated URL
|
||||
var queryParams = baseUri.Query;
|
||||
// Use the original query string from the request (preserves segment-specific params like ?m=timestamp)
|
||||
// If no query string is provided, check if we need to add auth params from the master manifest
|
||||
var queryParams = string.Empty;
|
||||
if (!string.IsNullOrEmpty(queryString))
|
||||
{
|
||||
// Use the original query string from the segment request
|
||||
queryParams = queryString.StartsWith('?') ? queryString : $"?{queryString}";
|
||||
}
|
||||
else if (!segmentPath.Contains("hdntl=", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
// Only append master manifest query params if segment doesn't have path-based auth
|
||||
queryParams = baseUri.Query;
|
||||
}
|
||||
|
||||
// Build full segment URL
|
||||
var segmentUrl = $"{baseUrl}/{segmentPath}{queryParams}";
|
||||
|
||||
_logger.LogDebug(
|
||||
"Fetching segment - BaseUri: {BaseUri}, BaseUrl: {BaseUrl}, SegmentPath: {SegmentPath}, FullUrl: {FullUrl}",
|
||||
"Fetching segment - BaseUri: {BaseUri}, BaseUrl: {BaseUrl}, SegmentPath: {SegmentPath}, QueryString: {QueryString}, FullUrl: {FullUrl}",
|
||||
authenticatedUrl,
|
||||
baseUrl,
|
||||
segmentPath,
|
||||
queryString ?? "(none)",
|
||||
segmentUrl);
|
||||
using var httpClient = _httpClientFactory.CreateClient(NamedClient.Default);
|
||||
var segmentData = await httpClient.GetByteArrayAsync(segmentUrl, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
@@ -41,26 +41,25 @@ public static class PlaceholderImageGenerator
|
||||
Typeface = SKTypeface.FromFamilyName("Arial", SKFontStyle.Bold)
|
||||
};
|
||||
|
||||
// Calculate font size to fit text (max 48, min 24)
|
||||
// Calculate font size and wrap text if needed
|
||||
float fontSize = 48;
|
||||
textPaint.TextSize = fontSize;
|
||||
var textWidth = textPaint.MeasureText(text);
|
||||
var maxWidth = Width * 0.85f;
|
||||
var maxHeight = Height * 0.8f;
|
||||
|
||||
if (textWidth > maxWidth)
|
||||
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)
|
||||
{
|
||||
fontSize = fontSize * maxWidth / textWidth;
|
||||
fontSize = fontSize < 24 ? 24 : fontSize;
|
||||
textPaint.TextSize = fontSize;
|
||||
canvas.DrawText(line, Width / 2, startY, textPaint);
|
||||
startY += lineHeight;
|
||||
}
|
||||
|
||||
// Center text vertically
|
||||
SKRect textBounds = default;
|
||||
textPaint.MeasureText(text, ref textBounds);
|
||||
var yPos = (Height / 2) - textBounds.MidY;
|
||||
|
||||
canvas.DrawText(text, Width / 2, yPos, textPaint);
|
||||
|
||||
// Encode to PNG
|
||||
using var image = surface.Snapshot();
|
||||
using var data = image.Encode(SKEncodedImageFormat.Png, 100);
|
||||
@@ -70,4 +69,73 @@ public static class PlaceholderImageGenerator
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,16 @@ A Jellyfin plugin for accessing SRF Play (Swiss Radio and Television) video-on-d
|
||||
|
||||
**Beta/Alpha** - This plugin has been tested on two Jellyfin instances and is working. Some clients may experience issues with hardware decoding, which appears to be client-specific behavior.
|
||||
|
||||
## Quick Install
|
||||
|
||||
Add this repository URL in Jellyfin (Dashboard → Plugins → Repositories):
|
||||
|
||||
```
|
||||
https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/raw/branch/master/manifest.json
|
||||
```
|
||||
|
||||
Then install "SRF Play" from the plugin catalog.
|
||||
|
||||
## Features
|
||||
|
||||
- Access to SRF Play VOD content (video-on-demand, no DRM-protected content)
|
||||
@@ -102,16 +112,6 @@ dotnet build
|
||||
|
||||
The compiled plugin will be in `bin/Debug/net8.0/`
|
||||
|
||||
## 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.
|
||||
|
||||
## Manual Installation
|
||||
|
||||
1. Build the plugin (see above)
|
||||
|
||||
+34
-1
@@ -7,6 +7,39 @@
|
||||
"owner": "dtourolle",
|
||||
"category": "Live TV",
|
||||
"imageUrl": "https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/raw/branch/master/assests/main%20logo.png",
|
||||
"versions": []
|
||||
"versions": [
|
||||
{
|
||||
"version": "1.0.15",
|
||||
"changelog": "Release 1.0.15",
|
||||
"targetAbi": "10.9.0.0",
|
||||
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/releases/download/v1.0.15/srfplay_1.0.15.0.zip",
|
||||
"checksum": "3140ef53a7460d7ce52daf48bbb37e4d",
|
||||
"timestamp": "2026-01-17T09:54:39Z"
|
||||
},
|
||||
{
|
||||
"version": "1.0.14",
|
||||
"changelog": "Release 1.0.14",
|
||||
"targetAbi": "10.9.0.0",
|
||||
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/releases/download/v1.0.14/srfplay_1.0.14.0.zip",
|
||||
"checksum": "49e6afe16f7abf95c099ecc1d016e725",
|
||||
"timestamp": "2025-12-30T12:38:41Z"
|
||||
},
|
||||
{
|
||||
"version": "1.0.13",
|
||||
"changelog": "Release 1.0.13",
|
||||
"targetAbi": "10.9.0.0",
|
||||
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/releases/download/v1.0.13/srfplay_1.0.13.0.zip",
|
||||
"checksum": "ac7c1e33c926c21f8e4319da91ef079c",
|
||||
"timestamp": "2025-12-21T13:02:37Z"
|
||||
},
|
||||
{
|
||||
"version": "1.0.12",
|
||||
"changelog": "Release 1.0.12",
|
||||
"targetAbi": "10.9.0.0",
|
||||
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/releases/download/v1.0.12/srfplay_1.0.12.0.zip",
|
||||
"checksum": "47baa02ade413253db94fe3ba0763f69",
|
||||
"timestamp": "2025-12-20T13:35:38Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user