Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d313b68975 | ||
| 60434abd01 | |||
|
|
5ace3f4296 | ||
| 757aab1943 | |||
|
|
23d8da9ae7 | ||
| 2631c93444 | |||
| 7f71d3419c |
@ -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
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -491,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();
|
||||||
@ -581,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)
|
||||||
{
|
{
|
||||||
|
|||||||
@ -74,6 +74,7 @@ public class PluginConfiguration : BasePluginConfiguration
|
|||||||
EnableTrendingContent = true;
|
EnableTrendingContent = true;
|
||||||
EnableCategoryFolders = true;
|
EnableCategoryFolders = true;
|
||||||
EnabledTopics = new System.Collections.Generic.List<string>();
|
EnabledTopics = new System.Collections.Generic.List<string>();
|
||||||
|
GenerateTitleCards = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@ -149,4 +150,10 @@ public class PluginConfiguration : BasePluginConfiguration
|
|||||||
/// This is important for Android and other remote clients to access streams.
|
/// This is important for Android and other remote clients to access streams.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string PublicServerUrl { get; set; } = string.Empty;
|
public string PublicServerUrl { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets a value indicating whether to generate title card images with the content title.
|
||||||
|
/// When enabled, generates custom thumbnails instead of using SRF-provided images.
|
||||||
|
/// </summary>
|
||||||
|
public bool GenerateTitleCards { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@ -58,6 +58,13 @@
|
|||||||
</label>
|
</label>
|
||||||
<div class="fieldDescription">Automatically discover and add trending videos</div>
|
<div class="fieldDescription">Automatically discover and add trending videos</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="checkboxContainer checkboxContainer-withDescription">
|
||||||
|
<label class="emby-checkbox-label">
|
||||||
|
<input id="GenerateTitleCards" name="GenerateTitleCards" type="checkbox" is="emby-checkbox" />
|
||||||
|
<span>Generate Title Cards</span>
|
||||||
|
</label>
|
||||||
|
<div class="fieldDescription">Generate custom thumbnails with the content title instead of using SRF-provided images</div>
|
||||||
|
</div>
|
||||||
<br />
|
<br />
|
||||||
<h2>Proxy Settings</h2>
|
<h2>Proxy Settings</h2>
|
||||||
<div class="checkboxContainer checkboxContainer-withDescription">
|
<div class="checkboxContainer checkboxContainer-withDescription">
|
||||||
@ -117,6 +124,7 @@
|
|||||||
document.querySelector('#CacheDurationMinutes').value = config.CacheDurationMinutes;
|
document.querySelector('#CacheDurationMinutes').value = config.CacheDurationMinutes;
|
||||||
document.querySelector('#EnableLatestContent').checked = config.EnableLatestContent;
|
document.querySelector('#EnableLatestContent').checked = config.EnableLatestContent;
|
||||||
document.querySelector('#EnableTrendingContent').checked = config.EnableTrendingContent;
|
document.querySelector('#EnableTrendingContent').checked = config.EnableTrendingContent;
|
||||||
|
document.querySelector('#GenerateTitleCards').checked = config.GenerateTitleCards !== false;
|
||||||
document.querySelector('#UseProxy').checked = config.UseProxy || false;
|
document.querySelector('#UseProxy').checked = config.UseProxy || false;
|
||||||
document.querySelector('#ProxyAddress').value = config.ProxyAddress || '';
|
document.querySelector('#ProxyAddress').value = config.ProxyAddress || '';
|
||||||
document.querySelector('#ProxyUsername').value = config.ProxyUsername || '';
|
document.querySelector('#ProxyUsername').value = config.ProxyUsername || '';
|
||||||
@ -137,6 +145,7 @@
|
|||||||
config.CacheDurationMinutes = parseInt(document.querySelector('#CacheDurationMinutes').value);
|
config.CacheDurationMinutes = parseInt(document.querySelector('#CacheDurationMinutes').value);
|
||||||
config.EnableLatestContent = document.querySelector('#EnableLatestContent').checked;
|
config.EnableLatestContent = document.querySelector('#EnableLatestContent').checked;
|
||||||
config.EnableTrendingContent = document.querySelector('#EnableTrendingContent').checked;
|
config.EnableTrendingContent = document.querySelector('#EnableTrendingContent').checked;
|
||||||
|
config.GenerateTitleCards = document.querySelector('#GenerateTitleCards').checked;
|
||||||
config.UseProxy = document.querySelector('#UseProxy').checked;
|
config.UseProxy = document.querySelector('#UseProxy').checked;
|
||||||
config.ProxyAddress = document.querySelector('#ProxyAddress').value;
|
config.ProxyAddress = document.querySelector('#ProxyAddress').value;
|
||||||
config.ProxyUsername = document.querySelector('#ProxyUsername').value;
|
config.ProxyUsername = document.querySelector('#ProxyUsername').value;
|
||||||
|
|||||||
@ -461,4 +461,38 @@ public class StreamProxyController : ControllerBase
|
|||||||
return StatusCode(StatusCodes.Status502BadGateway);
|
return StatusCode(StatusCodes.Status502BadGateway);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Generates a placeholder image with the given text centered.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="text">The text to display (base64 encoded).</param>
|
||||||
|
/// <returns>A PNG image with the text centered.</returns>
|
||||||
|
[HttpGet("Placeholder")]
|
||||||
|
[AllowAnonymous]
|
||||||
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||||
|
public IActionResult GetPlaceholder([FromQuery] string text)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(text))
|
||||||
|
{
|
||||||
|
return BadRequest("Text parameter is required");
|
||||||
|
}
|
||||||
|
|
||||||
|
string decodedText;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogDebug("Generating placeholder image for: {Text}", decodedText);
|
||||||
|
|
||||||
|
var imageStream = PlaceholderImageGenerator.GeneratePlaceholder(decodedText);
|
||||||
|
return File(imageStream, "image/png");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
141
Jellyfin.Plugin.SRFPlay/Utilities/PlaceholderImageGenerator.cs
Normal file
141
Jellyfin.Plugin.SRFPlay/Utilities/PlaceholderImageGenerator.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
12
README.md
12
README.md
@ -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
BIN
assests/main logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 20 KiB |
37
manifest.json
Normal file
37
manifest.json
Normal 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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
Loading…
x
Reference in New Issue
Block a user