Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
92dc6d8203 | ||
|
|
d313b68975 | ||
|
|
60434abd01 | ||
|
|
5ace3f4296 |
@@ -2,6 +2,7 @@ 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.Encodings.Web;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using System.Threading;
|
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>
|
/// <summary>
|
||||||
/// Creates an HttpClient with optional proxy configuration.
|
/// Creates an HttpClient with optional proxy configuration.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -187,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 };
|
||||||
@@ -247,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);
|
||||||
@@ -286,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);
|
||||||
@@ -321,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)
|
||||||
@@ -349,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);
|
||||||
@@ -385,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);
|
||||||
@@ -422,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);
|
||||||
@@ -462,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);
|
||||||
|
|||||||
@@ -183,7 +183,8 @@ public class StreamProxyController : ControllerBase
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
// Fetch the variant manifest as a segment
|
// Fetch the variant manifest as a segment
|
||||||
var manifestData = await _proxyService.GetSegmentAsync(actualItemId, fullPath, cancellationToken).ConfigureAwait(false);
|
var queryString = Request.QueryString.HasValue ? Request.QueryString.Value : null;
|
||||||
|
var manifestData = await _proxyService.GetSegmentAsync(actualItemId, fullPath, queryString, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
if (manifestData == null)
|
if (manifestData == null)
|
||||||
{
|
{
|
||||||
@@ -234,7 +235,9 @@ public class StreamProxyController : ControllerBase
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var segmentData = await _proxyService.GetSegmentAsync(actualItemId, segmentPath, cancellationToken).ConfigureAwait(false);
|
// Pass the original query string to preserve segment-specific parameters (e.g., ?m=timestamp)
|
||||||
|
var queryString = Request.QueryString.HasValue ? Request.QueryString.Value : null;
|
||||||
|
var segmentData = await _proxyService.GetSegmentAsync(actualItemId, segmentPath, queryString, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
if (segmentData == null)
|
if (segmentData == null)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -56,9 +56,10 @@ public interface IStreamProxyService
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="itemId">The item ID.</param>
|
/// <param name="itemId">The item ID.</param>
|
||||||
/// <param name="segmentPath">The segment path.</param>
|
/// <param name="segmentPath">The segment path.</param>
|
||||||
|
/// <param name="queryString">The original query string from the request (preserves segment-specific parameters like timestamps).</param>
|
||||||
/// <param name="cancellationToken">Cancellation token.</param>
|
/// <param name="cancellationToken">Cancellation token.</param>
|
||||||
/// <returns>The segment content as bytes.</returns>
|
/// <returns>The segment content as bytes.</returns>
|
||||||
Task<byte[]?> GetSegmentAsync(string itemId, string segmentPath, CancellationToken cancellationToken = default);
|
Task<byte[]?> GetSegmentAsync(string itemId, string segmentPath, string? queryString = null, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Cleans up old and expired stream mappings.
|
/// Cleans up old and expired stream mappings.
|
||||||
|
|||||||
@@ -701,11 +701,13 @@ public class StreamProxyService : IStreamProxyService
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="itemId">The item ID.</param>
|
/// <param name="itemId">The item ID.</param>
|
||||||
/// <param name="segmentPath">The segment path.</param>
|
/// <param name="segmentPath">The segment path.</param>
|
||||||
|
/// <param name="queryString">The original query string from the request (preserves segment-specific parameters like timestamps).</param>
|
||||||
/// <param name="cancellationToken">Cancellation token.</param>
|
/// <param name="cancellationToken">Cancellation token.</param>
|
||||||
/// <returns>The segment content as bytes.</returns>
|
/// <returns>The segment content as bytes.</returns>
|
||||||
public async Task<byte[]?> GetSegmentAsync(
|
public async Task<byte[]?> GetSegmentAsync(
|
||||||
string itemId,
|
string itemId,
|
||||||
string segmentPath,
|
string segmentPath,
|
||||||
|
string? queryString = null,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var authenticatedUrl = await GetAuthenticatedUrlAsync(itemId, cancellationToken).ConfigureAwait(false);
|
var authenticatedUrl = await GetAuthenticatedUrlAsync(itemId, cancellationToken).ConfigureAwait(false);
|
||||||
@@ -720,17 +722,29 @@ public class StreamProxyService : IStreamProxyService
|
|||||||
var baseUri = new Uri(authenticatedUrl);
|
var baseUri = new Uri(authenticatedUrl);
|
||||||
var baseUrl = $"{baseUri.Scheme}://{baseUri.Host}{string.Join('/', baseUri.AbsolutePath.Split('/')[..^1])}";
|
var baseUrl = $"{baseUri.Scheme}://{baseUri.Host}{string.Join('/', baseUri.AbsolutePath.Split('/')[..^1])}";
|
||||||
|
|
||||||
// Extract query parameters (auth tokens) from authenticated URL
|
// Use the original query string from the request (preserves segment-specific params like ?m=timestamp)
|
||||||
var queryParams = baseUri.Query;
|
// If no query string is provided, check if we need to add auth params from the master manifest
|
||||||
|
var queryParams = string.Empty;
|
||||||
|
if (!string.IsNullOrEmpty(queryString))
|
||||||
|
{
|
||||||
|
// Use the original query string from the segment request
|
||||||
|
queryParams = queryString.StartsWith('?') ? queryString : $"?{queryString}";
|
||||||
|
}
|
||||||
|
else if (!segmentPath.Contains("hdntl=", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
// Only append master manifest query params if segment doesn't have path-based auth
|
||||||
|
queryParams = baseUri.Query;
|
||||||
|
}
|
||||||
|
|
||||||
// Build full segment URL
|
// Build full segment URL
|
||||||
var segmentUrl = $"{baseUrl}/{segmentPath}{queryParams}";
|
var segmentUrl = $"{baseUrl}/{segmentPath}{queryParams}";
|
||||||
|
|
||||||
_logger.LogDebug(
|
_logger.LogDebug(
|
||||||
"Fetching segment - BaseUri: {BaseUri}, BaseUrl: {BaseUrl}, SegmentPath: {SegmentPath}, FullUrl: {FullUrl}",
|
"Fetching segment - BaseUri: {BaseUri}, BaseUrl: {BaseUrl}, SegmentPath: {SegmentPath}, QueryString: {QueryString}, FullUrl: {FullUrl}",
|
||||||
authenticatedUrl,
|
authenticatedUrl,
|
||||||
baseUrl,
|
baseUrl,
|
||||||
segmentPath,
|
segmentPath,
|
||||||
|
queryString ?? "(none)",
|
||||||
segmentUrl);
|
segmentUrl);
|
||||||
using var httpClient = _httpClientFactory.CreateClient(NamedClient.Default);
|
using var httpClient = _httpClientFactory.CreateClient(NamedClient.Default);
|
||||||
var segmentData = await httpClient.GetByteArrayAsync(segmentUrl, cancellationToken).ConfigureAwait(false);
|
var segmentData = await httpClient.GetByteArrayAsync(segmentUrl, cancellationToken).ConfigureAwait(false);
|
||||||
|
|||||||
@@ -8,6 +8,22 @@
|
|||||||
"category": "Live TV",
|
"category": "Live TV",
|
||||||
"imageUrl": "https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/raw/branch/master/assests/main%20logo.png",
|
"imageUrl": "https://gitea.tourolle.paris/dtourolle/jellyfin-srfPlay/raw/branch/master/assests/main%20logo.png",
|
||||||
"versions": [
|
"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",
|
"version": "1.0.12",
|
||||||
"changelog": "Release 1.0.12",
|
"changelog": "Release 1.0.12",
|
||||||
|
|||||||
Reference in New Issue
Block a user